Compare commits

...

31 Commits

Author SHA1 Message Date
mAi
af7a1e9c56 mAi: #108 - t-paliad-276 submission generator language selector (DE/EN)
Per-draft `language` column drives the .docx output language for the
submission generator. The lawyer picks DE or EN on the draft editor's
sidebar; the generator selects the language-matched template variant
(falling back through {code}.{lang} → {code} → _skeleton.{lang} →
_skeleton → letterhead) and resolves language-aware variables
({{procedural_event.name}} → name_de vs name_en).

Schema (mig 130 — bumped from 129 to deconflict with atlas's #96):
- paliad.submission_drafts.language text NOT NULL DEFAULT 'de'
  CHECK IN ('de','en'). Existing rows inherit 'de' via the default,
  preserving every legacy draft's behaviour byte-for-byte.

Backend (Go):
- SubmissionVarsContext.Lang overrides the user's UI lang. Build()
  uses it when set; falls back to user.Lang otherwise — Slice 1's
  format-only /generate path keeps working unchanged.
- SubmissionDraftService.BuildRenderBag now threads draft.Language
  through. Create/EnsureLatest seed from the UI lang (DE default).
- DraftPatch.Language landed; Update validates and rejects values
  outside {de,en}. Project-scoped + global PATCH endpoints both
  surface the field.
- resolveSubmissionTemplate(ctx, code, lang) replaces the lang-less
  predecessor. Returns the matched tier (per_code_lang / per_code /
  skeleton_lang / skeleton / letterhead) so the editor knows whether
  to surface the "Fallback: universelles Skelett" notice.
- fileRegistry registers the EN skeleton sibling (`_skeleton.en.docx`)
  alongside the DE one; per-code EN variants land in a parallel
  submissionTemplateENRegistry (empty for now — EN templates land per
  HLC authoring). 404s from Gitea fall through silently.
- /api/projects/{id}/submissions/{code}/generate accepts
  `?language=de|en` query override (one-shot path, no draft row to
  pull the column from); defaults to the user's UI lang.

Frontend (TS/JSX):
- DE/EN radio above the variables list in the draft editor sidebar.
  Switching the radio PATCHes `language` and the server returns the
  freshly-resolved bag + preview HTML so the lawyer sees EN values
  immediately.
- Fallback notice ("Fallback: universelles Skelett (keine
  sprachspezifische Vorlage)") shows when the resolved tier doesn't
  match the requested language.
- 4 new i18n keys (DE + EN) + CSS for the toggle.

Tests:
- normalizeDraftLanguage covers DE/EN/case/whitespace/unknown.
- addRuleVars language-pick test pins procedural_event.name and the
  rule.name alias to the language-matched value.
- languageFallback truth table covers all 10 (lang × tier) combos.

Build hygiene: go build/vet/test clean; bun run build clean.
2026-05-25 16:45:04 +02:00
mAi
930771a898 Merge: t-paliad-275 — HL-formatted skeleton template with placeholders (m/paliad#107) 2026-05-25 16:37:34 +02:00
mAi
f2fbf93adf feat(submissions): HL-formatted skeleton template with placeholders (t-paliad-275)
Adds a firm-formatted Schriftsatz skeleton between the per-submission_code
template and the generic universal skeleton in the fallback chain. Carries
every HL paragraph + character style from the HL Patents Style .dotm
(HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
HLpat-Table-Recitals-Party/Details/Roles/Sequencers, HLpat-Signature,
HLpat-Requests-Intro/Level1, HLpat-EvidenceOffering, …) and the firm
letterhead (header logo + firm-address footer), plus the full 48-key
SubmissionVarsService placeholder bag exercised in a real Schriftsatz
layout (rubrum → Betreff → Anträge → Sachverhalt → Rechtsausführungen →
Beweis → Schlussformel) with a locale-aware verification footer covering
every DE/EN alias and the rule.* legacy keys.

Resolved fallback chain after this CL:

  1. per-firm per-submission_code template (submissionTemplateRegistry)
  2. _firm-skeleton.docx — HL styles + placeholders (NEW)
  3. universal _skeleton.docx — placeholders only
  4. HL Patents Style.dotm — letterhead only

scripts/gen-hl-skeleton-template/main.go reads the source .dotm,
strips VBA macros + ribbon customizations + glossary parts, patches
[Content_Types].xml and the document rels, and replaces document.xml
with HL-styled paragraphs containing the placeholders. Keeps styles.xml,
theme/, header[12].xml, footer[12].xml, numbering.xml, settings.xml,
fontTable.xml, and media untouched so the firm typography survives.

Template uploaded to HL/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
(commit 0a41b45, blob SHA 07f7547d).

Verified end-to-end against the in-house renderer with a 48-key sample
project: every placeholder substitutes cleanly, no orphan {{ markers,
no VBA / glossary / customUI leftovers, header/footer rIds resolve.
2026-05-25 16:35:38 +02:00
mAi
7368e7012b Merge: t-paliad-274 — bidirectional draft editor link + click-field-highlights (m/paliad#106) 2026-05-25 16:34:28 +02:00
mAi
d4df81e374 mAi: #106 - t-paliad-274 — bidirectional draft editor link + click-field-highlights
Extension of #92 (m/paliad/issues/106). Two related polish fixes for the
submission draft editor's preview ↔ sidebar wiring.

Concern A — link persists after fill (regression coverage + UX visibility)

  Audited the Go renderer: substituteInTextNodes / substituteAcrossRuns
  already pass both filled and missing values through htmlPreviewWrapper,
  so the <span class="draft-var" data-var="…"> wrapping is present for
  every substituted placeholder regardless of source (resolved bag,
  lawyer override, missing marker). What looked broken to m was a
  visibility problem: the always-on rgba(198, 244, 28, 0.12) tint is
  imperceptible against the serif preview prose, so a filled value
  reads as plain text and the user concludes "the link is gone".

  Added TestRenderHTML_WrapsOverriddenValueSameAsResolved that pins the
  invariant explicitly — an override (project.case_number = "UPC_CFI_
  42/2026") and a resolved value (firm.name = "HLC") both end up in
  matching draft-var spans. Locks future refactors out of dropping the
  wrap on either path.

  CSS rewrite per m's "prose stays clean when not interacting" guidance
  (issue body): drop the always-on background; on hover of a
  --has-input span, layer a dotted-underline + brighter lime tint so
  the click affordance reveals itself. Missing markers carry their own
  [KEIN WERT: …] / [NO VALUE: …] gap-text and don't need extra visual.

Concern B — sidebar-field-focus → preview-occurrence highlight (new)

  Reverse direction of the click-to-jump from #92. focusin on any
  .submission-draft-var-input applies .draft-var--active to every
  matching span in the preview; focusout (or focus shift via Tab)
  clears them. Sticky-while-focused, not a one-shot flash — the lawyer
  can scan "where does this variable land in my prose?" while the
  field stays focused.

  New CSS class .draft-var--active uses a brighter lime + box-shadow
  ring so all occurrences pop at once. Handlers are wired in
  paintVariables and re-applied at the end of both paintVariables AND
  paintPreview because:
    - paintVariables runs after autosave and re-creates inputs via
      innerHTML, so the focusin listener attached to the old input is
      gone; restoreVarFocus puts focus back programmatically without
      firing focusin again. We re-apply explicitly to bridge.
    - paintPreview blows away the preview HTML on every autosave, so
      any prior --active class is gone too. Re-apply based on the
      currently-focused sidebar input.

Files

  internal/services/submission_merge_test.go — new regression test
  frontend/src/client/submission-draft.ts    — focus handlers + re-apply
  frontend/src/styles/global.css             — draft-var rewrite, --active

Hard rules

  - .docx export path unchanged (Render passes nil wrap, covered by
    existing TestRender_DocxOutputUnchangedByPreviewWrap).
  - Both directions survive autosave-driven preview re-renders (see
    paintPreview re-apply + paintVariables re-apply).
  - go build ./... && go test ./internal/... && bun run build all clean.
2026-05-25 16:32:45 +02:00
mAi
f4dee97493 hotfix: drop is_optional + condition_flag refs from mig 125 (both dropped in earlier mig; unblock prod) 2026-05-25 16:12:13 +02:00
mAi
7aed8e4ec5 Merge: t-paliad-271 — Tier 3 deadline-rule primitives Slice A (working_days + combine_op + before-mode, mig 128) (m/paliad#103) 2026-05-25 16:08:33 +02:00
mAi
b429dabf9e hotfix: drop is_mandatory ref from mig 125 (column removed in mig 091; was blocking prod boot) 2026-05-25 16:07:31 +02:00
mAi
d3c28009de mAi: #103 - t-paliad-271 Wave 2 Tier-3 Slice A — deadline-rule primitives
Implements three Tier 3 primitives from curie's bulletproof completeness
audit (docs/research-deadlines-completeness-2026-05-25.md §10 T3.1, T3.2,
T3.5), per m's 2026-05-25 15:29 steer to build the full primitives
instead of documenting workarounds.

Primitive 1 — duration_unit='working_days':
  Calculator walks day-by-day skipping weekends + court holidays via
  HolidayService.IsNonWorkingDay. Event day is not counted; result is
  always a working day for the (country, regime). Unlocks T1.8/T1.9
  modeling and the R.198 / R.213 alt leg.

Primitive 2 — combine_op='max' (and 'min'):
  When alt_duration_value + alt_duration_unit + combine_op are set, the
  calculator evaluates both legs and picks the later (max) or earlier
  (min) of the two adjusted end dates. The DB already had two rules
  shaped this way ('31d OR 20wd, whichever is longer' — R.198 / R.213);
  the calculator was silently dropping the alt leg.

Primitive 5 — timing='before' backward snap-to-working-day:
  For backward rules (R.109.1: 1 month before oral hearing; R.109.4:
  2 weeks before) the calculator now snaps to the PRECEDING working day
  when the computed cut-off lands on a weekend/holiday. Forward snap
  (the prior behavior) would push the cut-off past the statutory limit
  and miss the deadline. Adds HolidayService.AdjustForNonWorkingDays-
  Backward as the symmetric counterpart of AdjustForNonWorkingDays.

Migration 128 — DB schema:
  Adds CHECK constraints on deadline_rules.duration_unit and
  alt_duration_unit pinning the allowed set to days/weeks/months/
  working_days. Live data audited and passes (no rows excluded).

Tests (12 new + 1 flipped):
  - 5 working_days cases: forward over weekend, 20wd anchored on Fri,
    across Karfreitag/Ostermontag, across year boundary, backward
    from Friday, anchored on Saturday.
  - 2 backward snap cases: Sun → preceding Fri; cluster Sun → Sat →
    Karfreitag → Thu.
  - 4 combine_op cases: max with primary winning, max with alt winning
    over Christmas+Neujahr cluster, min with primary winning, NULL-alt
    short-circuit.
  - TestCalculateEndDate_BeforeTiming renamed and flipped from forward
    (Sun → Mon, the prior wrong behavior) to backward (Sun → Fri).

No regression on existing rules: every pre-existing days/weeks/months
'after' rule still computes the same date. Frontend build + full
go test ./internal/... clean.

Slot 128 assigned per next-available convention (mig 127 = Wave 0
Tier-0 fixes, mig 128 = Wave 2 Tier-3 Slice A primitives).
2026-05-25 16:06:35 +02:00
mAi
8be7af7cd6 Merge: t-paliad-262 Slice A — procedural-events prose-only rename + {{rule.X}}↔{{procedural_event.X}} bidirectional aliases (m/paliad#93) 2026-05-25 16:03:42 +02:00
mAi
d52995a4d6 feat(procedural-events): t-paliad-262 Slice A — prose-only rename (m/paliad#93)
Renames the procedural-event surface of paliad.deadline_rules from
"rule" wording to "procedural event" / "Verfahrensschritt" wording.
No DB change, no API change, no Go-type rename. Fully reversible.

m's locks via head (2026-05-25):
- Q1=C: cosmetic now, structural rework (Slice B) as planned t-paliad-273.
- Q2: umbrella term = procedural event / Verfahrensschritt.
- Q7: legacy {{rule.X}} placeholder aliases kept forever (@deprecated).
- Q9: Slice B filed as on-hold task immediately.

Changes:
- internal/services/submission_vars.go: emit procedural_event.* keys
  alongside legacy rule.* keys with identical values. Package + function
  comments updated. Function name kept (addRuleVars) to avoid coupling
  Slice A to the Go-type rename which is Slice B (B.5).
- internal/services/submission_vars_aliases_test.go (new): regression
  test asserts (a) every (canonical, legacy) key pair resolves to the
  same string for both DE and EN; (b) NULL source columns still emit
  both keys with "". Removing either guard surfaces here.
- frontend/src/client/submission-draft.ts: placeholder catalog now
  shows canonical procedural_event.* labels first; legacy rule.*
  entries kept as "(legacy)"-marked aliases.
- frontend/src/client/i18n.ts: admin labels updated in place
  ("Regeln verwalten" → "Verfahrensschritte verwalten", etc.) under
  existing admin.rules.* keys; canonical admin.procedural_events.*
  keys added with identical values so .tsx files can rebind in Slice B.
- frontend/src/i18n-keys.ts: auto-regenerated by build pipeline.

Design doc: docs/design-procedural-events-model-2026-05-25.md (shipped
on the inventor branch mai/cronus/inventor-procedural).

Slice B (planned, on-hold): t-paliad-273.
2026-05-25 16:03:03 +02:00
mAi
f0c343c638 Merge: t-paliad-267 — Auto-rule resolved name on its own row in deadline form (m/paliad#98) 2026-05-25 16:02:41 +02:00
mAi
f11390d18b Merge: t-paliad-270 — i18n event.title.approval_decided + member_role_changed (m/paliad#101) 2026-05-25 16:01:56 +02:00
mAi
aa2f4aacc6 mAi: #98 - move Auto-rule resolved name to its own row
The Auto-mode resolved rule name was rendered as an inline-flex pill
that sat visually crammed next to the [Eigene Regel eingeben] toggle.
Promote .rule-mode-auto to a full-width block-level flex row (width:
100%, margin-top: 0.35rem) so it sits cleanly on its own line beneath
the toggle, and render the rule label via the canonical
formatRuleLabelHTML helper so the citation gets the muted-secondary
styling from rule-label.ts.

Applies to both /deadlines/new and /deadlines/:id edit form. Custom
mode (free-text input) is unaffected — the input already filled the
column.

Refs: m/paliad#98 (t-paliad-267), addendum to t-paliad-258 / m/paliad#89.
2026-05-25 16:01:15 +02:00
mAi
3d985ef0c2 Merge: t-paliad-269 — Paliadin chat-bubble lifted above PWA bottom-nav on mobile (m/paliad#100) 2026-05-25 16:00:26 +02:00
mAi
f72e8a7b85 mAi: #101 - add missing event.title.approval_decided + member_role_changed i18n
The FilterBar project_event_kind chip cluster (frontend/src/client/
filter-bar/axes.ts) renders one chip per KnownProjectEventKind via
tDyn(`event.title.${kind}`), which falls back to the raw key when the
catalog is missing the entry. Two kinds were uncovered:

  - approval_decided      → "Genehmigung entschieden" / "Approval decided"
  - member_role_changed   → "Teamrolle geändert"      / "Team role changed"

Both are now present in DE + EN. i18n-keys.ts regenerated by the build.

Audit of KnownProjectEventKinds (filter_spec.go:200) vs. the catalog —
all 18 kinds now have DE + EN labels.
2026-05-25 16:00:17 +02:00
mAi
013facb9db mAi: #100 - paliadin trigger: lift above bottom-nav at <=767px (t-paliad-269)
The Paliadin floating-button trigger was overlapping the PWA bottom-nav
on mobile because its lift rule was scoped to @media (max-width: 640px)
while .bottom-nav itself appears at @media (max-width: 767px). Phones in
landscape and small tablets between those breakpoints saw the desktop
bottom: 20px and got covered by the navbar.

Two changes:
- Widen the trigger lift breakpoint to 767px (matches .bottom-nav).
- Replace hardcoded 72px with calc(var(--bottom-nav-height) + 16px +
  env(safe-area-inset-bottom, 0px)) so the math tracks the navbar
  height variable already used elsewhere (e.g. dashboard-save-toast).

The drawer's full-screen rule (.paliadin-widget-drawer width: 100vw)
stays at <=640px — only the trigger lift moves.

Desktop layout (bottom: 20px) unchanged; widget open/close animation
unchanged.
2026-05-25 15:58:38 +02:00
mAi
ff503ffc43 Merge: Wave 0 Tier-0 deadline-rule fixes — 13 UPDATEs + #99 SoC mapping (mig 127) from curie's #94 audit (m/paliad#94, m/paliad#99) 2026-05-25 15:57:15 +02:00
mAi
05f7ea2af5 mAi: #99 #94 - t-paliad-263 Wave 0 - Tier 0 deadline-rule corrections
Migration 127 lands curie's audit-doc Tier 0 sweep (docs/research-
deadlines-completeness-2026-05-25.md section 10) plus the UPC
Statement of Claim citation backfill from m/paliad#99.

14 single-row UPDATEs touching UPC + DE-LG + DPMA + EPA proceedings:

T0.1  upc.rev.cfi.defence      dur 3mo -> 2mo (RoP.049.1)
T0.2  upc.rev.cfi.rejoin       dur 2mo -> 1mo (RoP.052)
T0.3  upc.apl.merits.response  dur 2mo -> 3mo (RoP.235.1)
T0.4  de.inf.lg.beruf_begr     parent_id berufung -> NULL (ZPO 520.2)
T0.7  upc.rev.cfi.reply        citation backfill RoP.051
T0.9  upc.apl.merits.notice    citation RoP.220.1 -> RoP.224.1.a
T0.10 upc.apl.merits.grounds   citation RoP.220.1 -> RoP.224.2.a
T0.12 dpma.opp.dpma.erwiderung   flip is_court_set, drop PatG 59.3
T0.13 dpma.appeal.bpatg.begruendung flip is_court_set, drop PatG 75.1
T0.14 de.null.bpatg.erwidg     citation PatG 82.1 -> PatG 82.3
T0.15 de.null.bgh.begruendung  citation PatG 111.1 -> ZPO 520.2 (via PatG 117)
T0.16 de.null.bgh.erwiderung   flip is_court_set, recite as ZPO 521.2 (via PatG 117)
T0.17 epa.opp.opd.erwidg       flip is_court_set (EPO Guidelines D-IV 5.2)
#99   upc.inf.cfi.soc          backfill UPC RoP R.13(1) citation

T0.5 and T0.6 (de.inf.lg.replik / .duplik) shipped separately as
mig 124 (m/paliad#95). T0.8 / T0.11 dedup'd into T0.2 / T0.1 per
the audit doc.

Each UPDATE guarded by a WHERE clause matching only the pre-fix
row state (mig 095 convention) - re-apply against a DB carrying
the fix matches zero rows and no-ops, no duplicate deadline_rule_
audit entries on idempotent re-runs. Verification DO block at the
end RAISE EXCEPTIONs if any row remains in inconsistent state.

Applied to live youpc DB via Supabase MCP with audit_reason set
(13 rows touched - T0.4 also fired; all 14 verified in post-fix
shape via direct query). applied_migrations entry NOT pre-recorded;
the boot-time runner inserts version=127 cleanly on next deploy
because every guarded UPDATE no-ops at that point.

Build hygiene: go build / go test ./internal/... / bun run build
all clean (2824 i18n keys, no scan warnings). No code changes -
pure data migration.

Cites: UPC RoP (UPCRoP.013.1 / 049.1 / 051 / 052 / 224.1.a /
224.2.a / 235.1), PatG 82.3 / 117, ZPO 520.2 / 521.2, EPC R.79(1)
+ EPO Guidelines D-IV 5.2.
2026-05-25 15:56:19 +02:00
mAi
df2a1275cb Merge: t-paliad-272 — docker-compose: PALIAD_EXPORT_DIR env + paliad_exports volume (m/paliad#105) 2026-05-25 15:56:12 +02:00
mAi
3700d68c68 mAi: #105 - docker-compose: add PALIAD_EXPORT_DIR + paliad_exports volume
Slice A Backup Mode (m/paliad#77) needs PALIAD_EXPORT_DIR set on the web
container, otherwise /admin/backups returns 503. Declare it via env
interpolation with a sensible compose-level default and mount a named
volume so backups persist across container restarts.

- env: PALIAD_EXPORT_DIR=${PALIAD_EXPORT_DIR:-/var/lib/paliad/exports}
- volume mount: paliad_exports:/var/lib/paliad/exports
- top-level: declare paliad_exports volume (default driver)

Verified: `docker compose config` resolves cleanly,
`go build ./... && go test ./internal/...` clean,
`cd frontend && bun run build` clean (no code change).

Closes m/paliad#105 once Dokploy auto-redeploys.
2026-05-25 15:54:46 +02:00
mAi
e0c8401482 Merge: t-paliad-266 — event-type modal cross-cutting filter by court system (mig 125) (m/paliad#97) 2026-05-25 15:53:50 +02:00
mAi
247e9005db Merge: t-paliad-248 Slice A — symmetric date-range picker + filter-bar wiring (m/paliad#79)
# Conflicts:
#	frontend/src/client/filter-bar/axes.ts
2026-05-25 15:51:36 +02:00
mAi
e68b800d52 Merge: t-paliad-249 Slice A — inbox overhaul (project_event feed + read cursor + dispatch) (m/paliad#80) 2026-05-25 15:50:27 +02:00
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
a8e2bd8350 Merge: t-paliad-264 — de.inf.lg Replik/Duplik sequencing fix (mig 124, idempotent) (m/paliad#95) 2026-05-25 15:47:40 +02:00
mAi
8c94dccf83 mAi: #95 - t-paliad-264 - fix de.inf.lg Replik/Duplik sequencing
Replik and Duplik had parent_id = NULL with a 4-week placeholder
duration, so the projection anchored both off the proceeding's
trigger date (Klageerhebung) - both rows rendered at the same
calendar date AND before Klageerwiderung.

Migration 124 anchors Replik on Klageerwiderung
(de.inf.lg.erwidg) and Duplik on Replik, and marks both
is_court_set = true with legal_source DE.ZPO.273. The 4-week
placeholder duration is retained so the timeline gives a sane
notional date for each row; the lawyer overrides it with "Datum
setzen" once the court issues the actual period.

Each UPDATE is guarded by parent_id IS NULL so a re-apply against
a DB that already carries the fix no-ops cleanly (mig 095
convention). No new audit-log rows on idempotent re-runs.

Slot note: originally landed as 123 in an earlier iteration;
cronus's t-paliad-246 Backup-Mode migration won slot 123 in the
parallel merge race, so this migration shifted to slot 124.

ZPO citations in the migration comment per the t-paliad-264 brief:
  - Klageerhebung           - section 253 ZPO
  - Anzeige Verteidigungsbereitschaft - section 276 Abs. 1 S. 1 ZPO
  - Klageerwiderung         - section 276 Abs. 1 S. 2 + section 277 ZPO
  - Replik / Duplik         - vom Gericht bestimmte Frist
    (section 273 ZPO Anordnungskompetenz; section 282 ZPO
    prozessuale Foerderungspflicht)

Verified ordering for trigger 2026-05-25:
  Klage         2026-05-25 Mon
  Anzeige       2026-06-08 Mon
  Klageerwidg   2026-07-06 Mon
  Replik        2026-08-03 Mon
  Duplik        2026-08-31 Mon

Each row strictly later than the previous; Replik and Duplik no
longer collide on the same date and no longer precede the
Klageerwiderung.
2026-05-25 15:46:09 +02:00
mAi
90f5dd4b1b fix: t-paliad-266 — bump migration to slot 125 (123 taken by cronus #77 backups) 2026-05-25 15:40:24 +02:00
mAi
24f3baf61f mAi: #97 - t-paliad-266 — event-type modal: narrow cross-cutting trigger pills by court system
Cross-cutting Wiedereinsetzung sub-rows (PatG §123 / ZPO §233 /
EPC Art.122 / DPMA PatG §123 / UPC R.320) used to bypass the
forum-bucket chip selection by design — every chip combination
returned all five rows. m/paliad#97: chip the chips through
to triggers via legal_source inference.

  - mig 123 backfills the missing deadline_rules row for trigger
    207 (UPC R.320 Wiedereinsetzung, orphaned by mig 063 because
    mig 092 dropped event_deadlines before that path was seeded)
    and rebuilds paliad.deadline_search with a LEFT JOIN on
    deadline_rules so cross-cutting trigger pills carry their
    structured legal_source.
  - DeadlineSearchService gains ForumToLegalSourcePrefixes (10
    buckets → UPC. / DE.ZPO. / DE.PatG. / EU.EPC + EU.EPÜ)
    paralleling ForumToProceedingCodes. Rule pills still narrow
    by proceeding_code; trigger pills now narrow by legal_source
    LIKE prefix. Multiple chips union the prefix allow-list as
    expected.
  - Live golden-table test gains a Wiedereinsetzung×forum matrix
    plus a multi-chip union case, and the existing 4-pill assertion
    is updated to the now-5-pill state (mig 063 added trigger 207).

Branch: mai/hermes/gitster-event-type-modal.
2026-05-25 15:36:08 +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
54 changed files with 5310 additions and 338 deletions

View File

@@ -42,5 +42,14 @@ services:
- AICHAT_URL=${AICHAT_URL:-}
- AICHAT_TOKEN=${AICHAT_TOKEN:-}
- AICHAT_PERSONA=${AICHAT_PERSONA:-paliadin}
# Backup Mode (m/paliad#77 Slice A). Local-disk export target; the
# paliad_exports named volume below persists it across container
# restarts. Unset → /admin/backups returns 503 (BackupService gate).
- PALIAD_EXPORT_DIR=${PALIAD_EXPORT_DIR:-/var/lib/paliad/exports}
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
volumes:
- paliad_exports:/var/lib/paliad/exports
restart: unless-stopped
volumes:
paliad_exports:

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

@@ -465,7 +465,8 @@ function refreshRuleAutoDisplay(): void {
panel.style.display = "";
const r = currentAutoRule();
if (r) {
text.textContent = formatRuleLabel(r);
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
text.innerHTML = formatRuleLabelHTML(r, esc);
text.classList.remove("rule-auto-text--empty");
return;
}

View File

@@ -8,7 +8,7 @@ import {
type PickerHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { formatRuleLabel } from "./rule-label";
import { formatRuleLabel, formatRuleLabelHTML } from "./rule-label";
let eventTypePicker: PickerHandle | null = null;
let currentUserAdmin = false;
@@ -192,7 +192,8 @@ function refreshRuleAutoDisplay(): void {
panel.style.display = "";
const rule = currentAutoRule();
if (rule) {
text.textContent = formatRuleLabel(rule);
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
text.innerHTML = formatRuleLabelHTML(rule, esc);
text.classList.remove("rule-auto-text--empty");
return;
}

View File

@@ -18,7 +18,7 @@ import {
type TimeHorizon as DRPTimeHorizon,
type TimeSpec as DRPTimeSpec,
} from "../date-range-picker-pure";
import type { BarState, AxisKey } from "./types";
import type { BarState, AxisKey, InboxFocus } from "./types";
export interface AxisCtx {
// Read the current value for this axis.
@@ -53,6 +53,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
@@ -493,6 +495,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

@@ -104,4 +104,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

@@ -1117,6 +1117,10 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.appointment_updated": "Termin ge\u00e4ndert",
"event.title.appointment_deleted": "Termin gel\u00f6scht",
"event.title.appointment_project_changed": "Termin verschoben",
// Umbrella audit kind + admin churn surfaced by the FilterBar
// project_event_kind chip cluster (KnownProjectEventKinds).
"event.title.approval_decided": "Genehmigung entschieden",
"event.title.member_role_changed": "Teamrolle ge\u00e4ndert",
// 4-eye approval lifecycle (t-paliad-138). Verlauf renders these as
// a paired card with the original lifecycle event (e.g.
// "Frist angelegt" + "Genehmigung erteilt von Bert").
@@ -1469,6 +1473,11 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.name.placeholder": "Name dieses Entwurfs",
"submissions.draft.preview.title": "Vorschau",
"submissions.draft.preview.hint": "Read-only Vorschau — finale Bearbeitung in Word.",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Sprache",
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
// t-paliad-240 — global Schriftsätze drafts index page.
"submissions.index.title": "Schriftsätze — Paliad",
"submissions.index.heading": "Schriftsätze",
@@ -2239,6 +2248,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",
@@ -2827,21 +2850,26 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.save.error.network": "Netzwerkfehler — bitte erneut versuchen.",
// t-paliad-192 Slice 11b — Admin rule-editor UI.
"nav.admin.rules": "Regeln verwalten",
"nav.admin.rules_export": "Regel-Migrations",
"admin.card.rules.title": "Regeln verwalten",
"admin.card.rules.desc": "Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
// (URL change is Slice B.6); the visible labels rename. Canonical
// `admin.procedural_events.*` aliases live after the EN block — they
// pin the contract for when .tsx files rebind in Slice B (B.5).
"nav.admin.rules": "Verfahrensschritte verwalten",
"nav.admin.rules_export": "Verfahrensschritt-Migrations",
"admin.card.rules.title": "Verfahrensschritte verwalten",
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
"admin.rules.list.title": "Regeln verwalten — Paliad",
"admin.rules.list.heading": "Regeln verwalten",
"admin.rules.list.subtitle": "Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ Neue Regel",
"admin.rules.list.title": "Verfahrensschritte verwalten — Paliad",
"admin.rules.list.heading": "Verfahrensschritte verwalten",
"admin.rules.list.subtitle": "Verfahrensschritte (Schriftsätze, Anhörungen, Entscheidungen, …) anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ Neuer Verfahrensschritt",
"admin.rules.list.export": "Migrations exportieren",
"admin.rules.tab.rules": "Regeln",
"admin.rules.tab.orphans": "Orphans",
"admin.rules.loading": "Lade…",
"admin.rules.empty": "Keine Regeln für die gewählten Filter.",
"admin.rules.error.load": "Konnte Regeln nicht laden.",
"admin.rules.empty": "Keine Verfahrensschritte für die gewählten Filter.",
"admin.rules.error.load": "Konnte Verfahrensschritte nicht laden.",
"admin.rules.filter.proceeding": "Verfahrenstyp",
"admin.rules.filter.proceeding.any": "Alle",
@@ -2852,7 +2880,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.search": "Suche",
"admin.rules.filter.search.placeholder": "Name, Submission Code, Rechtsgrundlage…",
"admin.rules.col.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.col.submission_code": "Code (Verfahrensschritt)",
"admin.rules.col.legal_citation": "Rechtsgrundlage",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Verfahrenstyp",
@@ -2882,8 +2910,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.orphans.reason.manual_unbound": "Manuell entkoppelt",
"admin.rules.orphans.resolved": "Orphan zugeordnet.",
"admin.rules.modal.new.title": "Neue Regel anlegen",
"admin.rules.modal.new.body": "Eine neue Regel wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.",
"admin.rules.modal.new.title": "Neuen Verfahrensschritt anlegen",
"admin.rules.modal.new.body": "Ein neuer Verfahrensschritt wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.",
"admin.rules.modal.resolve.title": "Orphan zuordnen",
"admin.rules.modal.resolve.body": "Bitte einen Grund (mind. 10 Zeichen) angeben. Die Regel-Verknüpfung wird sofort auf der Deadline gespeichert.",
"admin.rules.modal.reason": "Grund",
@@ -2898,12 +2926,12 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.modal.error.create": "Anlegen fehlgeschlagen.",
"admin.rules.modal.error.resolve": "Zuordnung fehlgeschlagen.",
"admin.rules.edit.title": "Regel bearbeiten — Paliad",
"admin.rules.edit.heading.loading": "Regel laden…",
"admin.rules.edit.breadcrumb": "← Regeln verwalten",
"admin.rules.edit.error.bad_id": "Ungültige Regel-ID in der URL.",
"admin.rules.edit.error.not_found": "Regel nicht gefunden.",
"admin.rules.edit.error.load": "Konnte Regel nicht laden.",
"admin.rules.edit.title": "Verfahrensschritt bearbeiten — Paliad",
"admin.rules.edit.heading.loading": "Verfahrensschritt laden…",
"admin.rules.edit.breadcrumb": "← Verfahrensschritte verwalten",
"admin.rules.edit.error.bad_id": "Ungültige Verfahrensschritt-ID in der URL.",
"admin.rules.edit.error.not_found": "Verfahrensschritt nicht gefunden.",
"admin.rules.edit.error.load": "Konnte Verfahrensschritt nicht laden.",
"admin.rules.edit.section.identity": "Identität",
"admin.rules.edit.section.proceeding": "Verfahren & Trigger",
@@ -2916,14 +2944,14 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Beschreibung",
"admin.rules.edit.field.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.edit.field.submission_code": "Code (Verfahrensschritt-Identifikator)",
"admin.rules.edit.field.rule_code": "Rechtsgrundlage (Kurzform)",
"admin.rules.edit.field.legal_source": "Rechtsgrundlage (Langform)",
"admin.rules.edit.field.proceeding": "Verfahrenstyp",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger-Ereignis",
"admin.rules.edit.field.trigger.none": "—",
"admin.rules.edit.field.parent": "Parent-Regel (UUID)",
"admin.rules.edit.field.parent": "Übergeordneter Verfahrensschritt (UUID)",
"admin.rules.edit.field.concept": "Konzept (UUID)",
"admin.rules.edit.field.sequence_order": "Reihenfolge",
"admin.rules.edit.field.duration_value": "Dauer",
@@ -2935,7 +2963,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.alt_rule_code": "Alt-Rule-Code",
"admin.rules.edit.field.anchor_alt": "Alt-Anchor",
"admin.rules.edit.field.primary_party": "Primäre Partei",
"admin.rules.edit.field.event_type": "Event-Typ (frei)",
"admin.rules.edit.field.event_type": "Art des Verfahrensschritts (filing / hearing / decision / order)",
"admin.rules.edit.field.deadline_notes": "Hinweise (DE)",
"admin.rules.edit.field.deadline_notes_en": "Hinweise (EN)",
"admin.rules.edit.field.priority": "Priorität",
@@ -3046,6 +3074,21 @@ const translations: Record<Lang, Record<string, string>> = {
"date_range.custom.invalid": "Bis-Datum muss nach Von-Datum liegen.",
"date_range.custom.invalid_format": "Datum nicht erkannt (Format JJJJ-MM-TT).",
"date_range.custom.invalid_missing": "Bitte beide Datumsfelder ausfüllen.",
// t-paliad-262 Slice A — canonical `procedural_event` i18n contract.
// The values are identical to the legacy `admin.rules.*` keys above —
// these aliases let .tsx files rebind in Slice B (B.5) without
// touching DE/EN strings then. Adding/changing values? Update BOTH
// sides.
"admin.procedural_events.list.title": "Verfahrensschritte verwalten — Paliad",
"admin.procedural_events.list.heading": "Verfahrensschritte verwalten",
"admin.procedural_events.list.new": "+ Neuer Verfahrensschritt",
"admin.procedural_events.col.code": "Code (Verfahrensschritt)",
"admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten",
"admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)",
"admin.procedural_events.edit.field.event_kind": "Art des Verfahrensschritts (filing / hearing / decision / order)",
"admin.procedural_events.edit.field.parent": "Übergeordneter Verfahrensschritt (UUID)",
},
en: {
@@ -4129,6 +4172,10 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.appointment_updated": "Appointment updated",
"event.title.appointment_deleted": "Appointment deleted",
"event.title.appointment_project_changed": "Appointment moved",
// Umbrella audit kind + admin churn surfaced by the FilterBar
// project_event_kind chip cluster (KnownProjectEventKinds).
"event.title.approval_decided": "Approval decided",
"event.title.member_role_changed": "Team role changed",
// 4-eye approval lifecycle (t-paliad-138).
"event.title.deadline_approval_requested": "Approval requested",
"event.title.deadline_approval_approved": "Approval granted",
@@ -4478,6 +4525,11 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.switcher.label": "Draft",
"submissions.draft.name.placeholder": "Name of this draft",
"submissions.draft.preview.title": "Preview",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Language",
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
@@ -5240,6 +5292,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",
@@ -5826,21 +5892,22 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.save.error.network": "Network error — please retry.",
// t-paliad-192 Slice 11b — Admin rule-editor UI.
"nav.admin.rules": "Manage Rules",
"nav.admin.rules_export": "Rule Migrations",
"admin.card.rules.title": "Manage Rules",
"admin.card.rules.desc": "Author, edit and publish deadline rules. Audit log, preview, migration export.",
// t-paliad-262 Slice A — "Rule" relabelled as "Procedural event".
"nav.admin.rules": "Manage procedural events",
"nav.admin.rules_export": "Procedural-event migrations",
"admin.card.rules.title": "Manage procedural events",
"admin.card.rules.desc": "Author, edit and publish procedural-event templates. Audit log, preview, migration export.",
"admin.rules.list.title": "Manage Rules — Paliad",
"admin.rules.list.heading": "Manage Rules",
"admin.rules.list.subtitle": "Author, edit and publish deadline rules. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ New Rule",
"admin.rules.list.title": "Manage procedural events — Paliad",
"admin.rules.list.heading": "Manage procedural events",
"admin.rules.list.subtitle": "Author, edit and publish procedural events (filings, hearings, decisions, …). Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ New procedural event",
"admin.rules.list.export": "Export migrations",
"admin.rules.tab.rules": "Rules",
"admin.rules.tab.orphans": "Orphans",
"admin.rules.loading": "Loading…",
"admin.rules.empty": "No rules for the chosen filters.",
"admin.rules.error.load": "Could not load rules.",
"admin.rules.empty": "No procedural events for the chosen filters.",
"admin.rules.error.load": "Could not load procedural events.",
"admin.rules.filter.proceeding": "Proceeding type",
"admin.rules.filter.proceeding.any": "Any",
@@ -5851,7 +5918,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.search": "Search",
"admin.rules.filter.search.placeholder": "Name, submission code, legal citation…",
"admin.rules.col.submission_code": "Submission code",
"admin.rules.col.submission_code": "Code (procedural event)",
"admin.rules.col.legal_citation": "Legal citation",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Proceeding type",
@@ -5881,8 +5948,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.orphans.reason.manual_unbound": "Manually unbound",
"admin.rules.orphans.resolved": "Orphan resolved.",
"admin.rules.modal.new.title": "Create new rule",
"admin.rules.modal.new.body": "A new rule will be created as a draft. Please supply a reason (≥10 chars) — recorded in the audit log and exported into the migration file.",
"admin.rules.modal.new.title": "Create new procedural event",
"admin.rules.modal.new.body": "A new procedural event will be created as a draft. Please supply a reason (≥10 chars) — recorded in the audit log and exported into the migration file.",
"admin.rules.modal.resolve.title": "Resolve orphan",
"admin.rules.modal.resolve.body": "Please supply a reason (≥10 chars). The rule binding is persisted immediately on the deadline.",
"admin.rules.modal.reason": "Reason",
@@ -5897,12 +5964,12 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.modal.error.create": "Creation failed.",
"admin.rules.modal.error.resolve": "Resolution failed.",
"admin.rules.edit.title": "Edit Rule — Paliad",
"admin.rules.edit.heading.loading": "Loading rule…",
"admin.rules.edit.breadcrumb": "← Manage Rules",
"admin.rules.edit.error.bad_id": "Invalid rule id in URL.",
"admin.rules.edit.error.not_found": "Rule not found.",
"admin.rules.edit.error.load": "Could not load rule.",
"admin.rules.edit.title": "Edit procedural event — Paliad",
"admin.rules.edit.heading.loading": "Loading procedural event…",
"admin.rules.edit.breadcrumb": "← Manage procedural events",
"admin.rules.edit.error.bad_id": "Invalid procedural-event id in URL.",
"admin.rules.edit.error.not_found": "Procedural event not found.",
"admin.rules.edit.error.load": "Could not load procedural event.",
"admin.rules.edit.section.identity": "Identity",
"admin.rules.edit.section.proceeding": "Proceeding & Trigger",
@@ -5915,14 +5982,14 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Description",
"admin.rules.edit.field.submission_code": "Submission code",
"admin.rules.edit.field.submission_code": "Code (procedural-event identifier)",
"admin.rules.edit.field.rule_code": "Legal citation (short form)",
"admin.rules.edit.field.legal_source": "Legal citation (long form)",
"admin.rules.edit.field.proceeding": "Proceeding type",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger event",
"admin.rules.edit.field.trigger.none": "—",
"admin.rules.edit.field.parent": "Parent rule (UUID)",
"admin.rules.edit.field.parent": "Parent procedural event (UUID)",
"admin.rules.edit.field.concept": "Concept (UUID)",
"admin.rules.edit.field.sequence_order": "Order",
"admin.rules.edit.field.duration_value": "Duration",
@@ -5934,7 +6001,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.alt_rule_code": "Alt rule code",
"admin.rules.edit.field.anchor_alt": "Alt anchor",
"admin.rules.edit.field.primary_party": "Primary party",
"admin.rules.edit.field.event_type": "Event type (free)",
"admin.rules.edit.field.event_type": "Procedural-event kind (filing / hearing / decision / order)",
"admin.rules.edit.field.deadline_notes": "Notes (DE)",
"admin.rules.edit.field.deadline_notes_en": "Notes (EN)",
"admin.rules.edit.field.priority": "Priority",
@@ -6042,6 +6109,19 @@ const translations: Record<Lang, Record<string, string>> = {
"date_range.custom.invalid": "End date must be strictly after start date.",
"date_range.custom.invalid_format": "Date not recognised (format YYYY-MM-DD).",
"date_range.custom.invalid_missing": "Please fill in both date fields.",
// t-paliad-262 Slice A — canonical `procedural_event` i18n contract.
// Mirrors the DE block; values identical to the legacy
// `admin.rules.*` keys. Adding/changing values? Update BOTH sides.
"admin.procedural_events.list.title": "Manage procedural events — Paliad",
"admin.procedural_events.list.heading": "Manage procedural events",
"admin.procedural_events.list.new": "+ New procedural event",
"admin.procedural_events.col.code": "Code (procedural event)",
"admin.procedural_events.edit.title": "Edit procedural event — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Manage procedural events",
"admin.procedural_events.edit.field.code": "Code (procedural-event identifier)",
"admin.procedural_events.edit.field.event_kind": "Procedural-event kind (filing / hearing / decision / order)",
"admin.procedural_events.edit.field.parent": "Parent procedural event (UUID)",
},
};

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

@@ -20,6 +20,9 @@ interface SubmissionDraftJSON {
submission_code: string;
user_id: string;
name: string;
// t-paliad-276 — per-draft output language ("de" or "en"). Drives the
// template-variant lookup and language-aware variable resolution.
language: string;
variables: Record<string, string>;
last_exported_at?: string | null;
last_exported_sha?: string | null;
@@ -46,6 +49,11 @@ interface SubmissionDraftView {
lang: string;
has_template: boolean;
template_missing?: boolean;
// t-paliad-276 — template-tier metadata used to surface the
// "Fallback: universelles Skelett" notice when the requested draft
// language has no per-firm language-matched template.
template_tier?: string;
language_fallback?: boolean;
}
interface SubmissionDraftListResponse {
@@ -155,14 +163,29 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
"rule.submission_code": { de: "Schriftsatz-Code", en: "Submission code" },
"rule.name": { de: "Schriftsatz", en: "Submission" },
"rule.name_de": { de: "Schriftsatz (DE)", en: "Submission (DE)" },
"rule.name_en": { de: "Schriftsatz (EN)", en: "Submission (EN)" },
"rule.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
"rule.legal_source_pretty": { de: "Rechtsgrundlage", en: "Legal source" },
"rule.primary_party": { de: "Partei (typisch)", en: "Primary party" },
"rule.event_type": { de: "Schriftsatz-Typ", en: "Event type" },
// Procedural-event namespace (t-paliad-262 Slice A, design doc
// docs/design-procedural-events-model-2026-05-25.md). The canonical
// placeholder names are below; the `rule.*` aliases that follow are
// @deprecated but kept forever per m's Q7 lock — existing Word
// templates and saved drafts authored with the old names keep
// merging identically.
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
@@ -174,14 +197,14 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
const VARIABLE_GROUPS: VariableGroup[] = [
{
id: "rule",
label: { de: "Schriftsatz", en: "Submission" },
id: "procedural_event",
label: { de: "Verfahrensschritt", en: "Procedural event" },
keys: [
"rule.name",
"rule.legal_source_pretty",
"rule.primary_party",
"rule.event_type",
"rule.submission_code",
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.code",
],
},
{
@@ -386,7 +409,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; language?: string }): Promise<SubmissionDraftView> {
const p = state.parsed;
if (!p.draftID) throw new Error("no draft id");
if (state.inFlight) {
@@ -436,6 +459,8 @@ function paint(): void {
paintNoProjectBanner();
paintSwitcher();
paintNameRow();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintPreview();
}
@@ -547,6 +572,63 @@ function paintNameRow(): void {
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
}
// paintLanguageRow syncs the DE/EN radio with the loaded draft's
// language. Switching the radio fires onLanguageChange which PATCHes
// the draft and lets the server return the freshly-resolved bag +
// preview HTML (so the lawyer sees the EN form names appear without a
// manual reload). t-paliad-276.
function paintLanguageRow(): void {
if (!state.view) return;
const lang = (state.view.draft.language || "de").toLowerCase();
const de = document.getElementById("submission-draft-language-de") as HTMLInputElement | null;
const en = document.getElementById("submission-draft-language-en") as HTMLInputElement | null;
if (de) {
de.checked = lang === "de";
de.onchange = () => { void onLanguageChange("de"); };
}
if (en) {
en.checked = lang === "en";
en.onchange = () => { void onLanguageChange("en"); };
}
}
// paintLanguageFallback shows / hides the "no language-matched
// template" notice. The server sets language_fallback=true when the
// resolved template tier doesn't match the draft's language
// (e.g. EN draft → DE per-code template, or no skeleton EN sibling).
function paintLanguageFallback(): void {
const el = document.getElementById("submission-draft-language-fallback");
if (!el) return;
const fallback = !!state.view?.language_fallback;
el.style.display = fallback ? "" : "none";
}
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
if (!state.view) return;
if ((state.view.draft.language || "de").toLowerCase() === lang) return;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ language: lang });
state.view = view;
// Repaint everything that depends on language: the DE/EN form
// values in the resolved bag, the localized rule name in the
// header, and the fallback notice.
paintHeader();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintPreview();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft language switch:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
// Revert the radio to the persisted value so the UI doesn't lie
// about which language is active.
paintLanguageRow();
}
}
function paintVariables(): void {
const host = document.getElementById("submission-draft-variables");
if (!host || !state.view) return;
@@ -595,10 +677,25 @@ function paintVariables(): void {
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
inp.addEventListener("input", () => onVarChange(inp));
// t-paliad-274 (B) — focus into a sidebar field highlights every
// matching .draft-var span in the preview (sticky while focused,
// clears on blur). Survives autosave repaints because paintVariables
// is called by flushAutosave and we re-bind every render.
inp.addEventListener("focusin", () => onVarFocusEnter(inp.dataset.var ?? ""));
inp.addEventListener("focusout", () => onVarFocusLeave(inp.dataset.var ?? ""));
});
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
});
// After repaint, re-apply the active highlight if a field is still
// focused (paintVariables runs after autosave; the same input regains
// focus via restoreVarFocus and would otherwise emit focusin too
// late for our handler — re-apply explicitly).
const active = document.activeElement;
if (isVarField(active)) {
const key = active.dataset.var;
if (key) applyPreviewActiveHighlight(key);
}
}
function paintPreview(): void {
@@ -606,6 +703,16 @@ function paintPreview(): void {
if (!host || !state.view) return;
host.innerHTML = state.view.preview_html ?? "";
wireDraftVars(host);
// t-paliad-274 (B) — preview HTML was just blown away by innerHTML,
// so any prior --active classes are gone. Re-apply for whichever
// sidebar field is currently focused (typing in a field triggers an
// autosave round-trip that ends in paintPreview, and the user should
// see the highlight stay put across that cycle).
const active = document.activeElement;
if (isVarField(active)) {
const key = active.dataset.var;
if (key) applyPreviewActiveHighlight(key);
}
}
// t-paliad-261 (B) — click a substituted variable in the preview to
@@ -680,6 +787,48 @@ function onDraftVarClick(key: string, ev: Event): void {
flashVarRow(input);
}
// t-paliad-274 (B) — sidebar-field-focus → preview-occurrence highlight.
// Reverse direction of the click-to-jump from #92: when the user focuses
// any .submission-draft-var-input, every matching .draft-var span in the
// preview gets the --active modifier; on blur (or focus shift to a
// different field), the previous key's highlights clear and the new
// key's apply. Sticky-while-focused, not a one-shot flash — the lawyer
// can scan the preview for "where does this variable land in my prose?"
// while the field stays focused.
function onVarFocusEnter(key: string): void {
if (!key) return;
// Clear any leftover highlight before applying the new one — covers
// the focus-shift-without-blur case (Tab between fields).
clearPreviewActiveHighlight();
applyPreviewActiveHighlight(key);
}
function onVarFocusLeave(_key: string): void {
// We don't need the key here — if focus moves to a different sidebar
// input, that input's focusin will re-call apply with the new key
// (after our clearPreviewActiveHighlight). If focus leaves the sidebar
// entirely, this clears.
clearPreviewActiveHighlight();
}
function applyPreviewActiveHighlight(key: string): void {
const host = document.getElementById("submission-draft-preview");
if (!host) return;
host.querySelectorAll<HTMLElement>(
`.draft-var[data-var="${cssEscape(key)}"]`,
).forEach((el) => {
el.classList.add("draft-var--active");
});
}
function clearPreviewActiveHighlight(): void {
const host = document.getElementById("submission-draft-preview");
if (!host) return;
host.querySelectorAll<HTMLElement>(".draft-var--active").forEach((el) => {
el.classList.remove("draft-var--active");
});
}
function flashVarRow(input: HTMLElement): void {
const row = input.closest<HTMLElement>(".submission-draft-var-row");
if (!row) return;

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 {
@@ -233,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

@@ -290,6 +290,15 @@ export type I18nKey =
| "admin.partner_units.new.heading"
| "admin.partner_units.subtitle"
| "admin.partner_units.title"
| "admin.procedural_events.col.code"
| "admin.procedural_events.edit.breadcrumb"
| "admin.procedural_events.edit.field.code"
| "admin.procedural_events.edit.field.event_kind"
| "admin.procedural_events.edit.field.parent"
| "admin.procedural_events.edit.title"
| "admin.procedural_events.list.heading"
| "admin.procedural_events.list.new"
| "admin.procedural_events.list.title"
| "admin.rules.col.legal_citation"
| "admin.rules.col.lifecycle"
| "admin.rules.col.modified"
@@ -1604,6 +1613,7 @@ export type I18nKey =
| "event.title.appointment_deleted"
| "event.title.appointment_project_changed"
| "event.title.appointment_updated"
| "event.title.approval_decided"
| "event.title.checklist_created"
| "event.title.checklist_deleted"
| "event.title.checklist_linked"
@@ -1622,6 +1632,7 @@ export type I18nKey =
| "event.title.deadline_reopened"
| "event.title.deadline_updated"
| "event.title.deadlines_imported"
| "event.title.member_role_changed"
| "event.title.note_created"
| "event.title.our_side_changed"
| "event.title.project_archived"
@@ -1790,9 +1801,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"
@@ -2582,6 +2599,10 @@ export type I18nKey =
| "submissions.draft.action.export"
| "submissions.draft.action.new"
| "submissions.draft.back"
| "submissions.draft.language"
| "submissions.draft.language.de"
| "submissions.draft.language.en"
| "submissions.draft.language.fallback_notice"
| "submissions.draft.loading"
| "submissions.draft.name.placeholder"
| "submissions.draft.notfound"
@@ -2711,12 +2732,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"
@@ -2724,6 +2750,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"
@@ -2753,6 +2780,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

@@ -5774,6 +5774,40 @@ dialog.modal::backdrop {
color: var(--color-danger, #c00);
}
/* t-paliad-276 — DE/EN language toggle on the draft editor. Same look
as the rest of the sidebar mini-controls; muted label + inline radios
so it doesn't compete with the editor's primary inputs. */
.submission-draft-language-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.25rem 0 0.5rem 0;
font-size: 0.9em;
}
.submission-draft-language-label {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.85em;
}
.submission-draft-language-option {
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
}
.submission-draft-language-fallback {
font-size: 0.85em;
color: var(--color-text-muted);
margin: 0 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border-left: 2px solid var(--color-warning, #d4a017);
background: var(--color-warning-bg, rgba(212, 160, 23, 0.08));
}
.submission-draft-variables {
display: flex;
flex-direction: column;
@@ -5880,16 +5914,27 @@ 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. */
/* t-paliad-261 / t-paliad-274 — substituted variables in the preview
are wrapped in <span class="draft-var" data-var="…"> by the Go HTML
renderer for BOTH filled values and missing-marker text. The lawyer
can click any wrapped span and jump to the matching sidebar input;
conversely, focusing a sidebar input lights up every matching span in
the preview via .draft-var--active.
Visual contract:
.draft-var — invisible by default (prose stays clean
per t-paliad-274 m's request).
.draft-var--has-input — pointer cursor; dotted underline on
hover so the click affordance reveals
itself, plus a brighter lime tint.
.draft-var--active — sticky lime highlight applied while the
matching sidebar input is focused
(t-paliad-274 reverse direction).
[KEIN WERT: …] / [NO VALUE: …] markers carry their own warning
style via .submission-draft-var-marker on the sidebar hint; in the
preview they read as obvious gap text, so .draft-var itself doesn't
need an always-on visual to flag them. */
.draft-var {
background-color: rgba(198, 244, 28, 0.12);
border-radius: 2px;
padding: 0 2px;
box-decoration-break: clone;
@@ -5904,9 +5949,21 @@ dialog.modal::backdrop {
.draft-var--has-input:hover,
.draft-var--has-input:focus-visible {
background-color: rgba(198, 244, 28, 0.45);
text-decoration: underline dotted rgba(198, 244, 28, 0.85);
text-underline-offset: 2px;
outline: none;
}
/* t-paliad-274 (B) — sticky highlight while the matching sidebar input
is focused. Brighter than the hover tint so the user's eye lands on
every occurrence at once when they click into a field. Applies to ALL
.draft-var spans for that data-var, not just one. */
.draft-var--active,
.draft-var--has-input.draft-var--active {
background-color: rgba(198, 244, 28, 0.55);
box-shadow: 0 0 0 1px rgba(198, 244, 28, 0.85);
}
/* 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
@@ -7690,11 +7747,16 @@ dialog.modal::backdrop {
/* 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-auto — read-only display, lime-tint chip + label.
.rule-mode-custom — free-text input, full-width.
Toggle button reuses .btn-link-action for the inline link styling. */
Toggle button reuses .btn-link-action for the inline link styling.
t-paliad-267 / m/paliad#98 — the auto display is now a block-level
row of its own so the resolved rule name sits on its own line
beneath the toggle, not crammed beside it. Width is content-sized
(align-self:flex-start within form-field's block flow keeps the
chip from spanning the whole form column gratuitously). */
.rule-mode-auto {
display: inline-flex;
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.55rem;
@@ -7702,6 +7764,9 @@ dialog.modal::backdrop {
border-left: 2px solid var(--color-accent);
border-radius: var(--radius-sm, 4px);
min-height: 2rem;
width: 100%;
box-sizing: border-box;
margin-top: 0.35rem;
}
.rule-auto-text {
color: var(--color-text);
@@ -15174,8 +15239,10 @@ dialog.quick-add-sheet::backdrop {
* Floating trigger at bottom-right + slide-out drawer from the
* right edge. Hidden by default; revealed by paliadin-widget.ts
* after /api/me confirms the caller is the Paliadin owner.
* Mobile (≤640px): drawer goes full-screen; trigger sits above
* the bottom-nav slots.
* Mobile (≤640px): drawer goes full-screen.
* Phone breakpoint (≤767px, matches .bottom-nav): trigger lifts
* above the bottom-nav slots so it doesn't collide with the
* navbar on PWA standalone (t-paliad-269).
*/
.paliadin-widget-trigger {
@@ -15262,8 +15329,20 @@ dialog.quick-add-sheet::backdrop {
.paliadin-widget-drawer {
width: 100vw;
}
}
/* Lift the trigger above the BottomNav at the same breakpoint where
the nav appears (<768px in global.css ".bottom-nav"). The navbar is
--bottom-nav-height tall plus the iOS safe-area inset; 16px gap
keeps the bubble clear without crowding the nav slots. Bubble sits
at the right edge so the center FAB-circle (margin-top: -10px) is
not in its column.
t-paliad-269: previously this rule was scoped to <=640px, but the
.bottom-nav shows at <=767px, leaving phones in landscape and small
tablets with an overlapping bubble. */
@media (max-width: 767px) {
.paliadin-widget-trigger {
bottom: calc(72px + env(safe-area-inset-bottom, 0px));
bottom: calc(var(--bottom-nav-height, 56px) + 16px + env(safe-area-inset-bottom, 0px));
}
}

View File

@@ -109,6 +109,47 @@ export function renderSubmissionDraft(): string {
</button>
</div>
{/* t-paliad-276 — output language toggle (DE/EN).
Hydrated by client/submission-draft.ts; switching
autosaves the draft and re-renders the preview. */}
<div
className="submission-draft-language-row"
id="submission-draft-language-row"
role="radiogroup"
aria-labelledby="submission-draft-language-label">
<span
id="submission-draft-language-label"
className="submission-draft-language-label"
data-i18n="submissions.draft.language">
Sprache
</span>
<label className="submission-draft-language-option">
<input
type="radio"
name="submission-draft-language"
value="de"
id="submission-draft-language-de"
/>
<span data-i18n="submissions.draft.language.de">DE</span>
</label>
<label className="submission-draft-language-option">
<input
type="radio"
name="submission-draft-language"
value="en"
id="submission-draft-language-en"
/>
<span data-i18n="submissions.draft.language.en">EN</span>
</label>
</div>
<p
className="submission-draft-language-fallback"
id="submission-draft-language-fallback"
style="display:none"
data-i18n="submissions.draft.language.fallback_notice">
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
</p>
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
<div className="submission-draft-variables" id="submission-draft-variables" />

View File

@@ -0,0 +1,31 @@
-- Revert t-paliad-264 / m/paliad#95.
-- Restores Replik and Duplik to parent_id = NULL with the pre-fix
-- "Frist vom Gericht bestimmt" placeholder note. The pre-fix rows
-- carried legal_source = NULL and is_court_set = false; both
-- placeholder durations (4 weeks) are left untouched (the .up
-- migration did not modify them).
--
-- audit_reason set_config required for the mig 079 trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 124 revert: unwind de.inf.lg Replik/Duplik sequencing back to pre-#95 placeholder state',
true);
UPDATE paliad.deadline_rules
SET parent_id = NULL,
is_court_set = false,
legal_source = NULL,
deadline_notes = 'Frist vom Gericht bestimmt',
deadline_notes_en = NULL
WHERE submission_code = 'de.inf.lg.replik'
AND is_active = true;
UPDATE paliad.deadline_rules
SET parent_id = NULL,
is_court_set = false,
legal_source = NULL,
deadline_notes = 'Frist vom Gericht bestimmt',
deadline_notes_en = NULL
WHERE submission_code = 'de.inf.lg.duplik'
AND is_active = true;

View File

@@ -0,0 +1,94 @@
-- t-paliad-264 / m/paliad#95 — Fix de.inf.lg Replik + Duplik sequencing.
--
-- BEFORE this migration, the de.inf.lg rules for Replik and Duplik
-- had parent_id = NULL with duration_value = 4 weeks each. The
-- projection therefore anchored both off the proceeding's trigger
-- date (Klageerhebung) and added 4 weeks → both rows rendered at the
-- same calendar date, BEFORE Klageerwiderung (which sits at
-- Klageerhebung + 6 weeks per § 276 Abs. 1 S. 2 ZPO).
--
-- Correct ZPO sequence for first-instance infringement before the
-- Landgericht is:
--
-- Klageerhebung (§ 253 ZPO)
-- → Anzeige der Verteidigungsbereitschaft (§ 276 Abs. 1 S. 1 ZPO,
-- 2 Wochen ab Zustellung der Klage)
-- → Klageerwiderung (§ 276 Abs. 1 S. 2 + § 277 ZPO; vom Gericht
-- gesetzte Frist von mindestens 2 Wochen, in der Praxis 6 Wochen)
-- → Replik (vom Gericht gesetzte Frist; Anordnungskompetenz aus
-- § 273 ZPO, prozessuale Förderungspflicht der Parteien aus
-- § 282 ZPO; in der Praxis ~ 4 Wochen ab Zustellung der
-- Klageerwiderung)
-- → Duplik (vom Gericht gesetzte Frist; § 273, § 282 ZPO; in der
-- Praxis ~ 4 Wochen ab Zustellung der Replik)
--
-- Replik and Duplik have NO statutory period — the Landgericht fixes
-- the period in its prozessleitende Verfügung. We model them as
-- is_court_set = true with a placeholder 4-week duration anchored on
-- the immediately preceding filing so the timeline (a) renders them
-- in strict chronological order and (b) gives the lawyer a sane
-- notional date that can be overridden via "Datum setzen" once the
-- court issues the actual period.
--
-- legal_source set to DE.ZPO.273 (Vorbereitung des Termins —
-- court's case-management power that authorises setting Replik /
-- Duplik periods). The full citation chain (§§ 273, 282 ZPO) lives
-- in deadline_notes so the rendered card explains the source.
--
-- Scope strictly de.inf.lg / cfi per the t-paliad-264 brief. Other
-- jurisdictions are out of scope and will be addressed via curie's
-- m/paliad#94 audit follow-ups (Wave 0+).
--
-- Slot note: this migration originally landed as 123 in an earlier
-- iteration; cronus's t-paliad-246 Backup-Mode migration won slot
-- 123 in parallel-merge order, so this one shifted to 124.
--
-- Idempotency: each UPDATE is guarded by a WHERE clause that only
-- matches the pre-fix row state (parent_id IS NULL on Replik /
-- Duplik, since that was the load-bearing bug). A re-apply against
-- a DB that already carries the fix matches zero rows and no-ops —
-- no duplicate audit-log rows in paliad.deadline_rule_audit, no
-- redundant writes. Mig 095 convention.
--
-- audit_reason set_config required at the top — the mig 079 trigger
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
-- on any UPDATE without it.
SELECT set_config(
'paliad.audit_reason',
'mig 124: t-paliad-264 / m/paliad#95 — anchor de.inf.lg Replik on Klageerwiderung and Duplik on Replik, mark both is_court_set per § 273 ZPO',
true);
-- Replik anchors on Klageerwiderung (de.inf.lg.erwidg).
-- Guard: parent_id IS NULL — only fires against the pre-fix shape.
UPDATE paliad.deadline_rules
SET parent_id = (
SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'de.inf.lg.erwidg'
AND is_active = true
LIMIT 1
),
is_court_set = true,
legal_source = 'DE.ZPO.273',
deadline_notes = 'Frist vom Gericht in der prozessleitenden Verfügung bestimmt (§ 273 ZPO, prozessuale Förderungspflicht § 282 ZPO). In der Praxis ca. 4 Wochen ab Zustellung der Klageerwiderung; mit "Datum setzen" überschreiben, sobald die gerichtliche Verfügung vorliegt.',
deadline_notes_en = 'Period set by the court in its case-management order (§ 273 ZPO; parties'' duty to file timely under § 282 ZPO). Typically ca. 4 weeks after service of the Statement of Defence; use "Set date" to override once the court issues the actual period.'
WHERE submission_code = 'de.inf.lg.replik'
AND is_active = true
AND parent_id IS NULL;
-- Duplik anchors on Replik (de.inf.lg.replik).
-- Guard: parent_id IS NULL — only fires against the pre-fix shape.
UPDATE paliad.deadline_rules
SET parent_id = (
SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'de.inf.lg.replik'
AND is_active = true
LIMIT 1
),
is_court_set = true,
legal_source = 'DE.ZPO.273',
deadline_notes = 'Frist vom Gericht in der prozessleitenden Verfügung bestimmt (§ 273 ZPO, prozessuale Förderungspflicht § 282 ZPO). In der Praxis ca. 4 Wochen ab Zustellung der Replik; mit "Datum setzen" überschreiben, sobald die gerichtliche Verfügung vorliegt.',
deadline_notes_en = 'Period set by the court in its case-management order (§ 273 ZPO; parties'' duty to file timely under § 282 ZPO). Typically ca. 4 weeks after service of the Reply; use "Set date" to override once the court issues the actual period.'
WHERE submission_code = 'de.inf.lg.duplik'
AND is_active = true
AND parent_id IS NULL;

View File

@@ -0,0 +1,103 @@
-- Down migration for 125_cross_cutting_filter_legal_source.up.sql.
--
-- Rebuilds the mig 098 matview shape (NULL legal_source on trigger
-- rows) and removes the trigger-207 backfill row. Two steps in
-- forward-reverse order so the matview drop doesn't trip on the
-- deadline_rules delete.
SELECT set_config(
'paliad.audit_reason',
'mig 125 down: revert cross-cutting filter legal_source (drop trigger-207 backfill + rebuild matview without LEFT JOIN to deadline_rules).',
true);
-- 1. Drop the matview before pulling rows underneath it.
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- 2. Delete the trigger 207 backfill row.
DELETE FROM paliad.deadline_rules
WHERE trigger_event_id = 207
AND sequence_order = 1207;
-- 3. Recreate the mig 098 matview verbatim (NULL legal_source on
-- trigger rows).
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);

View File

@@ -0,0 +1,216 @@
-- t-paliad-266 / m/paliad#97 — make cross-cutting trigger pills filter
-- by court system in the event-type / Fristen search modal.
--
-- Two things land here:
--
-- 1. DATA — backfill the missing deadline_rules row for trigger 207
-- (Wegfall des Hindernisses, UPC R.320). Mig 063 added the
-- trigger_event but never seeded its event_deadlines counterpart;
-- mig 092 then dropped event_deadlines after copying the four
-- sibling Wiedereinsetzungen (ids 200..203) into deadline_rules,
-- so trigger 207 stayed orphaned with no duration / legal_source.
-- Adding the row makes UPC R.320 Wiedereinsetzung calculable on
-- par with the four siblings (2 months from removal of obstacle,
-- legal_source = 'UPC.RoP.320', party = 'both') and gives the
-- matview a legal_source to surface for the UPC trigger pill.
-- Pattern mirrors the four sibling rows mig 085 inserted.
--
-- 2. MATVIEW — rebuild paliad.deadline_search with a LEFT JOIN on
-- paliad.deadline_rules for trigger pills, exposing the trigger's
-- legal_source on the row. The cross-cutting concept card pills
-- then carry a structured citation prefix (UPC.* / DE.ZPO.* /
-- DE.PatG.* / EU.EPC* / EU.EPÜ.*) that the search service can
-- match against the active forum-bucket filter — see
-- DeadlineSearchService.translateForums + ForumToLegalSourcePrefixes
-- (added in this same change). Without the matview surfacing
-- legal_source for trigger rows, every cross-cutting sub-row
-- ignored the court-system chip selection (the bug m reported).
--
-- The materialised view paliad.deadline_search refreshes on the next
-- server boot via services.RefreshSearchView (cmd/server/main.go), so
-- the new legal_source column for triggers becomes searchable as soon
-- as the deploy restarts the process. No matview refresh from the
-- migration itself.
SELECT set_config(
'paliad.audit_reason',
'mig 125: t-paliad-266 — backfill missing deadline_rules row for trigger 207 (UPC R.320 Wiedereinsetzung) and rebuild deadline_search matview so trigger pills carry legal_source (cross-cutting court-system filter, m/paliad#97).',
true);
-- =============================================================================
-- 1. Backfill: deadline_rules row for trigger 207.
--
-- Idempotency: gated on NOT EXISTS by (trigger_event_id, name). Mirrors
-- mig 085's guard so re-runs are no-ops once the row is present.
-- =============================================================================
INSERT INTO paliad.deadline_rules (
id,
proceeding_type_id,
parent_id,
trigger_event_id,
spawn_proceeding_type_id,
submission_code,
name,
name_en,
primary_party,
event_type,
is_court_set,
is_spawn,
duration_value,
duration_unit,
timing,
alt_duration_value,
alt_duration_unit,
combine_op,
rule_code,
deadline_notes,
deadline_notes_en,
legal_source,
condition_expr,
sequence_order,
is_active,
priority,
lifecycle_state,
draft_of,
published_at,
concept_id
)
SELECT
gen_random_uuid(),
NULL::integer,
NULL::uuid,
207,
NULL::integer,
NULL::text,
'Wiedereinsetzungsantrag (UPC R.320)',
'Petition for re-establishment of rights (UPC R.320)',
NULL::text,
NULL::text,
false,
false,
2,
'months',
'after',
NULL::integer,
NULL::text,
NULL::text,
NULL::text,
'Frist beträgt 2 Monate ab Wegfall des Hindernisses (R.320 RoP). Spätestens 12 Monate nach Ablauf der versäumten Frist.',
'Period is 2 months from removal of the obstacle (UPC R.320 RoP). Latest 12 months after expiry of the missed deadline.',
'UPC.RoP.320',
NULL::jsonb,
1207,
true,
'mandatory',
'published',
NULL::uuid,
now(),
(SELECT id FROM paliad.deadline_concepts WHERE slug = 'wiedereinsetzung')
WHERE NOT EXISTS (
SELECT 1
FROM paliad.deadline_rules dr
WHERE dr.trigger_event_id = 207
);
-- =============================================================================
-- 2. Matview rebuild — LEFT JOIN deadline_rules on trigger_event_id so
-- cross-cutting trigger pills carry legal_source. Indexes reproduced
-- verbatim from mig 098 §5.
--
-- The trigger-row JOIN matches the Pipeline-C convention (mig 085 §2.5 /
-- mig 092 §2): each cross-cutting trigger has a single deadline_rules
-- row with proceeding_type_id IS NULL. A trigger event without that
-- row leaves legal_source NULL and the trigger pill keeps its current
-- "no jurisdiction filter match" semantics — same shape as before this
-- migration, just structurally surfaceable.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
dr_trig.legal_source AS legal_source,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
LEFT JOIN paliad.deadline_rules dr_trig
ON dr_trig.trigger_event_id = te.id
AND dr_trig.proceeding_type_id IS NULL
AND dr_trig.is_active
AND dr_trig.lifecycle_state = 'published'
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);

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

@@ -0,0 +1,146 @@
-- Revert t-paliad-263 Wave 0 + m/paliad#99.
-- Restores each Tier 0 row to its pre-fix state per
-- docs/research-deadlines-completeness-2026-05-25.md §10. T0.5 and
-- T0.6 are NOT reverted here — they live in mig 124's down.
--
-- audit_reason set_config required for the mig 079 trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 127 revert: unwind Tier 0 deadline-rule corrections (Wave 0 + #99)',
true);
-- T0.1 defence: 2mo + RoP.049.1 → 3mo + RoP.49.1
UPDATE paliad.deadline_rules
SET duration_value = 3,
rule_code = 'RoP.49.1',
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.defence'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.2 rejoin: 1mo + RoP.052/UPC.RoP.52 → 2mo + NULL/NULL
UPDATE paliad.deadline_rules
SET duration_value = 2,
rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.rejoin'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.3 response: 3mo + RoP.235.1 → 2mo + NULL
UPDATE paliad.deadline_rules
SET duration_value = 2,
rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'upc.apl.merits.response'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.4 beruf_begr: parent_id NULL → de.inf.lg.berufung
UPDATE paliad.deadline_rules
SET parent_id = (
SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'de.inf.lg.berufung'
AND is_active = true
AND lifecycle_state = 'published'
LIMIT 1
),
updated_at = now()
WHERE submission_code = 'de.inf.lg.beruf_begr'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.7 reply: clear citation
UPDATE paliad.deadline_rules
SET rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.reply'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.9 notice: revert citation
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.220.1',
legal_source = 'UPC.RoP.220.1',
updated_at = now()
WHERE submission_code = 'upc.apl.merits.notice'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.10 grounds: revert citation
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.220.1',
legal_source = 'UPC.RoP.220.1',
updated_at = now()
WHERE submission_code = 'upc.apl.merits.grounds'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.12 dpma.opp erwiderung: restore court-set=false + §59 citation
UPDATE paliad.deadline_rules
SET is_court_set = false,
rule_code = '§ 59 PatG',
legal_source = 'DE.PatG.59.3',
updated_at = now()
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.13 dpma.appeal.bpatg begründung: restore court-set=false + §75 citation
UPDATE paliad.deadline_rules
SET is_court_set = false,
rule_code = '§ 75 PatG',
legal_source = 'DE.PatG.75.1',
updated_at = now()
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.14 bpatg erwidg: revert citation
UPDATE paliad.deadline_rules
SET rule_code = '§ 82 PatG',
legal_source = 'DE.PatG.82.1',
updated_at = now()
WHERE submission_code = 'de.null.bpatg.erwidg'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.15 bgh begründung: revert citation
UPDATE paliad.deadline_rules
SET rule_code = '§ 111 PatG',
legal_source = 'DE.PatG.111.1',
updated_at = now()
WHERE submission_code = 'de.null.bgh.begruendung'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.16 bgh erwiderung: revert court-set + citation
UPDATE paliad.deadline_rules
SET is_court_set = false,
rule_code = '§ 111 PatG',
legal_source = 'DE.PatG.111.3',
updated_at = now()
WHERE submission_code = 'de.null.bgh.erwiderung'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.17 epa.opp opd erwidg: revert court-set
UPDATE paliad.deadline_rules
SET is_court_set = false,
updated_at = now()
WHERE submission_code = 'epa.opp.opd.erwidg'
AND is_active = true
AND lifecycle_state = 'published';
-- #99 upc.inf.cfi.soc: clear citation backfill
UPDATE paliad.deadline_rules
SET rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'upc.inf.cfi.soc'
AND is_active = true
AND lifecycle_state = 'published';

View File

@@ -0,0 +1,477 @@
-- t-paliad-263 Wave 0 + m/paliad#99 — Tier 0 deadline-rule corrections.
--
-- Source: docs/research-deadlines-completeness-2026-05-25.md §10 Tier 0
-- (curie's bulletproof completeness audit, merged 2026-05-25 as commit
-- 94a9e7e). 16 distinct single-row UPDATEs across UPC + DE-LG + DPMA +
-- EPA proceedings; T0.5 + T0.6 were shipped separately as mig 124
-- (m/paliad#95, de.inf.lg Replik/Duplik sequencing) and are not
-- repeated here. T0.8 (covered by T0.2) and T0.11 (covered by T0.1)
-- are dedup'd out per the audit's own note.
--
-- Also folds in m/paliad#99 (UPC Statement of Claim missing legal
-- citation): upc.inf.cfi.soc.rule_code / legal_source backfilled to
-- UPC RoP R.13(1). Same migration file, separate UPDATE block with
-- its own guard.
--
-- All fixes within the existing schema (no new columns). Each UPDATE
-- is guarded by a WHERE clause that matches only the pre-fix row
-- state (per mig 095 convention) — re-applying against a DB that
-- already carries the fix matches zero rows and no-ops, so there are
-- no duplicate deadline_rule_audit entries on idempotent re-runs.
--
-- Verification DO block at the end RAISEs EXCEPTION if any of the
-- patched rows is left in an inconsistent shape (mixing pre-fix and
-- post-fix state).
--
-- audit_reason set_config required at the top — the mig 079 trigger
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
-- on any UPDATE without it.
--
-- Slot 127 reserved per paliadin: sequence is 124 brunel #95 (done),
-- 125 hermes #97, 126 icarus #80, 127 brunel Wave 0 + #99, 128+ next.
SELECT set_config(
'paliad.audit_reason',
'mig 127: t-paliad-263 Wave 0 + m/paliad#99 — Tier 0 deadline-rule corrections from curie''s audit (docs/research-deadlines-completeness-2026-05-25.md §10) plus UPC SoC R.13 citation',
true);
-- =============================================================================
-- T0.1 upc.rev.cfi.defence — duration 3mo → 2mo per RoP.049.1.
-- Zero-pads the rule_code citation to canonical form. Audit §5
-- (wrong period — every UPC_REV tracked in paliad today computes
-- Defence at +3 months, statute says +2). Verbatim from
-- UPCRoP.049.1: "The defendant shall lodge a Defence to revocation
-- within two months of service of the Statement for revocation."
-- =============================================================================
UPDATE paliad.deadline_rules
SET duration_value = 2,
rule_code = 'RoP.049.1',
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.defence'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 3
AND rule_code = 'RoP.49.1';
-- =============================================================================
-- T0.2 upc.rev.cfi.rejoin — duration 2mo → 1mo per RoP.052; add citation.
-- Audit §5 (wrong period). Verbatim from UPCRoP.052: "Within one
-- month of the service of the Reply the defendant may lodge a
-- Rejoinder to the Reply to the Defence to revocation."
-- =============================================================================
UPDATE paliad.deadline_rules
SET duration_value = 1,
rule_code = 'RoP.052',
legal_source = 'UPC.RoP.52',
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.rejoin'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 2
AND rule_code IS NULL;
-- =============================================================================
-- T0.3 upc.apl.merits.response — duration 2mo → 3mo per RoP.235.1.
-- Audit §5 (wrong period — every main-track appellate respondent).
-- Verbatim from UPCRoP.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."
-- =============================================================================
UPDATE paliad.deadline_rules
SET duration_value = 3,
rule_code = 'RoP.235.1',
legal_source = 'UPC.RoP.235.1',
updated_at = now()
WHERE submission_code = 'upc.apl.merits.response'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 2
AND rule_code IS NULL;
-- =============================================================================
-- T0.4 de.inf.lg.beruf_begr — parent_id = NULL (was de.inf.lg.berufung).
-- Audit §7.1 — every DE-LG-Verletzung appeal renders the
-- Berufungsbegründung at trigger + 1mo (Berufung) + 2mo = 3 months
-- from Urteil-service. Per ZPO §520(2) "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 from Urteil-service (parallel to, not chained off, the
-- Berufungsfrist itself). NULL parent_id makes the rule anchor
-- on the proceeding's trigger date — matches how the symmetric
-- de.inf.olg.begruendung is modelled.
-- =============================================================================
UPDATE paliad.deadline_rules
SET parent_id = NULL,
updated_at = now()
WHERE submission_code = 'de.inf.lg.beruf_begr'
AND is_active = true
AND lifecycle_state = 'published'
AND parent_id = (
SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'de.inf.lg.berufung'
AND is_active = true
AND lifecycle_state = 'published'
LIMIT 1
);
-- =============================================================================
-- T0.5 / T0.6 de.inf.lg.replik + de.inf.lg.duplik — already shipped
-- as mig 124 (m/paliad#95). Not repeated here. Idempotency of the
-- audit's Tier 0 sweep against a fresh DB is preserved because mig
-- 124 runs before this one and is itself guarded.
-- =============================================================================
-- =============================================================================
-- T0.7 upc.rev.cfi.reply — backfill rule_code + legal_source per RoP.051.
-- Audit §4.1 — duration (2mo) unchanged. Verbatim from UPCRoP.051:
-- "Reply to Defence to revocation and Application to amend the
-- patent. The claimant in the revocation action may, within two
-- months of service of the Defence to revocation and the
-- Application to amend the patent, if any, lodge a Reply…"
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.051',
legal_source = 'UPC.RoP.51',
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.reply'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code IS NULL
AND legal_source IS NULL;
-- =============================================================================
-- T0.9 upc.apl.merits.notice — citation drift RoP.220.1 → RoP.224.1.a.
-- Audit §4.1 — duration unchanged. R.220.1 is the umbrella ("an
-- appeal may be brought"); R.224.1(a) carries the Notice-of-appeal
-- 2-month period explicitly.
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.224.1.a',
legal_source = 'UPC.RoP.224.1.a',
updated_at = now()
WHERE submission_code = 'upc.apl.merits.notice'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.220.1'
AND legal_source = 'UPC.RoP.220.1';
-- =============================================================================
-- T0.10 upc.apl.merits.grounds — citation drift RoP.220.1 → RoP.224.2.a.
-- Audit §4.1 — duration unchanged. R.224.2(a) sets the Grounds
-- 4-month period for decisions referred to in R.220.1(a) and (b).
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.224.2.a',
legal_source = 'UPC.RoP.224.2.a',
updated_at = now()
WHERE submission_code = 'upc.apl.merits.grounds'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.220.1'
AND legal_source = 'UPC.RoP.220.1';
-- =============================================================================
-- T0.12 dpma.opp.dpma.erwiderung — flip is_court_set = true; drop the
-- § 59(3) PatG citation. Audit §4.3 + §9.1: §59(3) addresses
-- Anhörung, not a 4-month response period. No statutory
-- Erwiderungsfrist exists in §59 — the 4-month figure is DPMA
-- practice (DPMA-Richtlinien D-IV 5.2). Modelled court-set, the
-- 4-month value remains the default-display heuristic the
-- lawyer overrides via "Datum setzen".
-- =============================================================================
UPDATE paliad.deadline_rules
SET is_court_set = true,
rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = false
AND legal_source = 'DE.PatG.59.3';
-- =============================================================================
-- T0.13 dpma.appeal.bpatg.begruendung — flip is_court_set = true; drop
-- the § 75 PatG citation. Audit §4.3 + §9.1: §75 PatG addresses
-- aufschiebende Wirkung only, not a Begründungsfrist. No fixed
-- Begründungsfrist for BPatG-Beschwerde exists in PatG §§73-80 —
-- the BPatG sets it in the individual case. 1-month default
-- retained as display heuristic.
-- =============================================================================
UPDATE paliad.deadline_rules
SET is_court_set = true,
rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = false
AND legal_source = 'DE.PatG.75.1';
-- =============================================================================
-- T0.14 de.null.bpatg.erwidg — citation DE.PatG.82.1 → DE.PatG.82.3.
-- Audit §4.4 — duration (2 months) is correct. §82(1) carries the
-- 1-month Erklärungsfrist ("sich darüber zu erklären"); the full
-- Klageerwiderung 2-month period lives in §82(3).
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = '§ 82 Abs. 3 PatG',
legal_source = 'DE.PatG.82.3',
updated_at = now()
WHERE submission_code = 'de.null.bpatg.erwidg'
AND is_active = true
AND lifecycle_state = 'published'
AND legal_source = 'DE.PatG.82.1';
-- =============================================================================
-- T0.15 de.null.bgh.begruendung — citation DE.PatG.111.1 →
-- DE.ZPO.520.2 (via PatG §117). Audit §4.4 — duration (3 months)
-- is correct. §111 PatG defines the Grounds of Berufung
-- (Verletzung des Bundesrechts), not a Begründungsfrist; the
-- 3-month figure is supplied by §117 PatG → ZPO §520(2).
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = '§ 520 Abs. 2 ZPO i.V.m. § 117 PatG',
legal_source = 'DE.ZPO.520.2',
updated_at = now()
WHERE submission_code = 'de.null.bgh.begruendung'
AND is_active = true
AND lifecycle_state = 'published'
AND legal_source = 'DE.PatG.111.1';
-- =============================================================================
-- T0.16 de.null.bgh.erwiderung — flip is_court_set = true; recite as
-- DE.ZPO.521.2 (via PatG §117). Audit §4.4 + §9.1 — §111 PatG
-- has no Erwiderungsfrist clause. The actual Erwiderungsfrist
-- for BGH-Nichtigkeitsberufung is set by the court per §117
-- PatG → ZPO §521(2). 2-month default retained as display
-- heuristic.
-- =============================================================================
UPDATE paliad.deadline_rules
SET is_court_set = true,
rule_code = '§ 521 Abs. 2 ZPO i.V.m. § 117 PatG',
legal_source = 'DE.ZPO.521.2',
updated_at = now()
WHERE submission_code = 'de.null.bgh.erwiderung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = false
AND legal_source = 'DE.PatG.111.3';
-- =============================================================================
-- T0.17 epa.opp.opd.erwidg — flip is_court_set = true. Audit §4.5 +
-- §9.1: R.79(1) EPÜ authorises the Opposition Division to set
-- the period, but does not specify a fixed 4 months. The 4-month
-- figure is administrative practice (EPO Guidelines D-IV 5.2).
-- Citation retained as the rule-of-authority for the OD's
-- discretion. 4-month default retained as display heuristic.
-- =============================================================================
UPDATE paliad.deadline_rules
SET is_court_set = true,
updated_at = now()
WHERE submission_code = 'epa.opp.opd.erwidg'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = false
AND legal_source = 'EU.EPC-R.79.1';
-- =============================================================================
-- m/paliad#99 upc.inf.cfi.soc — backfill UPC RoP R.13(1) citation.
-- The Statement of Claim is defined in UPC RoP R.13 (R.13.1
-- lists the required contents). The row carries no statutory
-- deadline (duration_value = 0, parent_id IS NULL — the SoC is
-- the originating filing that anchors the proceeding's trigger
-- date), but the catalog UI surfaces the rule citation in
-- result cards and the Type=Statement-of-Claim / Rule=Auto
-- resolution; both render blank today because rule_code +
-- legal_source are NULL. Backfill leaves duration / anchor /
-- party untouched.
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.013.1',
legal_source = 'UPC.RoP.13.1',
updated_at = now()
WHERE submission_code = 'upc.inf.cfi.soc'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code IS NULL
AND legal_source IS NULL;
-- =============================================================================
-- Hard assertions. Each touched row must end up in its post-fix
-- shape. Re-running the migration after a successful first run is a
-- no-op for the data but the assertions still pass because they
-- check the post-fix state.
-- =============================================================================
DO $$
DECLARE
v_count integer;
BEGIN
-- T0.1 defence: dur=2 + canonical zero-padded rule_code
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.rev.cfi.defence'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 2
AND rule_code = 'RoP.049.1';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.1: upc.rev.cfi.defence not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.2 rejoin: dur=1
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.rev.cfi.rejoin'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 1
AND rule_code = 'RoP.052';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.2: upc.rev.cfi.rejoin not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.3 response: dur=3
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.merits.response'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 3
AND rule_code = 'RoP.235.1';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.3: upc.apl.merits.response not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.4 beruf_begr: parent_id IS NULL
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'de.inf.lg.beruf_begr'
AND is_active = true
AND lifecycle_state = 'published'
AND parent_id IS NULL;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.4: de.inf.lg.beruf_begr not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.7 reply: citation backfilled
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.rev.cfi.reply'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.051'
AND legal_source = 'UPC.RoP.51';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.7: upc.rev.cfi.reply not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.9 notice: citation RoP.224.1.a
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.merits.notice'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.224.1.a'
AND legal_source = 'UPC.RoP.224.1.a';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.9: upc.apl.merits.notice not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.10 grounds: citation RoP.224.2.a
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.merits.grounds'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.224.2.a'
AND legal_source = 'UPC.RoP.224.2.a';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.10: upc.apl.merits.grounds not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.12 dpma.opp erwiderung: court-set, no citation
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = true
AND legal_source IS NULL
AND rule_code IS NULL;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.12: dpma.opp.dpma.erwiderung not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.13 dpma.appeal.bpatg begründung: court-set, no citation
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = true
AND legal_source IS NULL
AND rule_code IS NULL;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.13: dpma.appeal.bpatg.begruendung not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.14 bpatg erwidg: §82.3
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'de.null.bpatg.erwidg'
AND is_active = true
AND lifecycle_state = 'published'
AND legal_source = 'DE.PatG.82.3';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.14: de.null.bpatg.erwidg not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.15 bgh begründung: ZPO §520.2
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'de.null.bgh.begruendung'
AND is_active = true
AND lifecycle_state = 'published'
AND legal_source = 'DE.ZPO.520.2';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.15: de.null.bgh.begruendung not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.16 bgh erwiderung: court-set, ZPO §521.2
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'de.null.bgh.erwiderung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = true
AND legal_source = 'DE.ZPO.521.2';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.16: de.null.bgh.erwiderung not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.17 epa.opp opd erwidg: court-set
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'epa.opp.opd.erwidg'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = true;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.17: epa.opp.opd.erwidg not in post-fix shape (got % matches)', v_count;
END IF;
-- #99 upc.inf.cfi.soc: citation backfilled
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.soc'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.013.1'
AND legal_source = 'UPC.RoP.13.1';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 #99: upc.inf.cfi.soc not in post-fix shape (got % matches)', v_count;
END IF;
END $$;

View File

@@ -0,0 +1,11 @@
-- Revert t-paliad-271 Wave 2 Tier-3 Slice A — drop duration_unit /
-- alt_duration_unit CHECK constraints. Pre-mig-128 the columns accepted
-- arbitrary text, so dropping the CHECKs restores that shape exactly.
-- No data revert necessary — the constraint addition was purely
-- additive and validated against live data before adding.
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;

View File

@@ -0,0 +1,36 @@
-- t-paliad-271 Wave 2 Tier-3 Slice A — duration_unit CHECK constraint with
-- 'working_days' added to the allowed set.
--
-- Per docs/research-deadlines-completeness-2026-05-25.md Tier 3 Primitive 1
-- (T3.1) — the calculator gains a business-day arithmetic path for UPC RoP
-- R.198 / R.213 (and downstream for any rule that needs the 31d-OR-20wd
-- combine-max pattern). The schema currently accepts free-text on
-- duration_unit (no CHECK), which is why 'working_days' rows already exist
-- in the DB but were silently dropped by the calculator. Adding the CHECK
-- pins the contract and prevents typos.
--
-- alt_duration_unit gets the same constraint (NULL-tolerant) so the alt
-- path stays in lockstep with the primary path.
--
-- Idempotent: DROP CONSTRAINT IF EXISTS before ADD. Existing data was
-- audited via `SELECT DISTINCT duration_unit FROM paliad.deadline_rules`
-- on 2026-05-25 (returned only days/weeks/months) plus the two live
-- alt-unit rows already at 'working_days' — both shapes pass.
--
-- audit_reason set_config is NOT needed for DDL (mig 079 trigger fires on
-- INSERT/UPDATE/DELETE on the rows, not on ALTER TABLE).
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_duration_unit_check
CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days'));
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_alt_duration_unit_check
CHECK (alt_duration_unit IS NULL
OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days'));

View File

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

View File

@@ -0,0 +1,17 @@
-- t-paliad-276 / m/paliad#108: per-draft output language for the
-- Submissions generator.
--
-- The submission editor lets the lawyer pick DE or EN per draft so the
-- generator selects the matching template variant + resolves language-
-- aware variables ({{procedural_event.name_de}} vs _en). Default is
-- 'de' to match the primary-language convention in CLAUDE.md and to
-- keep existing rows behaving exactly as before (every legacy draft
-- was implicitly DE; the resolved bag for those drafts is unchanged
-- under language='de').
ALTER TABLE paliad.submission_drafts
ADD COLUMN IF NOT EXISTS language text NOT NULL DEFAULT 'de'
CONSTRAINT submission_drafts_language_check CHECK (language IN ('de', 'en'));
COMMENT ON COLUMN paliad.submission_drafts.language IS
't-paliad-276: output language for the generated .docx. ''de'' or ''en''. Drives template variant selection ({code}.{lang}.docx fallback chain) and language-aware variable resolution.';

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

View File

@@ -79,6 +79,38 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
},
// Firm-formatted skeleton (t-paliad-275). Carries the same 48-key
// placeholder bag as the universal _skeleton.docx, but additionally
// preserves every HL paragraph + character style from the HL Patents
// Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
// HLpat-Table-Recitals-*, HLpat-Signature, …) and the firm letterhead
// (header logo + firm-address footer). Slotted ahead of the universal
// skeleton in the fallback chain so any submission_code without a
// dedicated per-code template still renders as a real firm-branded
// Schriftsatz with variables substituted, rather than a plain skeleton.
// Generated via scripts/gen-hl-skeleton-template against the .dotm.
firmSkeletonSubmissionSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
DownloadName: branding.Name + " — Firm Schriftsatz-Skelett.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
},
// English skeleton variant (t-paliad-276). Sibling of
// `_skeleton.docx`; used when a draft's language='en' and no
// per-code EN template exists. If the file isn't authored yet in
// mWorkRepo, the Gitea fetch fails and resolveSubmissionTemplate
// falls through to the DE skeleton — visible to the user as the
// "Fallback: universelles Skelett" notice on the draft editor.
skeletonSubmissionENSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
DownloadName: branding.Name + " — Submission skeleton.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
},
}
// skeletonSubmissionSlug names the universal skeleton template inside
@@ -87,6 +119,19 @@ var fileRegistry = map[string]fileEntry{
// the same string the registry uses.
const skeletonSubmissionSlug = "submission/_skeleton.docx"
// firmSkeletonSubmissionSlug names the firm-formatted skeleton template
// inside the shared fileRegistry cache (t-paliad-275). Same placeholder
// surface as skeletonSubmissionSlug; carries HL paragraph + character
// styles from the source .dotm on top. Sits between the per-code
// template and the generic universal skeleton in the fallback chain so
// codes without a dedicated template still render with firm branding.
const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx"
// skeletonSubmissionENSlug names the English skeleton variant used when
// a draft's language='en' and no per-code EN template exists
// (t-paliad-276). Same role as skeletonSubmissionSlug but in EN.
const skeletonSubmissionENSlug = "submission/_skeleton.en.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
@@ -96,14 +141,32 @@ const skeletonSubmissionSlug = "submission/_skeleton.docx"
// the file itself lives in mWorkRepo and is served through the shared
// Gitea proxy cache so refreshes are visible to all consumers in one
// place.
//
// t-paliad-276: codes that ship an EN sibling
// (e.g. `de.inf.lg.erwidg.en.docx`) also register it in
// submissionTemplateENRegistry; the language-aware lookup
// (resolveSubmissionTemplate(ctx, code, lang)) prefers the language-
// suffixed slug and falls back to the unsuffixed one when no per-firm
// EN variant exists.
var submissionTemplateRegistry = map[string]string{
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
}
// submissionTemplateENRegistry maps a submission_code to the EN
// variant slug. Empty when no EN template has been authored — the
// lookup falls through to the unsuffixed (DE-baked) template and the
// editor surfaces the "Fallback: universelles Skelett" notice when
// even the skeleton has no EN sibling.
var submissionTemplateENRegistry = map[string]string{}
// fetchSubmissionTemplateBytes returns the per-submission_code template
// bytes (and provenance SHA) when one is registered. The bool result
// distinguishes "no per-code template registered" (callers fall back to
// HL Patents Style) from an upstream fetch error.
//
// Language-suffixed variants (t-paliad-276) are served via
// fetchSubmissionTemplateBytesForLang — this base function returns the
// unsuffixed registry entry only (the legacy DE-baked template).
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
slug, ok := submissionTemplateRegistry[submissionCode]
if !ok {
@@ -209,6 +272,113 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
}
// fetchSubmissionTemplateBytesForLang returns the per-(code, lang)
// template bytes when a language-suffixed variant is registered. Used
// only for the EN variant today; DE goes through the unsuffixed
// fetchSubmissionTemplateBytes (which is the legacy / authoritative
// DE registry). t-paliad-276.
//
// Returned bool = "variant registered AND fetched OK". A registered
// variant whose file 404s on Gitea returns (nil, "", false, nil) so
// the caller falls through to the unsuffixed template, mirroring the
// behaviour for unregistered codes.
func fetchSubmissionTemplateBytesForLang(ctx context.Context, submissionCode, lang string) ([]byte, string, bool, error) {
if lang != "en" {
// Only EN has a separate registry today. DE goes through the
// unsuffixed path which is the authoritative DE template.
return nil, "", false, nil
}
slug, ok := submissionTemplateENRegistry[submissionCode]
if !ok {
return nil, "", false, nil
}
entry, ok := fileRegistry[slug]
if !ok {
return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
}
ce := getCacheEntry(slug)
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 {
// Treat upstream miss as "variant unavailable" so the
// resolver falls through to the DE template instead of
// surfacing a 502.
log.Printf("file proxy: EN variant fetch failed for %s (%s): %v — falling through", submissionCode, slug, err)
return nil, "", false, nil
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", false, nil
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx
return out, ce.sha, true, nil
}
// fetchSubmissionSkeletonBytesForLang returns the cached skeleton
// template bytes for the requested language. EN falls back to DE when
// the EN skeleton hasn't been authored yet (t-paliad-276). Returned
// bool flags whether the bytes match the requested language — false
// means the resolver should communicate "fallback" to the UI.
func fetchSubmissionSkeletonBytesForLang(ctx context.Context, lang string) ([]byte, string, bool, error) {
if lang == "en" {
entry, ok := fileRegistry[skeletonSubmissionENSlug]
if ok {
ce := getCacheEntry(skeletonSubmissionENSlug)
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 {
ce.mu.RLock()
if len(ce.data) > 0 {
out := make([]byte, len(ce.data))
copy(out, ce.data)
sha := ce.sha
ce.mu.RUnlock()
return out, sha, true, nil
}
ce.mu.RUnlock()
} else {
log.Printf("file proxy: EN skeleton fetch failed (%s): %v — falling back to DE", skeletonSubmissionENSlug, err)
}
} else {
if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
if len(ce.data) > 0 {
out := make([]byte, len(ce.data))
copy(out, ce.data)
sha := ce.sha
ce.mu.RUnlock()
return out, sha, true, nil
}
ce.mu.RUnlock()
}
}
}
// Fall through to the DE skeleton; bool=false flags that the
// returned bytes don't carry the requested language.
bytes, sha, err := fetchSubmissionSkeletonBytes(ctx)
if err != nil {
return nil, "", false, err
}
return bytes, sha, lang == "de", nil
}
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
// template bytes plus its provenance SHA. Sits between the per-firm
// per-submission_code template (fetchSubmissionTemplateBytes) and the
@@ -219,11 +389,28 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
// 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]
return fetchSubmissionTemplateSlug(ctx, skeletonSubmissionSlug)
}
// fetchFirmSkeletonBytes returns the cached firm-formatted skeleton
// template bytes (HL paragraph/character styles + 48-key placeholder
// bag) plus its provenance SHA. Sits between the per-code template and
// the generic universal skeleton in resolveSubmissionTemplate's
// fallback chain (t-paliad-275). Same stale-while-revalidate caching
// as the other Gitea-backed template parts.
func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
}
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
// the firm-skeleton and universal-skeleton accessors. Factored out so
// the two paths can't drift apart on caching semantics.
func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, string, error) {
entry, ok := fileRegistry[slug]
if !ok {
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
return nil, "", fmt.Errorf("file proxy: %s not registered", slug)
}
ce := getCacheEntry(skeletonSubmissionSlug)
ce := getCacheEntry(slug)
ce.mu.RLock()
hasData := len(ce.data) > 0
@@ -241,7 +428,7 @@ func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", slug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)

View File

@@ -671,6 +671,7 @@ 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)

View File

@@ -68,6 +68,17 @@ type submissionDraftView struct {
Lang string `json:"lang"`
HasTemplate bool `json:"has_template"`
TemplateMissing bool `json:"template_missing,omitempty"`
// TemplateTier identifies which tier of resolveSubmissionTemplate
// produced the bytes — one of per_code_lang, per_code, skeleton_lang,
// skeleton, letterhead. Lets the editor distinguish a perfect
// per-firm match from a skeleton fallback. t-paliad-276.
TemplateTier string `json:"template_tier,omitempty"`
// LanguageFallback is true when the requested draft.language has no
// per-firm per-code template (e.g. EN draft falls back to the DE
// per-code template, or to the universal skeleton). UI surfaces a
// notice so the lawyer knows the rendered body lacks language-
// matched code-specific prose. t-paliad-276.
LanguageFallback bool `json:"language_fallback,omitempty"`
}
type submissionDraftJSON struct {
@@ -76,6 +87,7 @@ type submissionDraftJSON struct {
SubmissionCode string `json:"submission_code"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Language string `json:"language"`
Variables services.PlaceholderMap `json:"variables"`
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
@@ -103,6 +115,7 @@ type submissionDraftListResponse struct {
type submissionDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
}
// ─────────────────────────────────────────────────────────────────────
@@ -337,7 +350,7 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables}
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables, Language: input.Language}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
writeSubmissionDraftServiceError(w, err)
@@ -418,7 +431,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -467,7 +480,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -670,6 +683,7 @@ func handleGetGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
type globalDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
// projectIDProvided is true when the JSON included the "project_id"
// key (regardless of value); needed to distinguish "no change" from
// "set to null". Set by the custom UnmarshalJSON below.
@@ -681,6 +695,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
type alias struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
}
var a alias
@@ -689,6 +704,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
}
g.Name = a.Name
g.Variables = a.Variables
g.Language = a.Language
g.ProjectID = a.ProjectID
// Detect whether "project_id" was present in the JSON object.
var raw map[string]json.RawMessage
@@ -726,7 +742,7 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables}
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables, Language: in.Language}
if in.projectIDProvided {
pid := in.ProjectID // may be nil → detach
patch.ProjectID = &pid
@@ -801,7 +817,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -886,7 +902,7 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
}
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
view.TemplateMissing = true
@@ -894,6 +910,12 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
view.PreviewHTML = `<p class="preview-error">Vorlage konnte nicht geladen werden.</p>`
return view, nil
}
view.TemplateTier = string(tier)
// LanguageFallback signals "no per-firm template in the requested
// language" — the editor surfaces a notice so the lawyer knows the
// rendered body lacks code-specific prose. The per-code DE template
// counts as a fallback when the requested language is EN.
view.LanguageFallback = languageFallback(d.Language, tier)
html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
if err != nil {
return nil, err
@@ -902,41 +924,101 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
return view, nil
}
// submissionTemplateTier enumerates which tier of the template
// fallback chain produced the bytes returned by resolveSubmissionTemplate.
// Used by the editor to surface "Fallback: universelles Skelett" when
// the requested (code, lang) didn't have a dedicated template.
type submissionTemplateTier string
const (
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
)
// resolveSubmissionTemplate returns the .docx bytes for the given
// submission code. Lookup order matches the cronus design fallback chain
// §8 plus the t-paliad-259 universal-skeleton slot:
// (submission_code, language). Merges t-paliad-275 (firm-skeleton tier)
// and t-paliad-276 (language-selector + EN skeleton tier). Lookup order:
//
// 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.
// 1. per-firm per-(code, lang) template — most specific. e.g.
// `de.inf.lg.erwidg.en.docx` for EN drafts. t-paliad-276.
// 2. per-firm per-code (unsuffixed) template — DE-baked baseline. The
// legacy registry shape from before the language selector landed.
// 3. universal language-matched skeleton — `_skeleton.en.docx` for EN
// drafts. Skipped for DE drafts (steps 4+5 already cover DE).
// 4. firm-formatted skeleton — `_firm-skeleton.docx` (t-paliad-275).
// HL paragraph + character styles + letterhead, full placeholder
// bag. DE-flavored: counts as language_fallback=true for EN drafts.
// 5. universal _skeleton.docx — plain DE skeleton, no firm styles.
// Backstop when the firm skeleton is unreachable.
// 6. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Last-ditch when every skeleton tier is unreachable.
//
// 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
// The returned SHA pins the audit row's template provenance. The tier
// tells the editor whether the result language-matches the request so
// it can surface a "Fallback: universelles Skelett" notice.
func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string) ([]byte, string, submissionTemplateTier, error) {
if lang != "de" && lang != "en" {
lang = "de"
}
// 1. per-(code, lang)
if data, sha, found, err := fetchSubmissionTemplateBytesForLang(ctx, submissionCode, lang); err != nil {
return nil, "", "", err
} else if found {
return data, sha, nil
return data, sha, tplTierPerCodeLang, nil
}
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
return data, sha, nil
// 2. per-code (unsuffixed)
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
return nil, "", "", err
} else if found {
return data, sha, tplTierPerCode, nil
}
// 3. language-matched skeleton — only meaningful for EN drafts; DE
// drafts fall through to the firm/universal DE skeletons below.
if lang == "en" {
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched {
return data, sha, tplTierSkeletonLang, nil
}
}
// 4. firm-formatted skeleton (HL styles, DE prose). For DE drafts
// this is a first-class match; for EN drafts it counts as a
// language fallback (handled by languageFallback()).
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
return data, sha, tplTierSkeleton, nil
} else {
log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s lang=%s, falling back to universal skeleton: %v", submissionCode, lang, err)
}
// 5. universal plain DE skeleton.
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
return data, sha, tplTierSkeleton, nil
} else {
log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
}
// 6. HL Patents Style letterhead (no placeholders, last-ditch).
bytes, err := fetchHLPatentsStyleBytes(ctx)
if err != nil {
return nil, "", err
return nil, "", "", err
}
sha := hlPatentsStyleSHA()
return bytes, sha, nil
return bytes, sha, tplTierLetterhead, nil
}
// languageFallback reports whether the resolved template tier failed
// to match the requested draft language. For an EN draft, anything
// other than per_code_lang or skeleton_lang is a fallback (per_code is
// the legacy DE-baked template, skeleton is the DE skeleton). For a DE
// draft, only `letterhead` counts as a fallback — the DE skeleton and
// per-code template are both first-class DE outputs. t-paliad-276.
func languageFallback(lang string, tier submissionTemplateTier) bool {
if tier == tplTierLetterhead {
return true
}
if strings.EqualFold(lang, "en") {
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang
}
return false
}
// hlPatentsStyleSHA reads the current cache SHA for the universal
@@ -958,12 +1040,17 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
if vars == nil {
vars = services.PlaceholderMap{}
}
lang := d.Language
if lang == "" {
lang = "de"
}
return submissionDraftJSON{
ID: d.ID,
ProjectID: d.ProjectID,
SubmissionCode: d.SubmissionCode,
UserID: d.UserID,
Name: d.Name,
Language: lang,
Variables: vars,
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,

View File

@@ -0,0 +1,43 @@
package handlers
// Regression tests for the template-tier → language-fallback mapping
// (t-paliad-276). The editor surfaces a "Fallback: universelles
// Skelett" notice when the requested draft language has no per-firm
// language-matched template — these tests pin which tier counts as a
// fallback for each language so the UI signal stays stable.
import "testing"
func TestLanguageFallback(t *testing.T) {
t.Parallel()
cases := []struct {
name string
lang string
tier submissionTemplateTier
want bool
}{
// DE drafts: every non-letterhead tier is a first-class match.
{"de_per_code_lang", "de", tplTierPerCodeLang, false},
{"de_per_code", "de", tplTierPerCode, false},
{"de_skeleton_lang", "de", tplTierSkeletonLang, false},
{"de_skeleton", "de", tplTierSkeleton, false},
{"de_letterhead", "de", tplTierLetterhead, true},
// EN drafts: per_code (DE-baked) and skeleton (DE-baked) both
// surface the fallback notice so the lawyer knows the rendered
// body lacks EN prose.
{"en_per_code_lang", "en", tplTierPerCodeLang, false},
{"en_per_code", "en", tplTierPerCode, true},
{"en_skeleton_lang", "en", tplTierSkeletonLang, false},
{"en_skeleton", "en", tplTierSkeleton, true},
{"en_letterhead", "en", tplTierLetterhead, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := languageFallback(c.lang, c.tier); got != c.want {
t.Errorf("languageFallback(%q, %q) = %v, want %v", c.lang, c.tier, got, c.want)
}
})
}
}

View File

@@ -304,14 +304,23 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
defer cancel()
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
// One-shot /generate has no draft row to pull `language` from —
// accept `?language=de|en` as an explicit override (t-paliad-276)
// and otherwise fall back to the user's UI language.
user, _ := dbSvc.users.GetByID(ctx, uid)
lang := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("language")))
if lang != "de" && lang != "en" {
lang = userLang(user)
}
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, submissionCode, lang)
if err != nil {
log.Printf("submissions: template fetch (project=%s code=%s): %v", projectID, submissionCode, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, lang, tplBytes)
if err != nil {
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{

View File

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

@@ -27,33 +27,119 @@ func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator {
}
// CalculateEndDate applies a single rule's duration + timing to the event date,
// then bumps forward off non-working days for the given (country, regime).
// Returns (adjusted, original, didAdjust).
// then bumps off non-working days for the given (country, regime). For
// rules with both a primary and an alt duration (alt_duration_value/_unit)
// and a combine_op of 'max' or 'min', both legs are computed independently
// and combined per the operator — this implements RoP R.198 / R.213
// ("31 days OR 20 working days, whichever is longer") and the equivalent
// shape under EPC. Returns (adjusted, original, didAdjust).
//
// Snap direction follows timing: 'after' snaps forward to the next
// working day (RoP R.300.b — period extends to the next working day),
// 'before' snaps *backward* to the preceding working day so the
// statutory cut-off is not pushed past its hard limit.
//
// duration_unit='working_days' walks day-by-day via the holiday service
// (skipping weekends + court holidays), so its result is always already a
// working day — no post-arithmetic snap needed for that leg.
//
// Per Tier 3 Primitives §10 of docs/research-deadlines-completeness-2026-05-25.md
// (m's 2026-05-25 15:29 steer: build the full primitives, no workarounds).
func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule, country, regime string) (time.Time, time.Time, bool) {
endDate := eventDate
timing := "after"
if rule.Timing != nil {
timing = *rule.Timing
}
adjusted, raw, wasAdjusted := c.computeLeg(eventDate, rule.DurationValue, rule.DurationUnit, timing, country, regime)
// combine_op + alt_duration_*: compute the alt leg independently,
// then pick the later (max) or earlier (min) of the two adjusted
// end-dates. Live use case is UPC RoP R.198 / R.213 (31 calendar
// days vs. 20 working days, whichever is longer).
if rule.CombineOp != nil && rule.AltDurationValue != nil && rule.AltDurationUnit != nil {
altAdj, altRaw, altWasAdj := c.computeLeg(eventDate, *rule.AltDurationValue, *rule.AltDurationUnit, timing, country, regime)
switch *rule.CombineOp {
case "max":
if altAdj.After(adjusted) {
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
}
case "min":
if altAdj.Before(adjusted) {
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
}
}
}
return adjusted, raw, wasAdjusted
}
// computeLeg evaluates a single (value, unit) duration against the event
// date in the given timing direction and snap-adjusts the result. Returns
// the snap-adjusted end-date, the pre-snap end-date, and whether a snap
// occurred. working_days arithmetic never needs a snap (the walker lands
// on a working day by construction).
func (c *DeadlineCalculator) computeLeg(eventDate time.Time, value int, unit string, timing string, country, regime string) (adjusted, raw time.Time, wasAdjusted bool) {
sign := 1
if timing == "before" {
sign = -1
}
switch rule.DurationUnit {
case "days":
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue)
case "weeks":
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue*7)
case "months":
endDate = endDate.AddDate(0, sign*rule.DurationValue, 0)
raw = c.addDuration(eventDate, value, unit, sign, country, regime)
if unit == "working_days" {
return raw, raw, false
}
if timing == "before" {
return c.holidays.AdjustForNonWorkingDaysBackward(raw, country, regime)
}
return c.holidays.AdjustForNonWorkingDays(raw, country, regime)
}
original := endDate
adjusted, _, wasAdjusted := c.holidays.AdjustForNonWorkingDays(endDate, country, regime)
return adjusted, original, wasAdjusted
// addDuration adds `sign * value` of the given unit to eventDate. For
// 'working_days' it walks day-by-day skipping weekends and court
// holidays via the holiday service.
func (c *DeadlineCalculator) addDuration(eventDate time.Time, value int, unit string, sign int, country, regime string) time.Time {
switch unit {
case "days":
return eventDate.AddDate(0, 0, sign*value)
case "weeks":
return eventDate.AddDate(0, 0, sign*value*7)
case "months":
return eventDate.AddDate(0, sign*value, 0)
case "working_days":
return c.addWorkingDays(eventDate, sign*value, country, regime)
}
return eventDate
}
// addWorkingDays walks `n` business days from `date` (negative `n` walks
// backward). The event day itself is never counted; we step first, then
// skip past non-working days, repeated n times. Result is always a
// working day for the given (country, regime). Matches UPC RoP R.300.b's
// "the day on which the event happens shall not be counted" convention
// applied to the business-day axis.
//
// Bound: each business-day step is bounded by a 60-day inner cap so a
// misconfigured holiday table can never spin forever. The longest
// real-world non-working run between adjacent business days is the
// Christmas Eve → Neujahr window (~6 days), so 60 is over-provisioned.
func (c *DeadlineCalculator) addWorkingDays(date time.Time, n int, country, regime string) time.Time {
if n == 0 {
return date
}
step := 1
count := n
if n < 0 {
step = -1
count = -n
}
cur := date
for i := 0; i < count; i++ {
cur = cur.AddDate(0, 0, step)
for j := 0; j < 60 && c.holidays.IsNonWorkingDay(cur, country, regime); j++ {
cur = cur.AddDate(0, 0, step)
}
}
return cur
}
// CalculateFromRules calculates deadlines for a slice of rules using the

View File

@@ -93,7 +93,14 @@ func TestCalculateEndDate_Weeks_LandsOnHoliday(t *testing.T) {
}
}
func TestCalculateEndDate_BeforeTiming(t *testing.T) {
// TestCalculateEndDate_BeforeTiming_SnapsBackward — Tier 3 Primitive 5
// (m/paliad#103 Slice A). For timing='before' rules (R.109.1 / R.109.4
// "no later than X before the oral hearing"), a computed cut-off that
// lands on a weekend / holiday must snap *backward* to the preceding
// working day. Forward snap would push the cut-off past the statutory
// limit and miss the deadline. See
// docs/research-deadlines-completeness-2026-05-25.md §10 T3.5.
func TestCalculateEndDate_BeforeTiming_SnapsBackward(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
@@ -104,11 +111,322 @@ func TestCalculateEndDate_BeforeTiming(t *testing.T) {
DurationUnit: "months",
Timing: ptr("before"),
}
// "before" subtracts: 2026-04-15 - 1 month = 2026-03-15 (Sunday).
// Adjust: Sunday → Monday 2026-03-16.
// "before" subtracts: 2026-04-15 (Wed) - 1 month = 2026-03-15 (Sunday).
// Backward snap: Sunday → Friday 2026-03-13 (Karfreitag is later
// in 2026, so no extra holiday in this window).
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
wantOrig := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
wantAdj := time.Date(2026, 3, 13, 0, 0, 0, 0, time.UTC)
if !original.Equal(wantOrig) {
t.Errorf("original: got %s, want %s", original, wantOrig)
}
if !adjusted.Equal(wantAdj) {
t.Errorf("adjusted: got %s, want %s", adjusted, wantAdj)
}
if !wasAdjusted {
t.Error("expected wasAdjusted=true (Sun → preceding Fri)")
}
}
// Tier 3 Primitive 5 — backward snap across Karfreitag / Ostermontag.
// 2026 Ostern: Karfreitag = 2026-04-03 (Fri), Ostermontag = 2026-04-06 (Mon).
// Anchor Tue 2026-05-05 minus 1 month = Sun 2026-04-05 → backward through
// Sat → Karfreitag → Thu 2026-04-02.
func TestCalculateEndDate_BeforeTiming_BackwardSkipsHolidayCluster(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "1-month before, Ostern cluster",
DurationValue: 1,
DurationUnit: "months",
Timing: ptr("before"),
}
in := time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC)
adjusted, _, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
if !wasAdjusted {
t.Error("expected wasAdjusted=true (Sun→Karfreitag→Thu)")
}
}
// Tier 3 Primitive 1 — working_days arithmetic forward over a weekend.
// Anchor Mon 2026-01-12 + 5 working days = Tue 13 (1), Wed 14 (2),
// Thu 15 (3), Fri 16 (4), Mon 19 (5). Result = Mon 2026-01-19.
func TestCalculateEndDate_WorkingDays_ForwardSkipsWeekend(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "5 working days",
DurationValue: 5,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 19, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
// working_days arithmetic lands on a working day by construction, so the
// "snap" reports no adjustment and original == adjusted.
if !original.Equal(want) {
t.Errorf("original: got %s, want %s", original, want)
}
if wasAdjusted {
t.Error("working_days result should not report a snap adjustment")
}
}
// Tier 3 Primitive 1 — working_days arithmetic with anchor on Friday;
// 20 working days lands on the Friday four weeks later. Anchor Fri
// 2026-01-09 → +20wd → Fri 2026-02-06. No DE federal holiday in
// window. This exercises the R.198 / R.213 "20 working days" leg.
func TestCalculateEndDate_WorkingDays_TwentyDays(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "20 working days",
DurationValue: 20,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)
want := time.Date(2026, 2, 6, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days across Karfreitag/Ostermontag. Anchor
// Thu 2026-04-02 + 3 working days: skip Karfreitag (Fri 04-03), weekend,
// Ostermontag (Mon 04-06). Walk: Tue 04-07 (1), Wed 04-08 (2), Thu 04-09
// (3). Result = Thu 2026-04-09.
func TestCalculateEndDate_WorkingDays_AcrossEasterCluster(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "3 working days over Ostern",
DurationValue: 3,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 4, 9, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days across year boundary. Anchor Mon
// 2025-12-29 + 5 working days. Calendar: Tue 30 (1), Wed 31 (2),
// Thu 2026-01-01 = Neujahr (skip), Fri 2026-01-02 (3), Mon 05 (4),
// Tue 06 (5). Result = Tue 2026-01-06.
func TestCalculateEndDate_WorkingDays_AcrossYearBoundary(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "5 working days over year-end",
DurationValue: 5,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2025, 12, 29, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 6, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days backward (timing='before'). Anchor
// Fri 2026-04-17 - 5 working days: Thu 16 (1), Wed 15 (2), Tue 14 (3),
// Mon 13 (4), Fri 10 (5 — Mon 13 - 3 days skipping Sun/Sat). Result =
// Fri 2026-04-10.
func TestCalculateEndDate_WorkingDays_BackwardSkipsWeekend(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "5 working days before",
DurationValue: 5,
DurationUnit: "working_days",
Timing: ptr("before"),
}
in := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days anchored on a Saturday (rare but
// must not loop). +3 working days from Sat 2026-01-10: Mon 12 (1), Tue
// 13 (2), Wed 14 (3). Result = Wed 2026-01-14.
func TestCalculateEndDate_WorkingDays_AnchorOnWeekend(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "3 working days from Saturday",
DurationValue: 3,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op='max' picks the LATER of two adjusted
// end-dates. Matches UPC RoP R.198 / R.213 "31 calendar days OR 20
// working days, whichever is longer". Anchor Mon 2026-01-12.
// - Primary: 31 cal days → Sun 2026-02-12... wait, Mon Jan 12 + 31 =
// Thu 2026-02-12 (verify: Jan has 31 days; 12 + 31 = day-43 of year
// = Feb 12). Feb 12 2026 is Thursday → no snap, +31d.
// - Alt: 20 working_days → Mon Jan 12 + 20wd: Tue 13 (1) ... walk
// gives Mon 2026-02-09 (20 business days later, no DE holiday).
//
// max(Feb 12 Thu, Feb 09 Mon) = Feb 12 → primary wins.
func TestCalculateEndDate_CombineMax_PrimaryWins(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "31d OR 20wd, max",
DurationValue: 31,
DurationUnit: "days",
Timing: ptr("after"),
AltDurationValue: ptr(20),
AltDurationUnit: ptr("working_days"),
CombineOp: ptr("max"),
}
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op='max', alt wins. Anchor that makes the
// 20-working-days leg longer than the 31-cal-day leg. Anchor Fri
// 2026-01-09: +31 cal days = Mon 2026-02-09 (calendar weekday, no snap);
// +20 working_days = Fri 2026-02-06 ... actually let's pick an anchor
// where the working-days side overshoots. Anchor over a long-weekend
// cluster: Wed 2026-12-23, +31cal = Sat 2027-01-23 → forward-snap to Mon
// 2027-01-25 (DE has no holiday that day). +20wd = walk skipping Heilig
// Abend, Christmas, Neujahr, weekends. Pick simpler: anchor where 31cal
// + snap ≈ 20wd + cluster.
//
// Concrete: anchor Mon 2026-01-12, mock the 31d leg landing on Sun
// 2026-02-15 (no — Jan 12 + 34 days = Feb 15, not 31). For deterministic
// "alt wins", we use a configurable anchor and check the relative order
// instead.
func TestCalculateEndDate_CombineMax_AltWins(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
// Anchor Thu 2026-12-24 (Heilig Abend is not a DE federal holiday;
// holiday service only has Neujahr/Easter/.../Weihnachtstag — Dec
// 24 is a working day here). +14 calendar days = Thu 2027-01-07.
// +20 working_days walks Fri 12-25 (1. Weihnachtstag — skip), ...
// arrives much later. Use 14 days vs 20 working_days to make alt
// reliably win on this stretch.
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "14d OR 20wd, max",
DurationValue: 14,
DurationUnit: "days",
Timing: ptr("after"),
AltDurationValue: ptr(20),
AltDurationUnit: ptr("working_days"),
CombineOp: ptr("max"),
}
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
// Primary 14 cal days: Dec 24 (Thu) + 14 = Jan 7 2027 (Thu), working
// day → no snap. Alt 20 working_days walks past Christmas + Neujahr:
// Fri 12-25 (1.W) skip, Sat/Sun 12-26/27 skip (Sat counts as
// non-working; 2.W on 26 also skips), Mon 12-28 (1), Tue 12-29 (2),
// Wed 12-30 (3), Thu 12-31 (4), Fri 01-01-2027 Neujahr skip, Mon
// 01-04 (5), Tue 01-05 (6), Wed 01-06 (7), Thu 01-07 (8), Fri 01-08
// (9), Mon 01-11 (10), Tue 01-12 (11), Wed 01-13 (12), Thu 01-14
// (13), Fri 01-15 (14), Mon 01-18 (15), Tue 01-19 (16), Wed 01-20
// (17), Thu 01-21 (18), Fri 01-22 (19), Mon 01-25 (20). Result =
// Mon 2027-01-25. After max(Jan 7, Jan 25) → Jan 25.
want := time.Date(2027, 1, 25, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op='min' picks the EARLIER end-date.
// Same shape as the max test but inverted. Same Dec 24 2026 anchor,
// 14d vs 20wd: min = Jan 7 2027 (the primary leg).
func TestCalculateEndDate_CombineMin_PrimaryWins(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "14d OR 20wd, min",
DurationValue: 14,
DurationUnit: "days",
Timing: ptr("after"),
AltDurationValue: ptr(20),
AltDurationUnit: ptr("working_days"),
CombineOp: ptr("min"),
}
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2027, 1, 7, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op with NULL alt fields short-circuits to
// the primary-only result (defensive: drift in seed data shouldn't crash
// the calculator). Same as the basic days test but with combine_op set
// and alt fields nil.
func TestCalculateEndDate_CombineOp_AltNil_FallsBackToPrimary(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Primary only, stray combine_op",
DurationValue: 10,
DurationUnit: "days",
Timing: ptr("after"),
CombineOp: ptr("max"),
}
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 23, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
@@ -168,4 +486,3 @@ func TestAdjustForNonWorkingDays_WalksPastSummerVacation(t *testing.T) {
// PR-3 ("SoD 3mo from 2026-04-30 → adjusted Mon 2026-08-31, not Sat
// 2026-08-29") locks the live behaviour.
}

View File

@@ -33,7 +33,12 @@ import (
// tree alone is enough to produce a candidate concept set.
// - Forums: a list of forum slugs from the v3 bucket map. Translated
// to proceeding_type_codes by the search service; trigger-event
// pills bypass the forum filter (cross-cutting by design).
// pills carry a structured legal_source citation (via mig 123)
// and narrow by the per-forum legal-source prefix set instead of
// by proceeding_code — see ForumToLegalSourcePrefixes. Before mig
// 123 trigger pills bypassed the forum filter unconditionally;
// m/paliad#97 (t-paliad-266) requires the cross-cutting sub-rows
// to narrow with the active court-system chip.
//
// See docs/plans/unified-fristenrechner.md §4.6 + §6 (v2) and
// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
@@ -74,6 +79,40 @@ var ForumToProceedingCodes = map[string][]string{
"dpma": {CodeDPMAOpposition},
}
// ForumToLegalSourcePrefixes maps the v3 forum buckets to the
// structured legal_source prefixes that cross-cutting trigger pills
// must match against (t-paliad-266 / m/paliad#97). Rule pills already
// narrow by proceeding_code via ForumToProceedingCodes; trigger pills
// have no proceeding context, so the narrowing key is the citation
// body itself.
//
// Mapping mirrors m's spec on the issue:
//
// - UPC chips → UPC.* (UPC RoP / UPC Agreement / UPC Statute)
// - DE LG/OLG/BGH chips → DE.ZPO.* (civil-procedure path)
// - DE BPatG chip → DE.PatG.* (national patent path)
// - DPMA chip → DE.PatG.* (national patent path)
// - EPA chips → EU.EPC* / EU.EPÜ* (EPC / EPÜ citations)
//
// Two forums (de_bgh, de_bpatg) intentionally collapse: BGH hears
// both civil-patent and nullity appeals; PatG covers DPMA + BPatG
// patent jurisdiction. The matching SQL uses startsWith against the
// union of the active forums' prefixes, so a chip combination like
// "DPMA + de_bgh" surfaces every trigger whose legal_source starts
// with DE.PatG.* OR DE.ZPO.* — exactly the user's union expectation.
var ForumToLegalSourcePrefixes = map[string][]string{
"upc_cfi": {"UPC."},
"upc_coa": {"UPC."},
"de_lg": {"DE.ZPO."},
"de_olg": {"DE.ZPO."},
"de_bgh": {"DE.ZPO."},
"de_bpatg": {"DE.PatG."},
"epa_grant": {"EU.EPC", "EU.EPÜ"},
"epa_opp": {"EU.EPC", "EU.EPÜ"},
"epa_appeal": {"EU.EPC", "EU.EPÜ"},
"dpma": {"DE.PatG."},
}
// SearchOptions carries the optional facet filters from the URL query
// string. Empty strings / empty slices mean "no filter on this facet".
type SearchOptions struct {
@@ -279,8 +318,12 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
subtree = newSubtreeFilter(outcomes)
}
// v3: translate forum slugs to proceeding_code allow-list.
// v3: translate forum slugs to proceeding_code allow-list (rule
// pills) and t-paliad-266: parallel legal_source prefix allow-list
// for trigger pills. Empty slice for either axis = no narrowing on
// that pill kind.
forumCodes := translateForums(opts.Forums)
forumLegalPrefixes := translateForumsToLegalSourcePrefixes(opts.Forums)
if !browseMode && qNorm == "" {
return resp, nil
@@ -293,11 +336,11 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
var ranks []rankRow
if browseMode {
// Browse mode: synthesize ranks from the allow-list directly.
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, limit)
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, forumLegalPrefixes, limit)
} else {
qLow := strings.ToLower(qNorm)
var err error
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, limit)
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, forumLegalPrefixes, limit)
if err != nil {
return nil, err
}
@@ -310,7 +353,7 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
for i, r := range ranks {
conceptIDs[i] = r.ConceptID
}
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes)
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes, forumLegalPrefixes)
if err != nil {
return nil, err
}
@@ -418,6 +461,33 @@ func translateForums(slugs []string) []string {
return out
}
// translateForumsToLegalSourcePrefixes maps a list of forum slugs to
// the union of legal_source prefixes those forums admit for trigger
// pills (t-paliad-266). Empty when no slug carries a prefix mapping —
// callers must treat empty as "no trigger narrowing applies" rather
// than "match nothing", mirroring translateForums.
func translateForumsToLegalSourcePrefixes(slugs []string) []string {
if len(slugs) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, slug := range slugs {
prefixes, ok := ForumToLegalSourcePrefixes[slug]
if !ok {
continue
}
for _, p := range prefixes {
if seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
}
return out
}
// browseRanks synthesizes a rank list from a subtree-filter tuple set
// (v3 B1 browse mode). No trigram scoring — order is by concept
// sort_order then name. Forum filter applies post-hoc to keep concepts
@@ -430,6 +500,7 @@ func (s *DeadlineSearchService) browseRanks(
subtree *subtreeFilter,
party, proc, source *string,
forumCodes []string,
forumLegalPrefixes []string,
limit int,
) []rankRow {
const sqlText = `
@@ -452,8 +523,18 @@ SELECT DISTINCT
AND (
$6::text[] IS NULL
OR cardinality($6::text[]) = 0
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($6::text[])
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($6::text[])
)
OR (
s.kind = 'trigger'
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($8::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
ORDER BY s.concept_sort_order ASC, s.concept_name_de ASC
LIMIT $7
@@ -465,6 +546,7 @@ SELECT DISTINCT
party, proc, source,
nullableArray(forumCodes),
limit,
nullableArray(forumLegalPrefixes),
); err != nil {
// Browse mode failures degrade to empty (taxonomy-driven UX
// shouldn't crash on a malformed slug); log via the caller.
@@ -490,11 +572,12 @@ func (s *DeadlineSearchService) rankConcepts(
party, proc, source *string,
subtree *subtreeFilter,
forumCodes []string,
forumLegalPrefixes []string,
limit int,
) ([]rankRow, error) {
// $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
// $6 subtree_cids uuid[]? · $7 subtree_procs text[]? ·
// $8 forum_codes text[]? · $9 limit
// $8 forum_codes text[]? · $9 limit · $10 forum_legal_prefixes text[]?
const sqlText = `
WITH matched AS (
SELECT
@@ -544,8 +627,18 @@ WITH matched AS (
AND (
$8::text[] IS NULL
OR cardinality($8::text[]) = 0
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($8::text[])
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($8::text[])
)
OR (
s.kind = 'trigger'
AND ($10::text[] IS NULL OR cardinality($10::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($10::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
)
SELECT
@@ -569,6 +662,7 @@ SELECT
cidArg, procArg,
nullableArray(forumCodes),
limit,
nullableArray(forumLegalPrefixes),
); err != nil {
return nil, fmt.Errorf("rank concepts: %w", err)
}
@@ -581,10 +675,11 @@ func (s *DeadlineSearchService) loadPills(
party, proc, source *string,
subtree *subtreeFilter,
forumCodes []string,
forumLegalPrefixes []string,
) ([]pillRow, error) {
// $1 concept_ids uuid[] · $2 party · $3 proc · $4 source ·
// $5 subtree_cids uuid[]? · $6 subtree_procs text[]? ·
// $7 forum_codes text[]?
// $7 forum_codes text[]? · $8 forum_legal_prefixes text[]?
const sqlText = `
SELECT
s.kind,
@@ -627,8 +722,18 @@ SELECT
AND (
$7::text[] IS NULL
OR cardinality($7::text[]) = 0
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($7::text[])
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($7::text[])
)
OR (
s.kind = 'trigger'
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($8::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
ORDER BY s.concept_id, s.kind, s.proceeding_display_order, s.proceeding_code NULLS LAST, s.rule_local_code
`
@@ -638,6 +743,7 @@ SELECT
pq.Array(conceptIDs), party, proc, source,
cidArg, procArg,
nullableArray(forumCodes),
nullableArray(forumLegalPrefixes),
); err != nil {
return nil, fmt.Errorf("load pills: %w", err)
}

View File

@@ -166,15 +166,15 @@ func TestDeadlineSearch(t *testing.T) {
mustHaveLegalSource(t, card, "DE.PatG.82.1")
})
t.Run("Wiedereinsetzung returns the cross-cutting concept with 4 trigger pills", func(t *testing.T) {
t.Run("Wiedereinsetzung returns the cross-cutting concept with 5 trigger pills", func(t *testing.T) {
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
// Exactly 4 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
// Art.122 (EU), DPMA §123 — corresponding to trigger_event ids
// 200..203 from migration 046.
// Exactly 5 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
// Art.122 (EU), DPMA §123, and UPC R.320 — trigger_event ids
// 200..203 from mig 046 plus 207 from mig 063.
triggerIDs := []int64{}
for _, p := range card.Pills {
if p.Kind != "trigger" {
@@ -184,9 +184,9 @@ func TestDeadlineSearch(t *testing.T) {
triggerIDs = append(triggerIDs, *p.TriggerEventID)
}
}
want := map[int64]bool{200: true, 201: true, 202: true, 203: true}
if len(triggerIDs) != 4 {
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 4 (ids 200..203)", len(triggerIDs))
want := map[int64]bool{200: true, 201: true, 202: true, 203: true, 207: true}
if len(triggerIDs) != 5 {
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 5 (ids 200..203, 207)", len(triggerIDs))
}
for _, id := range triggerIDs {
if !want[id] {
@@ -195,6 +195,107 @@ func TestDeadlineSearch(t *testing.T) {
}
})
// t-paliad-266 / m/paliad#97 — court-system filter narrows
// cross-cutting trigger pills via legal_source inference.
t.Run("forum filter narrows Wiedereinsetzung trigger pills by court system", func(t *testing.T) {
// Each pair is (forum slug, expected trigger_event_ids).
cases := []struct {
name string
forum string
wantTrigIDs []int64
}{
{"upc_cfi shows only UPC R.320", "upc_cfi", []int64{207}},
{"upc_coa shows only UPC R.320", "upc_coa", []int64{207}},
{"de_lg shows only ZPO §233", "de_lg", []int64{201}},
{"de_olg shows only ZPO §233", "de_olg", []int64{201}},
{"de_bgh shows only ZPO §233", "de_bgh", []int64{201}},
{"de_bpatg shows only PatG §123 (DE national)", "de_bpatg", []int64{200, 203}},
{"dpma shows only PatG §123 (DPMA)", "dpma", []int64{200, 203}},
{"epa_grant shows only EPC Art.122", "epa_grant", []int64{202}},
{"epa_opp shows only EPC Art.122", "epa_opp", []int64{202}},
{"epa_appeal shows only EPC Art.122", "epa_appeal", []int64{202}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
Forums: []string{tc.forum},
Limit: 12,
})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
got := map[int64]bool{}
for _, p := range card.Pills {
if p.TriggerEventID != nil {
got[*p.TriggerEventID] = true
}
}
want := map[int64]bool{}
for _, id := range tc.wantTrigIDs {
want[id] = true
}
for id := range got {
if !want[id] {
t.Errorf("forum=%s leaked trigger id %d (got pills: %v)", tc.forum, id, got)
}
}
for id := range want {
if !got[id] {
t.Errorf("forum=%s missing expected trigger id %d (got pills: %v)", tc.forum, id, got)
}
}
})
}
})
t.Run("multiple forum chips union the legal_source allow-list for triggers", func(t *testing.T) {
// upc_cfi + de_lg → UPC.* OR DE.ZPO.* → trigger ids 201 + 207.
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
Forums: []string{"upc_cfi", "de_lg"},
Limit: 12,
})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
got := map[int64]bool{}
for _, p := range card.Pills {
if p.TriggerEventID != nil {
got[*p.TriggerEventID] = true
}
}
want := map[int64]bool{201: true, 207: true}
for id := range got {
if !want[id] {
t.Errorf("union forum upc_cfi+de_lg leaked trigger id %d", id)
}
}
for id := range want {
if !got[id] {
t.Errorf("union forum upc_cfi+de_lg missing trigger id %d", id)
}
}
})
t.Run("empty forum filter leaves cross-cutting pills untouched", func(t *testing.T) {
// No forum chips = all 5 triggers stay visible.
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
count := 0
for _, p := range card.Pills {
if p.Kind == "trigger" {
count++
}
}
if count != 5 {
t.Errorf("empty forum filter dropped a trigger pill: got %d, want 5", count)
}
})
t.Run("party filter narrows to defendant-only", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12})
if err != nil {

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
@@ -198,11 +206,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

@@ -189,6 +189,25 @@ func (s *HolidayService) IsNonWorkingDay(date time.Time, country, regime string)
return h != nil && h.IsClosure
}
// AdjustForNonWorkingDaysBackward is the symmetric counterpart of
// AdjustForNonWorkingDays: walks the date *backward* day-by-day until it
// lands on a working day for the given (country, regime). Used for
// timing='before' rules (e.g. UPC R.109.1 "no later than 1 month before
// the oral hearing") — when the computed cut-off lands on a weekend or
// public holiday, the lawyer must finish *earlier*, not later. Forward
// snap would push the cut-off past the statutory limit and cause the
// step to be filed too late. Bound by the same 60-iter cap as the
// forward variant.
func (s *HolidayService) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
for i := 0; i < 60 && s.IsNonWorkingDay(adjusted, country, regime); i++ {
adjusted = adjusted.AddDate(0, 0, -1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// AdjustForNonWorkingDays moves the date forward to the next working day for
// the given (country, regime). Returns adjusted date, the original
// (unmodified) date, and whether any adjustment was made.

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

@@ -0,0 +1,79 @@
package services
// Regression tests for the per-draft language column (t-paliad-276).
// The draft's `language` value drives both the placeholder-bag
// language pick (`procedural_event.name` → name_de vs name_en) and the
// template-variant lookup (`{code}.{lang}.docx` fallback chain). These
// tests pin the pure-function pieces — Build wiring needs DB fixtures
// and lives in the handler-layer smoke path.
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestNormalizeDraftLanguage(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
}{
{"de", "de"},
{"DE", "de"},
{" de ", "de"},
{"en", "en"},
{"EN", "en"},
{" en ", "en"},
{"fr", "de"}, // unknown collapses to de (the CHECK-allowed default)
{"", "de"},
{"english", "de"}, // strict — only the canonical two-letter code is accepted
}
for _, c := range cases {
if got := normalizeDraftLanguage(c.in); got != c.want {
t.Errorf("normalizeDraftLanguage(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// The placeholder bag picks the language-matched value for the
// canonical (procedural_event.name) and legacy (rule.name) keys based
// on the lang argument. This pins the wiring used by Build when a
// draft's language overrides the user's UI lang (t-paliad-276).
func TestAddRuleVars_LanguageSelectsMatchedName(t *testing.T) {
t.Parallel()
code := "de.inf.lg.erwidg"
rule := &models.DeadlineRule{
ID: uuid.New(),
SubmissionCode: &code,
Name: "Klageerwiderung",
NameEN: "Statement of Defence",
}
for _, lang := range []string{"de", "en"} {
bag := PlaceholderMap{}
addRuleVars(bag, rule, lang)
want := rule.Name
if strings.EqualFold(lang, "en") {
want = rule.NameEN
}
if got := bag["procedural_event.name"]; got != want {
t.Errorf("lang=%s: procedural_event.name = %q, want %q", lang, got, want)
}
if got := bag["rule.name"]; got != want {
t.Errorf("lang=%s: rule.name = %q, want %q (legacy alias must mirror canonical)", lang, got, want)
}
// The explicit *_de / *_en keys never change — both are always
// emitted so a template can pin one regardless of the draft's
// language. Regression guard against accidentally
// language-gating the explicit variants.
if bag["procedural_event.name_de"] != rule.Name {
t.Errorf("lang=%s: procedural_event.name_de = %q, want %q", lang, bag["procedural_event.name_de"], rule.Name)
}
if bag["procedural_event.name_en"] != rule.NameEN {
t.Errorf("lang=%s: procedural_event.name_en = %q, want %q", lang, bag["procedural_event.name_en"], rule.NameEN)
}
}
}

View File

@@ -47,6 +47,11 @@ type SubmissionDraft struct {
SubmissionCode string `db:"submission_code" json:"submission_code"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
// Language is the output language for the generated .docx — 'de' or
// 'en'. Drives the template-variant lookup ({code}.{lang}.docx
// fallback chain) and language-aware variable resolution
// ({{procedural_event.name}} → name_de or name_en). t-paliad-276.
Language string `db:"language" json:"language"`
VariablesRaw []byte `db:"variables" json:"-"`
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
@@ -94,6 +99,9 @@ type DraftPatch struct {
Name *string
Variables *PlaceholderMap
ProjectID **uuid.UUID
// Language sets the output language. Valid values: "de", "en".
// Anything else returns ErrInvalidInput. t-paliad-276.
Language *string
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
@@ -106,7 +114,7 @@ var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already tak
// draftColumns is the canonical select list — kept in one place so
// every fetch stays in sync.
const draftColumns = `id, project_id, submission_code, user_id, name,
const draftColumns = `id, project_id, submission_code, user_id, name, language,
variables, last_exported_at, last_exported_sha,
created_at, updated_at`
@@ -157,7 +165,7 @@ type DraftWithProject struct {
func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid.UUID) ([]DraftWithProject, error) {
var rows []DraftWithProject
err := s.db.SelectContext(ctx, &rows,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
d.variables, d.last_exported_at, d.last_exported_sha,
d.created_at, d.updated_at,
p.title AS project_title,
@@ -263,13 +271,18 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
if err != nil {
return nil, err
}
// Seed the new draft's output language from the user's UI lang so
// the editor opens in the language the lawyer is already working in.
// Anything other than "en" normalizes to "de" — matches the DB CHECK
// constraint and the project's primary-language default.
draftLang := normalizeDraftLanguage(lang)
var d SubmissionDraft
err = s.db.GetContext(ctx, &d,
`INSERT INTO paliad.submission_drafts
(project_id, submission_code, user_id, name)
VALUES ($1, $2, $3, $4)
(project_id, submission_code, user_id, name, language)
VALUES ($1, $2, $3, $4, $5)
RETURNING `+draftColumns,
projectID, submissionCode, userID, name)
projectID, submissionCode, userID, name, draftLang)
if err != nil {
return nil, fmt.Errorf("create submission draft: %w", err)
}
@@ -394,6 +407,16 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.Language != nil {
newLang := strings.ToLower(strings.TrimSpace(*patch.Language))
if newLang != "de" && newLang != "en" {
return nil, ErrInvalidInput
}
setParts = append(setParts, fmt.Sprintf("language = $%d", idx))
args = append(args, newLang)
idx++
}
if len(setParts) == 0 {
return existing, nil
}
@@ -476,6 +499,10 @@ func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *Subm
UserID: draft.UserID,
ProjectID: draft.ProjectID,
SubmissionCode: draft.SubmissionCode,
// The draft's language overrides the user's UI lang — the lawyer
// can author an EN draft in a DE-UI session and vice versa
// (t-paliad-276). Empty / unknown falls back to "de".
Lang: normalizeDraftLanguage(draft.Language),
})
if err != nil {
return nil, nil, err
@@ -530,12 +557,13 @@ func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDr
// ProjectService.GetByID — callers get ErrNotFound on no-access.
// ErrSubmissionRuleNotFound surfaces when no published rule matches the
// requested submission_code.
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
pid := projectID
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
UserID: userID,
ProjectID: &pid,
SubmissionCode: submissionCode,
Lang: normalizeDraftLanguage(lang),
})
if err != nil {
return nil, nil, err
@@ -562,6 +590,18 @@ func (d *SubmissionDraft) decodeVariables() error {
return nil
}
// normalizeDraftLanguage maps any input to one of the two allowed
// language values for paliad.submission_drafts.language. Anything other
// than "en" (case-insensitive) collapses to "de" — matches the DB CHECK
// constraint, the project's primary-language default, and the seed
// behaviour for existing rows that came in before the column existed.
func normalizeDraftLanguage(lang string) string {
if strings.EqualFold(strings.TrimSpace(lang), "en") {
return "en"
}
return "de"
}
// Compile-time guard: ensure the *models.User reference in the import
// graph doesn't get optimised away by linters. The service doesn't
// dereference User directly — that happens in SubmissionVarsService —

View File

@@ -327,6 +327,40 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
}
}
// TestRenderHTML_WrapsOverriddenValueSameAsResolved is the t-paliad-274
// regression: m's report on m/paliad#106 was that "When filled, the link
// disappears". The preview HTML must wrap an override value with the
// same <span class="draft-var"> as it would an unfilled placeholder, so
// the click-jump from preview→sidebar persists after the user types a
// value. There is no distinction at the renderer level between a value
// that came from the resolved bag (project / parties / deadline lookups)
// and a value the lawyer typed into the sidebar — both arrive in the
// same PlaceholderMap and both must be wrapped.
func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
doc := `<w:document><w:body>` +
`<w:p><w:r><w:t>{{project.case_number}} / {{firm.name}}</w:t></w:r></w:p>` +
`</w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
// project.case_number is the typed-by-lawyer override.
// firm.name is the always-resolved value from the firm bag.
html, err := r.RenderHTML(tmpl, PlaceholderMap{
"project.case_number": "UPC_CFI_42/2026",
"firm.name": "HLC",
}, nil)
if err != nil {
t.Fatalf("render html: %v", err)
}
wantOverride := `<span class="draft-var" data-var="project.case_number">UPC_CFI_42/2026</span>`
if !strings.Contains(html, wantOverride) {
t.Errorf("expected overridden value wrapped in draft-var span (click-jump must persist after fill, t-paliad-274), got %q", html)
}
wantResolved := `<span class="draft-var" data-var="firm.name">HLC</span>`
if !strings.Contains(html, wantResolved) {
t.Errorf("expected resolved value still wrapped, 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

View File

@@ -6,17 +6,28 @@ package services
//
// Variables span six namespaces:
//
// firm.* process-wide (branding.Name)
// user.* caller's user row
// today.* server time in Europe/Berlin, locale-aware
// project.* paliad.projects + joined proceeding type
// parties.* paliad.parties grouped by role
// rule.* paliad.deadline_rules row keyed by submission_code
// deadline.* next open paliad.deadlines row for (project, rule), if any
// firm.* process-wide (branding.Name)
// user.* caller's user row
// today.* server time in Europe/Berlin, locale-aware
// project.* paliad.projects + joined proceeding type
// parties.* paliad.parties grouped by role
// procedural_event.* paliad.deadline_rules row keyed by submission_code
// — the "what kind of step in the proceeding"
// identity (Schriftsatz, Anhörung, Entscheidung,
// …). See docs/design-procedural-events-model-
// 2026-05-25.md (t-paliad-262 Slice A).
// rule.* legacy alias for procedural_event.*; emitted
// unconditionally for backward compatibility
// with Word templates and saved drafts authored
// before the rename. @deprecated — new templates
// should use the procedural_event.* form.
// deadline.* next open paliad.deadlines row for
// (project, procedural_event), if any
//
// Locale handling: every long-form date string is computed in both DE
// and EN; the renderer picks based on the user's lang preference. The
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
// procedural-event pretty-printer (legalSourcePretty) also has DE/EN
// variants.
//
// Visibility: caller passes userID; ProjectService.GetByID enforces
// paliad.can_see_project — unauthorised callers get the standard
@@ -65,6 +76,13 @@ type SubmissionVarsContext struct {
UserID uuid.UUID
ProjectID *uuid.UUID
SubmissionCode string
// Lang pins the output language for this Build, overriding the
// caller's UI preference (user.Lang). When empty, Build falls back
// to user.Lang so existing callers (the format-only Slice 1 path)
// keep working unchanged. The draft editor passes the per-draft
// `language` column (t-paliad-276) so DE/EN can be picked
// independently of the UI session.
Lang string
}
// SubmissionVarsResult bundles the placeholder map with the lookup
@@ -114,7 +132,15 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return nil, err
}
lang := user.Lang
// Per-call Lang override (t-paliad-276) wins over the user's UI
// language so the draft editor can render an EN .docx from a DE-UI
// session and vice versa. Falls back to the user pref when the
// caller didn't specify, preserving the format-only Slice 1
// behaviour.
lang := strings.ToLower(strings.TrimSpace(in.Lang))
if lang != "de" && lang != "en" {
lang = user.Lang
}
if lang == "" {
lang = "de"
}
@@ -173,9 +199,12 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return out, nil
}
// loadPublishedRule fetches the deadline_rule that owns the given
// submission_code. Restricts to lifecycle_state='published' so drafts
// never end up shaping a real submission.
// loadPublishedRule fetches the published procedural-event template
// (paliad.deadline_rules row) keyed by submission_code. Restricts to
// lifecycle_state='published' so drafts never end up shaping a real
// submission. Function name retained for Slice A (prose-only); Slice
// B renames it to loadPublishedProceduralEvent when the Go type is
// renamed (t-paliad-262 §6).
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
if submissionCode == "" {
return nil, ErrSubmissionRuleNotFound
@@ -346,21 +375,55 @@ func addPartyVars(bag PlaceholderMap, parties []models.Party) {
}
}
// addRuleVars populates rule.* — submission_code, name(_en),
// legal_source (+ pretty form), primary_party, event_type.
// addRuleVars populates the procedural-event variable namespace —
// code, name(_en), legal_source (+ pretty form), primary_party, kind.
//
// Two key prefixes are emitted for every value:
//
// - procedural_event.* — canonical name (t-paliad-262 Slice A,
// design docs/design-procedural-events-model-2026-05-25.md).
// - rule.* — legacy alias kept forever (m's call,
// issue m/paliad#93 Q7); existing Word templates and saved
// submission_drafts authored before the rename keep working.
//
// `procedural_event.event_kind` is the canonical key for the
// procedural-event kind (filing|reply|hearing|decision|order). The
// legacy `rule.event_type` alias holds the same string. The column
// itself stays named `event_type` on `paliad.deadline_rules` — Slice
// A is prose-only; the column-level rename to `event_kind` is Slice B.
//
// Function name stays `addRuleVars` to avoid coupling Slice A to the
// Go-type rename which is Slice B (B.5 sub-slice).
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
bag["rule.submission_code"] = derefString(r.SubmissionCode)
code := derefString(r.SubmissionCode)
var localizedName string
if strings.EqualFold(lang, "en") {
bag["rule.name"] = r.NameEN
localizedName = r.NameEN
} else {
bag["rule.name"] = r.Name
localizedName = r.Name
}
legalSource := derefString(r.LegalSource)
legalSourcePrettyVal := legalSourcePretty(legalSource, lang)
primaryParty := derefString(r.PrimaryParty)
eventKind := derefString(r.EventType)
bag["procedural_event.code"] = code
bag["procedural_event.name"] = localizedName
bag["procedural_event.name_de"] = r.Name
bag["procedural_event.name_en"] = r.NameEN
bag["procedural_event.legal_source"] = legalSource
bag["procedural_event.legal_source_pretty"] = legalSourcePrettyVal
bag["procedural_event.primary_party"] = primaryParty
bag["procedural_event.event_kind"] = eventKind
bag["rule.submission_code"] = code
bag["rule.name"] = localizedName
bag["rule.name_de"] = r.Name
bag["rule.name_en"] = r.NameEN
bag["rule.legal_source"] = derefString(r.LegalSource)
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
bag["rule.primary_party"] = derefString(r.PrimaryParty)
bag["rule.event_type"] = derefString(r.EventType)
bag["rule.legal_source"] = legalSource
bag["rule.legal_source_pretty"] = legalSourcePrettyVal
bag["rule.primary_party"] = primaryParty
bag["rule.event_type"] = eventKind
}
// addDeadlineVars populates deadline.* from the next pending row. When

View File

@@ -0,0 +1,153 @@
package services
// Regression test for the procedural-event placeholder aliases
// (t-paliad-262 Slice A, m/paliad#93 Q7).
//
// The variable bag emits TWO key prefixes for the procedural-event
// namespace:
//
// - procedural_event.* (canonical, post-rename)
// - rule.* (legacy, @deprecated)
//
// m's lock: keep the legacy aliases forever so lawyer-authored Word
// templates and existing paliad.submission_drafts rows that already
// contain `{{rule.X}}` keep merging correctly.
//
// This test pins the contract: every (canonical, legacy) pair must
// resolve to the same string in the placeholder map, for every value
// of (lang, present-vs-NULL columns). Removing the legacy aliases —
// or letting them drift in value from the canonical — must light up
// here BEFORE the change can land in main.
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestAddRuleVars_CanonicalAndLegacyAliasesMatch(t *testing.T) {
t.Parallel()
// Pairs are (canonical key, legacy key). Order matters only for
// the assertion message — the test checks string equality both
// ways round.
pairs := []struct {
canonical string
legacy string
}{
{"procedural_event.code", "rule.submission_code"},
{"procedural_event.name", "rule.name"},
{"procedural_event.name_de", "rule.name_de"},
{"procedural_event.name_en", "rule.name_en"},
{"procedural_event.legal_source", "rule.legal_source"},
{"procedural_event.legal_source_pretty", "rule.legal_source_pretty"},
{"procedural_event.primary_party", "rule.primary_party"},
{"procedural_event.event_kind", "rule.event_type"},
}
// Build a fully-populated rule row. Every nullable column has a
// distinct non-empty value so missing-value bugs (e.g. the legacy
// key copying "" while the canonical key copies the real value)
// would surface.
code := "dpma.appeal.bgh.begruendung"
desc := "Rechtsbeschwerdebegründung — § 102 PatG"
party := "both"
kind := "filing"
legal := "DE.PatG.102"
ruleCode := "§ 102 PatG"
rule := &models.DeadlineRule{
ID: uuid.New(),
SubmissionCode: &code,
Name: "Rechtsbeschwerdebegründung",
NameEN: "Appeal brief",
Description: &desc,
PrimaryParty: &party,
EventType: &kind,
LegalSource: &legal,
RuleCode: &ruleCode,
}
for _, lang := range []string{"de", "en"} {
t.Run(lang, func(t *testing.T) {
t.Parallel()
bag := PlaceholderMap{}
addRuleVars(bag, rule, lang)
for _, p := range pairs {
canonicalVal, canonicalOK := bag[p.canonical]
legacyVal, legacyOK := bag[p.legacy]
if !canonicalOK {
t.Errorf("canonical key %q missing from bag (lang=%s); "+
"Slice A must emit both forms", p.canonical, lang)
}
if !legacyOK {
t.Errorf("legacy alias %q missing from bag (lang=%s); "+
"removing legacy aliases would break existing Word "+
"templates that paliad doesn't see — keep the "+
"emission per m/paliad#93 Q7", p.legacy, lang)
}
if canonicalVal != legacyVal {
t.Errorf("alias drift: %q=%q vs %q=%q (lang=%s)",
p.canonical, canonicalVal,
p.legacy, legacyVal, lang)
}
}
// Sanity: the localized name actually localizes (the
// canonical and legacy `name` keys depend on lang). If
// this fails the loop above wouldn't catch it (both keys
// would agree on the wrong language).
localized := bag["procedural_event.name"]
if strings.EqualFold(lang, "en") && localized != rule.NameEN {
t.Errorf("expected EN localized name=%q, got %q",
rule.NameEN, localized)
}
if strings.EqualFold(lang, "de") && localized != rule.Name {
t.Errorf("expected DE localized name=%q, got %q",
rule.Name, localized)
}
})
}
}
func TestAddRuleVars_NullableFieldsEmitEmptyOnBothPrefixes(t *testing.T) {
t.Parallel()
// A minimal rule with every optional column NULL. The bag must
// still emit every canonical + legacy key — with the empty
// string — so downstream merging produces the standard
// "[KEIN WERT: ...]" marker rather than a broken template.
rule := &models.DeadlineRule{
ID: uuid.New(),
Name: "Generic step",
NameEN: "Generic step",
}
bag := PlaceholderMap{}
addRuleVars(bag, rule, "de")
mustHave := []string{
"procedural_event.code", "rule.submission_code",
"procedural_event.legal_source", "rule.legal_source",
"procedural_event.legal_source_pretty", "rule.legal_source_pretty",
"procedural_event.primary_party", "rule.primary_party",
"procedural_event.event_kind", "rule.event_type",
}
for _, key := range mustHave {
val, ok := bag[key]
if !ok {
t.Errorf("key %q missing from bag even with NULL source column; "+
"derefString must materialize the empty string so the "+
"merger sees the variable and renders the missing-value "+
"marker", key)
}
if val != "" {
t.Errorf("key %q = %q, want \"\" (source column was NULL)", key, val)
}
}
}

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 {
@@ -366,6 +382,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}
@@ -387,6 +408,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,
@@ -541,6 +570,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
@@ -667,15 +705,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,450 @@
// HL-firm skeleton submission template generator (t-paliad-275).
//
// Reads HLC's "HL Patents Style" .dotm letterhead, strips its VBA
// macros and template-only artifacts, then emits a clean .docx that:
//
// 1. Preserves every HL paragraph + character style (HLpat-Heading-H1,
// HLpat-Body-B0, HLpat-Signature, HLpat-Table-Recitals-*, …) by
// keeping word/styles.xml, word/theme/*, word/numbering.xml,
// word/fontTable.xml, settings.xml, footnotes/endnotes from the
// source .dotm untouched.
// 2. Preserves the firm letterhead (logo header + firm-address footer)
// by keeping word/header[12].xml + word/footer[12].xml and the
// sectPr that references them.
// 3. Replaces word/document.xml with a Schriftsatz-shaped body that
// exercises every SubmissionVarsService placeholder (firm.*,
// today.*, user.*, project.*, parties.*, procedural_event.*, rule.*,
// deadline.*) — applying HL paragraph/character styles to each
// section so the rendered output reads as a real HL submission with
// variables substituted.
//
// Drop the output into HL/mWorkRepo at
//
// 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
//
// so paliad's submission generator picks it up via the fallback chain.
// Lookup order after this CL: per-firm per-code → _firm-skeleton.docx
// (THIS file — HL formatting + placeholders) → universal _skeleton.docx
// (generic skeleton from t-paliad-259) → bare HL Patents Style .dotm
// (no placeholders). See internal/handlers/submission_drafts.go
// resolveSubmissionTemplate.
//
// Why this is firm-specific: the .dotm carries HL-licensed fonts,
// HL-branded logo media, and HLpat-prefixed style IDs. The output lives
// under the firm-namespaced directory in mWorkRepo so a future firm gets
// its own equivalent file generated against its own .dotm.
//
// Run:
//
// go run ./scripts/gen-hl-skeleton-template \
// -in /tmp/hl-patents-style.dotm \
// -out /tmp/_firm-skeleton.docx
//
// Output is byte-stable across runs for a given input (zip mtimes
// pinned).
package main
import (
"archive/zip"
"bytes"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
)
func main() {
in := flag.String("in", "", "path to source HL Patents Style .dotm (required)")
out := flag.String("out", "_firm-skeleton.docx", "output .docx path")
flag.Parse()
if *in == "" {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: -in is required (path to HL Patents Style .dotm)")
os.Exit(2)
}
srcBytes, err := os.ReadFile(*in)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: read source:", err)
os.Exit(1)
}
docx, err := buildDocx(srcBytes)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template:", err)
os.Exit(1)
}
if err := os.WriteFile(*out, docx, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
}
// fixedTime pins every zip entry's mtime so successive runs over the
// same .dotm produce byte-stable output. Useful for diffing the
// generated file in PR review.
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
// dropPaths lists zip entries removed during the .dotm → .docx
// conversion. VBA macros + their keymap binding + the template-only
// glossary parts and ribbon customizations are all dead weight (and
// some actively trigger Word's macro-security warning) — none of them
// add anything to a placeholder-rich Schriftsatz starter.
var dropPaths = map[string]bool{
"word/vbaProject.bin": true,
"word/vbaData.xml": true,
"word/customizations.xml": true,
"userCustomization/customUI.xml": true,
"customUI/customUI14.xml": true,
"word/glossary/document.xml": true,
"word/glossary/_rels/document.xml.rels": true,
"word/glossary/fontTable.xml": true,
"word/glossary/numbering.xml": true,
"word/glossary/settings.xml": true,
"word/glossary/styles.xml": true,
"word/glossary/webSettings.xml": true,
}
// rIdsToDrop names the document-rel ids whose targets are stripped
// from the package (vbaProject, customizations.xml, glossary). They
// must vanish from word/_rels/document.xml.rels so Word doesn't choke
// on a dangling reference.
var rIdsToDrop = map[string]bool{
"rId1": true, // vbaProject.bin
"rId2": true, // customizations.xml (keymap to VBA)
"rId21": true, // glossary/document.xml
}
func buildDocx(src []byte) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
if err != nil {
return nil, fmt.Errorf("open source zip: %w", err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, f := range zr.File {
name := f.Name
if dropPaths[name] {
continue
}
body, err := readZipEntry(f)
if err != nil {
return nil, fmt.Errorf("read %s: %w", name, err)
}
switch name {
case "[Content_Types].xml":
body = []byte(patchContentTypes(string(body)))
case "_rels/.rels":
body = []byte(patchRootRels(string(body)))
case "word/_rels/document.xml.rels":
body = []byte(patchDocumentRels(string(body)))
case "word/document.xml":
body = []byte(buildDocumentXML())
}
hdr := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
Modified: fixedTime,
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return nil, fmt.Errorf("create %s: %w", name, err)
}
if _, err := w.Write(body); err != nil {
return nil, fmt.Errorf("write %s: %w", name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("finalise zip: %w", err)
}
return buf.Bytes(), nil
}
func readZipEntry(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// patchContentTypes rewrites the macroEnabledTemplate part type to the
// regular wordprocessingml.document type (a .dotm carries the macro
// part type even on the body part), and removes Default/Override
// entries that target now-deleted parts (vba binary, customizations,
// glossary).
func patchContentTypes(in string) string {
out := in
out = strings.ReplaceAll(out,
`<Override PartName="/word/document.xml" ContentType="application/vnd.ms-word.template.macroEnabledTemplate.main+xml"/>`,
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>`)
removals := []string{
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`,
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`,
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`,
`<Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/>`,
`<Override PartName="/word/glossary/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>`,
`<Override PartName="/word/glossary/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`,
`<Override PartName="/word/glossary/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>`,
`<Override PartName="/word/glossary/webSettings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"/>`,
`<Override PartName="/word/glossary/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/>`,
}
for _, r := range removals {
out = strings.ReplaceAll(out, r, "")
}
return out
}
// patchRootRels drops the userCustomization (ribbon mini-tab) and the
// customUI14 extensibility relationships — both reference VBA-backed
// UI we don't ship.
func patchRootRels(in string) string {
out := in
out = stripRelByPrefix(out, `<Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2006/relationships/ui/userCustomization"`)
out = stripRelByPrefix(out, `<Relationship Id="Rf8f70ab1afd0469a" Type="http://schemas.microsoft.com/office/2007/relationships/ui/extensibility"`)
return out
}
// patchDocumentRels drops the document-level rels whose targets we
// stripped (vbaProject, customizations.xml, glossaryDocument).
func patchDocumentRels(in string) string {
out := in
for rid := range rIdsToDrop {
needle := `<Relationship Id="` + rid + `" `
out = stripRelByPrefix(out, needle)
}
return out
}
// stripRelByPrefix removes the full <Relationship .../> element whose
// open tag starts with the given prefix. Tolerates either a regular
// closing tag (</Relationship>) or the more common self-closing form.
func stripRelByPrefix(s, prefix string) string {
for {
start := strings.Index(s, prefix)
if start < 0 {
return s
}
// Find end of this element (next "/>"). The .dotm always uses the
// self-closing form for Relationship elements.
end := strings.Index(s[start:], "/>")
if end < 0 {
return s
}
s = s[:start] + s[start+end+2:]
}
}
// buildDocumentXML emits a Schriftsatz skeleton that exercises every
// SubmissionVarsService placeholder (the canonical 48-key v1 contract
// + the procedural_event.* canonical names + their rule.* legacy
// aliases). The structure mirrors a real DE/UPC submission — title
// block → court → rubrum → patent reference → submission title →
// legal grounds → Sachverhalt/Anträge/Rechtsausführungen/Beweis →
// signature → locale-variant verification footer.
//
// Each placeholder lives in its own <w:r> run so the renderer's pass-1
// (format-preserving single-run replace) catches every key. HL
// paragraph styles (HLpat-Heading-H1, HLpat-Header-Section, etc.) are
// applied via pStyle, character styles via rStyle.
//
// The sectPr at the bottom is copied verbatim from the source .dotm
// so the firm header/footer references (rId16=header1, rId17=footer1,
// rId18=header2 first-page, rId19=footer2 first-page) keep resolving
// after we replace the body. pgSz/pgMar/cols/docGrid match the .dotm
// exactly — a lawyer printing this gets the same A4 layout the .dotm
// produces.
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" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`)
b.WriteString(`<w:body>`)
skeletonBanner(&b)
heading(&b, "HLpat-Heading-H1", "{{firm.name}}")
body0(&b, "Bearbeiter: {{user.display_name}}")
body0(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
body0(&b, "Datum: {{today.long_de}} ({{today.iso}})")
body0(&b, "{{firm.signature_block}}")
headerSection(&b, "{{project.court}}")
body0(&b, "Aktenzeichen: {{project.case_number}}")
body0(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
body0(&b, "Instanz: {{project.instance_level}}")
headerSubsection(&b, "In der Sache")
recitalsParty(&b, "{{parties.claimant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.claimant.representative}}")
recitalsRoles(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
recitalsSequencer(&b, "gegen")
recitalsParty(&b, "{{parties.defendant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.defendant.representative}}")
recitalsRoles(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
recitalsSequencer(&b, "sowie")
recitalsParty(&b, "{{parties.other.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.other.representative}}")
recitalsRoles(&b, "— Weitere Beteiligte —")
headerSubsection(&b, "Betreff")
body0(&b, "Streitpatent: {{project.patent_number}} (UPC-Schreibweise: {{project.patent_number_upc}})")
body0(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
body0(&b, "Projekttitel: {{project.title}}")
body0(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
body0(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
body0(&b, "Internes Aktenzeichen: {{project.reference}}")
heading(&b, "HLpat-Heading-H1", "{{procedural_event.name}}")
body0(&b, "(Schriftsatz-Code: {{procedural_event.code}})")
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
headerSubsection(&b, "Frist")
body0(&b, "Frist-Bezeichnung: {{deadline.title}}")
body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}")
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
heading(&b, "HLpat-Heading-H2", "II. Anträge")
requestsIntro(&b, "Es wird beantragt:")
requestsLevel1(&b, "[Antrag 1 — gemäß {{procedural_event.legal_source_pretty}}]")
requestsLevel1(&b, "[Antrag 2]")
heading(&b, "HLpat-Heading-H2", "III. Rechtsausführungen")
body0(&b, "[Hier folgen die Rechtsausführungen.]")
heading(&b, "HLpat-Heading-H2", "IV. Beweis")
evidenceOffering(&b, "Beweis: [Beweismittel — z. B. Anlage K1: {{project.patent_number}}]")
heading(&b, "HLpat-Heading-H2", "Schlussformel")
signature(&b, "{{today.long_de}}")
signature(&b, "")
signature(&b, "{{user.display_name}}")
signature(&b, "{{firm.name}}")
// Locale-aware verification block — exercises every EN/DE alias the
// variable bag carries plus the rule.* legacy aliases so a lawyer
// editing the template sees that both surfaces resolve. A real
// submission deletes this section after sanity-checking the render.
heading(&b, "HLpat-Heading-H3", "Locale-Varianten & Legacy-Aliase (SKELETON)")
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}")
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")
body1(&b, "Rule legacy aliases — primary_party: {{rule.primary_party}}, event_type: {{rule.event_type}}")
// sectPr — copied verbatim from the source .dotm. Keeps the firm
// letterhead header (rId16=header1.xml, rId18=header2.xml first-page)
// and the firm-address footer (rId17, rId19) on every printed page.
b.WriteString(sectPrXML)
b.WriteString(`</w:body></w:document>`)
return b.String()
}
// sectPrXML matches the source .dotm's section properties exactly so
// the firm header/footer refs and A4 page geometry round-trip.
const sectPrXML = `<w:sectPr><w:headerReference w:type="default" r:id="rId16"/><w:footerReference w:type="default" r:id="rId17"/><w:headerReference w:type="first" r:id="rId18"/><w:footerReference w:type="first" r:id="rId19"/><w:pgSz w:w="11906" w:h="16838" w:code="9"/><w:pgMar w:top="567" w:right="1418" w:bottom="567" w:left="1418" w:header="284" w:footer="284" w:gutter="0"/><w:cols w:space="720"/><w:titlePg/><w:docGrid w:linePitch="286"/></w:sectPr>`
func skeletonBanner(b *strings.Builder) {
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="HLpat-Heading-H1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — HL Patents Style mit Platzhaltern (nicht freigegeben)</w:t></w:r></w:p>`)
}
func heading(b *strings.Builder, style, text string) { styledPara(b, style, "", text) }
func headerSection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Section", "", text) }
func headerSubsection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Subsection", "", text) }
func body0(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B0", "", text) }
func body1(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B1", "", text) }
func recitalsParty(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Party", "", text) }
func recitalsPartyDetails(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyDetails", "", text) }
func recitalsRoles(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyRoles", "", text) }
func recitalsSequencer(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Sequencers", "", text) }
func requestsIntro(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Intro", "", text) }
func requestsLevel1(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Level1", "", text) }
func evidenceOffering(b *strings.Builder, text string) { styledPara(b, "HLpat-EvidenceOffering", "", text) }
func signature(b *strings.Builder, text string) { styledPara(b, "HLpat-Signature", "", text) }
// styledPara writes one paragraph with the given pStyle (paragraph
// style id) and optional rStyle (character style applied to every run).
// Empty style ids drop the corresponding wrapper. Placeholders inside
// `text` are split into their own runs so the renderer's pass-1
// single-run replace catches each one independently.
func styledPara(b *strings.Builder, pStyle, rStyle, text string) {
b.WriteString(`<w:p>`)
if pStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(pStyle)
b.WriteString(`"/></w:pPr>`)
}
for _, seg := range splitOnPlaceholders(text) {
b.WriteString(`<w:r>`)
if rStyle != "" {
b.WriteString(`<w:rPr><w:rStyle w:val="`)
b.WriteString(rStyle)
b.WriteString(`"/></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
}