Compare commits
31 Commits
mai/atlas/
...
mai/demete
| Author | SHA1 | Date | |
|---|---|---|---|
| af7a1e9c56 | |||
| 930771a898 | |||
| f2fbf93adf | |||
| 7368e7012b | |||
| d4df81e374 | |||
| f4dee97493 | |||
| 7aed8e4ec5 | |||
| b429dabf9e | |||
| d3c28009de | |||
| 8be7af7cd6 | |||
| d52995a4d6 | |||
| f0c343c638 | |||
| f11390d18b | |||
| aa2f4aacc6 | |||
| 3d985ef0c2 | |||
| f72e8a7b85 | |||
| 013facb9db | |||
| ff503ffc43 | |||
| 05f7ea2af5 | |||
| df2a1275cb | |||
| 3700d68c68 | |||
| e0c8401482 | |||
| 247e9005db | |||
| e68b800d52 | |||
| bcfde73815 | |||
| 4ead2d08c1 | |||
| a8e2bd8350 | |||
| 8c94dccf83 | |||
| 90f5dd4b1b | |||
| 24f3baf61f | |||
| 2683c5f9cf |
@@ -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:
|
||||
|
||||
848
docs/design-inbox-overhaul-2026-05-25.md
Normal file
848
docs/design-inbox-overhaul-2026-05-25.md
Normal 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:320–345`'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:344–430`.
|
||||
|
||||
### 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:46–58` 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:107–141`
|
||||
for the `<div className="events-view-selector">` block and
|
||||
`client/events.ts:617–650` 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:103–144)
|
||||
|
||||
```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:651–700` (`.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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 — Paliad</title>
|
||||
<title data-i18n="inbox.title.feed">Inbox — 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üfung fü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" />
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
4
internal/db/migrations/126_users_inbox_seen_at.down.sql
Normal file
4
internal/db/migrations/126_users_inbox_seen_at.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- t-paliad-249 — drop inbox read cursor.
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP COLUMN IF EXISTS inbox_seen_at;
|
||||
21
internal/db/migrations/126_users_inbox_seen_at.up.sql
Normal file
21
internal/db/migrations/126_users_inbox_seen_at.up.sql
Normal 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.';
|
||||
146
internal/db/migrations/127_wave0_tier0_deadline_fixes.down.sql
Normal file
146
internal/db/migrations/127_wave0_tier0_deadline_fixes.down.sql
Normal 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';
|
||||
477
internal/db/migrations/127_wave0_tier0_deadline_fixes.up.sql
Normal file
477
internal/db/migrations/127_wave0_tier0_deadline_fixes.up.sql
Normal 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 $$;
|
||||
@@ -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;
|
||||
36
internal/db/migrations/128_deadline_rules_unit_check.up.sql
Normal file
36
internal/db/migrations/128_deadline_rules_unit_check.up.sql
Normal 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'));
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS language;
|
||||
17
internal/db/migrations/130_submission_drafts_language.up.sql
Normal file
17
internal/db/migrations/130_submission_drafts_language.up.sql
Normal 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.';
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
43
internal/handlers/submission_template_lang_test.go
Normal file
43
internal/handlers/submission_template_lang_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
79
internal/services/submission_draft_language_test.go
Normal file
79
internal/services/submission_draft_language_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
153
internal/services/submission_vars_aliases_test.go
Normal file
153
internal/services/submission_vars_aliases_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
111
internal/services/view_service_inbox_test.go
Normal file
111
internal/services/view_service_inbox_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
450
scripts/gen-hl-skeleton-template/main.go
Normal file
450
scripts/gen-hl-skeleton-template/main.go
Normal 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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user