Compare commits
55 Commits
mai/knuth/
...
mai/artemi
| Author | SHA1 | Date | |
|---|---|---|---|
| 3700d68c68 | |||
| 247e9005db | |||
| e68b800d52 | |||
| bcfde73815 | |||
| 4ead2d08c1 | |||
| 31d78526cf | |||
| a8e2bd8350 | |||
| 8c94dccf83 | |||
| 34e3d7188e | |||
| 0f2f3e3ea1 | |||
| 2683c5f9cf | |||
| 51fca9383f | |||
| 99c9d89daa | |||
| 7bc6fdb18a | |||
| 94a9e7e5fb | |||
| f55648944c | |||
| 7e66da8def | |||
| ef21e43375 | |||
| 4cb99fb627 | |||
| 452ccdf127 | |||
| 045accc6d9 | |||
| e6b61b4d2e | |||
| 940df95418 | |||
| 538c2d2da9 | |||
| a9a9adbd2a | |||
| f24a90b722 | |||
| 55bfe439f2 | |||
| 0ac26fe0ee | |||
| 72b64140e9 | |||
| 50cd80a4a6 | |||
| 716f6d7ece | |||
| 1bf62c78e3 | |||
| 9a774ba3ad | |||
| 8caaf6a631 | |||
| 228ae1b263 | |||
| cdd3747c2b | |||
| 02255c4234 | |||
| 206f2917ea | |||
| 5df87f4129 | |||
| 898348a64a | |||
| 1714b788d2 | |||
| db8335253b | |||
| 5589cbb477 | |||
| 0059e3f15b | |||
| a911a2d0ee | |||
| b26f04ffe0 | |||
| 8e195cb497 | |||
| 1f7de99493 | |||
| 0adcc2c826 | |||
| 2c7ac6423f | |||
| 436c1b41bb | |||
| 2c5f85b802 | |||
| d3aade5aac | |||
| 17d2fff661 | |||
| f7374a67cd |
@@ -151,6 +151,14 @@ func main() {
|
||||
|
||||
eventTypeSvc := services.NewEventTypeService(pool, users)
|
||||
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
||||
partySvc := services.NewPartyService(pool, projectSvc)
|
||||
// t-paliad-238 — dedicated submission draft editor. The variable
|
||||
// bag service is shared between the renderer (export) and the
|
||||
// preview HTML path. Resurrected from t-paliad-215 Slice 1 backend
|
||||
// (commits 3677c81 + 1765d5e + 8ea3509).
|
||||
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
|
||||
submissionRenderer := services.NewSubmissionRenderer()
|
||||
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
@@ -160,7 +168,8 @@ func main() {
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: services.NewPartyService(pool, projectSvc),
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -211,6 +220,23 @@ func main() {
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
// PALIAD_EXPORT_DIR is set (LocalDiskStore needs a target
|
||||
// directory). Without it the /admin/backups handlers return 503
|
||||
// in the same shape as Paliadin's gate. The directory is created
|
||||
// (0700) on first use; a malformed path fails fast at boot so
|
||||
// misconfig surfaces before the server starts taking traffic.
|
||||
if exportDir := strings.TrimSpace(os.Getenv("PALIAD_EXPORT_DIR")); exportDir != "" {
|
||||
store, err := services.NewLocalDiskStore(exportDir)
|
||||
if err != nil {
|
||||
log.Fatalf("PALIAD_EXPORT_DIR: %v", err)
|
||||
}
|
||||
svcBundle.Backup = services.NewBackupRunner(pool, svcBundle.Export, store)
|
||||
log.Printf("backup: LocalDiskStore at %s (/admin/backups active)", exportDir)
|
||||
} else {
|
||||
log.Println("PALIAD_EXPORT_DIR not set — /admin/backups will return 503")
|
||||
}
|
||||
|
||||
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
|
||||
// for the inbox-approvals widget. Done post-construction to avoid
|
||||
// a circular constructor dependency (ApprovalService doesn't need
|
||||
|
||||
@@ -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:
|
||||
|
||||
856
docs/design-date-range-picker-2026-05-25.md
Normal file
856
docs/design-date-range-picker-2026-05-25.md
Normal file
@@ -0,0 +1,856 @@
|
||||
# Symmetric date-range picker — design
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Task:** t-paliad-248 (Gitea m/paliad#79)
|
||||
**Inventor:** atlas
|
||||
**Branch:** `mai/atlas/inventor-symmetric-date`
|
||||
**Status:** READ-ONLY design. Awaiting head's go/no-go before coder shift.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Today paliad has **three independent date-range schemes** scattered across surfaces:
|
||||
|
||||
1. **`/agenda`** — future-only chip row [7|14|30|90 Tage], state `rangeDays`.
|
||||
2. **`/admin/audit-log`** — past-only `<select>` [24h|7d|30d|custom|all] + manual `<input type="date">` pair.
|
||||
3. **`/projects/:id/chart`** — symmetric `RangePreset` [1y|2y|all|custom] + manual date pair.
|
||||
|
||||
…plus a **fourth, unified `TimeHorizon` contract** (`internal/services/filter_spec.go`, mirrored in `frontend/src/client/views/types.ts`) that's used by the filter-bar, Verlauf, Custom Views, and InboxFilterBar — but its "Anpassen" custom-range chip is still stubbed (`filter-bar/axes.ts:105-112`, marked Phase 2, disabled, "coming soon" tooltip).
|
||||
|
||||
The fix is **not** "build a fourth scheme." The fix is to **finish the TimeHorizon contract** (add `past_14d`, `next_14d`, `past_all`, `next_all`), build **one reusable `<DateRangePicker>`** that emits a `TimeSpec`, then migrate the three legacy affordances to it.
|
||||
|
||||
**Layout (m's brief, locked):**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ [Zeitraum: Nächste 30 Tage ▾] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
↓ click to open
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Vergangenheit (ALLE) Zukunft │
|
||||
│ [Ganze Vergangenheit] [⌖ ALLE] [Ganze Zukunft] │
|
||||
│ [90 T] [30 T] [14 T] [7 T] [7 T] [14 T] [30 T] [90 T] │
|
||||
│ │
|
||||
│ ── oder benutzerdefiniert ── │
|
||||
│ Von [____.____.____] Bis [____.____.____] [Anwenden] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Slice plan:**
|
||||
|
||||
- **Slice A** — `<DateRangePicker>` component + 4 new horizon constants (`past_14d`, `next_14d`, `past_all`, `next_all`). Wired onto filter-bar `time` axis first (lights up Verlauf + InboxFilterBar + views simultaneously by replacing the stubbed Phase-2 chip).
|
||||
- **Slice B** — `/agenda` migrates (highest-traffic standalone consumer).
|
||||
- **Slice C** — `/admin/audit-log` + `/projects/:id/chart` migrate. Each surface picks the preset subset it cares about.
|
||||
- **Slice D** *(optional, later)* — upckommentar-style two-handle slicer replaces the inline date-pair for the "custom" mode.
|
||||
|
||||
**Hard rules honoured:**
|
||||
|
||||
- No new top-level table or migration in Slice A — purely additive enum values + Go switch arms.
|
||||
- No new dependency in Slice A — slicer is deferred (it's a non-trivial port from Svelte to paliad's plain TSX renderer).
|
||||
- Backward-compatible URL shape — each surface keeps its current short-alias parser (e.g. `?range=30` → `horizon=next_30d`) and additionally accepts the canonical `?horizon=…&from=…&to=…`.
|
||||
|
||||
---
|
||||
|
||||
## §1 Current state — every date-range affordance
|
||||
|
||||
Cataloguing **every** place a paliad user picks a past/future window, with file:line refs.
|
||||
|
||||
### 1.1 `/agenda` — future-only chip row
|
||||
|
||||
`frontend/src/agenda.tsx:64-67`:
|
||||
|
||||
```tsx
|
||||
<button className="agenda-chip" data-range="7" >7 Tage</button>
|
||||
<button className="agenda-chip" data-range="14" >14 Tage</button>
|
||||
<button className="agenda-chip" data-range="30" >30 Tage</button>
|
||||
<button className="agenda-chip" data-range="90" >90 Tage</button>
|
||||
```
|
||||
|
||||
State machine `frontend/src/client/agenda.ts:80-104`:
|
||||
|
||||
- `state.rangeDays ∈ {7,14,30,90}` (set `VALID_RANGES`). Default `30`.
|
||||
- URL: `?range=30&types=…&event_type=…`.
|
||||
- Fetch: `GET /api/agenda?from=<today>&to=<today+rangeDays-1>&types=…`.
|
||||
- **Future-only by construction** — m's complaint applies precisely here. No "past 7 days" affordance, no "all" affordance.
|
||||
|
||||
### 1.2 `/admin/audit-log` — past-only `<select>` + manual date pair
|
||||
|
||||
`frontend/src/admin-audit-log.tsx:50-65`:
|
||||
|
||||
```tsx
|
||||
<select id="audit-range">
|
||||
<option value="24h">Letzte 24h</option>
|
||||
<option value="7d" selected>Letzte 7 Tage</option>
|
||||
<option value="30d">Letzte 30 Tage</option>
|
||||
<option value="custom">Benutzerdefiniert</option>
|
||||
<option value="all">Alles</option>
|
||||
</select>
|
||||
<!-- custom toggles a date-pair: -->
|
||||
<input type="date" id="audit-from" />
|
||||
<input type="date" id="audit-to" />
|
||||
```
|
||||
|
||||
State machine `frontend/src/client/admin-audit-log.ts:135-174`:
|
||||
|
||||
- `rangePresetToFrom(preset)` converts `"24h" | "7d" | "30d"` → `Date`. `"custom"` reads `from`/`to` inputs. `"all"` clears both bounds.
|
||||
- URL: `?source=…&range=7d&q=…&from=…&to=…&limit=…&before_ts=…&before_id=…` (cursor-paged).
|
||||
- **Past-only by construction.** No future-projection — this is an audit log, looking forward makes no sense.
|
||||
|
||||
### 1.3 `/projects/:id/chart` — symmetric `RangePreset`
|
||||
|
||||
`frontend/src/client/views/types.ts:77-79`:
|
||||
|
||||
```ts
|
||||
range_preset?: "1y" | "2y" | "all" | "custom";
|
||||
range_from?: string;
|
||||
range_to?: string;
|
||||
```
|
||||
|
||||
UI `frontend/src/projects-chart.tsx:78-82`:
|
||||
|
||||
```tsx
|
||||
<input type="date" id="projects-chart-range-from" />
|
||||
<input type="date" id="projects-chart-range-to" />
|
||||
```
|
||||
|
||||
State machine `frontend/src/client/projects-chart.ts:73-118`:
|
||||
|
||||
- `rangeFromURL()` → `{preset, from?, to?}` with default `"1y"`.
|
||||
- "1y" = `today-1y..today+1y`, "2y" = `today-2y..today+2y`, "all" derived from loaded events, "custom" = read inputs.
|
||||
- URL: `?range=1y&from=YYYY-MM-DD&to=YYYY-MM-DD`.
|
||||
- **Symmetric around today** by construction — this is a chart, not a filter; the user is panning a viewport, not picking a fan.
|
||||
|
||||
### 1.4 `views-editor.tsx` (Custom Views config form)
|
||||
|
||||
`frontend/src/views-editor.tsx:102-109`:
|
||||
|
||||
```tsx
|
||||
<select id="editor-time-horizon">
|
||||
<option value="next_7d">Nächste 7 Tage</option>
|
||||
<option value="next_30d">Nächste 30 Tage</option>
|
||||
<option value="next_90d">Nächste 90 Tage</option>
|
||||
<option value="past_30d">Letzte 30 Tage</option>
|
||||
<option value="past_90d">Letzte 90 Tage</option>
|
||||
<option value="any">Beliebig</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
- Mixes past + future, but only 5 horizons exposed (no 14d, no past_7d, no all).
|
||||
- Persists into `paliad.user_views.filter_spec` (JSON column) as a `TimeSpec`.
|
||||
- **This is the closest existing affordance to m's symmetric fan**, but rendered as a plain `<select>` and incomplete.
|
||||
|
||||
### 1.5 Filter-bar `time` axis (riemann's t-paliad-163 Phase 1)
|
||||
|
||||
`frontend/src/client/filter-bar/axes.ts:65-115`:
|
||||
|
||||
- Renders a chip cluster: `[next_7d, next_30d, next_90d, past_30d, any]` (default presets, line 77-79).
|
||||
- **"Anpassen" chip is disabled** with `coming_soon` tooltip (line 108-112). This is the documented Phase 2 substrate.
|
||||
- Surfaces declaring axis `time` thread their own preset list via `RenderAxisOpts.timePresets` — e.g. Verlauf overrides to `["past_7d","past_30d","past_90d","any"]` (`frontend/src/client/projects-detail.ts:2310`).
|
||||
|
||||
Consumers:
|
||||
- `/projects/:id` Verlauf (`projects-detail.ts:2296` initial state, 2310 preset override).
|
||||
- `/views` and `/views/:id` (Custom Views runtime).
|
||||
- `/inbox` (`InboxFilterBar` flow — t-paliad-138/139 derived inbox).
|
||||
|
||||
### 1.6 `horizonBounds()` — the materializer
|
||||
|
||||
`frontend/src/client/projects-detail.ts:393-406` mirrors the Go-side `computeViewSpecBounds()` (`internal/services/view_service.go:156-187`):
|
||||
|
||||
```ts
|
||||
case "past_7d": return { from: offset(-7), to: offset(1) };
|
||||
case "past_30d": return { from: offset(-30), to: offset(1) };
|
||||
case "past_90d": return { from: offset(-90), to: offset(1) };
|
||||
case "next_7d": return { from: day, to: offset(7) };
|
||||
case "next_30d": return { from: day, to: offset(30) };
|
||||
case "next_90d": return { from: day, to: offset(90) };
|
||||
default: return {};
|
||||
```
|
||||
|
||||
(Backend equivalent: `internal/services/view_service.go:160-186`.)
|
||||
|
||||
### 1.7 Single-date inputs (NOT date-range — listed for completeness)
|
||||
|
||||
These are out of scope but mentioned so the audit is exhaustive:
|
||||
|
||||
- `verfahrensablauf.tsx:174` — `#trigger-date` (calculator anchor).
|
||||
- `fristenrechner.tsx:496,504,616` — `#trigger-date`, `#priority-date`, `#event-date` (calculator).
|
||||
- `admin-rules-edit.tsx:265` — `#preview-trigger-date`.
|
||||
- `deadlines-detail.tsx:82` — `#deadline-due-edit` (inline-edit).
|
||||
- `deadlines-new.tsx:116` — `#deadline-due` (form).
|
||||
- `appointments-new.tsx`, `appointments-detail.tsx` — `start_at`/`end_at`.
|
||||
- `projects-detail.tsx:181` — `#smart-timeline-milestone-date` (add-milestone modal).
|
||||
- `components/ProjectFormFields.tsx:134,138` — `#project-filing-date`, `#project-grant-date`.
|
||||
|
||||
### 1.8 Summary matrix
|
||||
|
||||
| Surface | Direction | Presets | Custom | URL contract | Default |
|
||||
|---|---|---|---|---|---|
|
||||
| `/agenda` | Future | 7\|14\|30\|90 | — | `?range=N` | 30d |
|
||||
| `/admin/audit-log` | Past | 24h\|7d\|30d\|all + custom | date pair | `?range=…&from=…&to=…` | 7d |
|
||||
| `/projects/:id/chart` | Symmetric ±N | 1y\|2y\|all + custom | date pair | `?range=…&from=…&to=…` | 1y |
|
||||
| `/views/:id` editor | Past+Future mix | next_7d\|next_30d\|next_90d\|past_30d\|past_90d\|any | — | persisted JSON | next_30d |
|
||||
| Filter-bar `time` axis | Past+Future mix | next_7d\|next_30d\|next_90d\|past_30d\|any | **stubbed** | persisted + `?…__time_from=` | per surface |
|
||||
| Verlauf | Past + any | past_7d\|past_30d\|past_90d\|any | **stubbed** | URL | past_30d |
|
||||
| InboxFilterBar | Mix | filter-bar default | **stubbed** | URL | per surface |
|
||||
|
||||
Three of seven surfaces have **incomplete** custom-range affordances. None of the seven exposes the full symmetric fan m wants.
|
||||
|
||||
---
|
||||
|
||||
## §2 upckommentar slicer pattern
|
||||
|
||||
Verified by reading source at `/home/m/dev/web/upc-kommentar/src/lib/`:
|
||||
|
||||
- **`DateRangeSlider.svelte`** (component, 448 lines).
|
||||
- **`date-range-slider-pure.ts`** (pure-math helpers, 487 lines, fully unit-tested).
|
||||
- **`InboxFilterBar.svelte`** (host).
|
||||
|
||||
### 2.1 What it is
|
||||
|
||||
A **two-handle range slider** that wraps `svelte-range-slider-pips` (npm: `svelte-range-slider-pips@4`). The slider's rail is the upckommentar floor (`2023-01-01`) to today, and the two handles define `dateFrom` and `dateTo`. Step is **1 day** regardless of zoom.
|
||||
|
||||
Public contract (DateRangeSlider.svelte:57-82):
|
||||
|
||||
```ts
|
||||
interface Props {
|
||||
minISO: string; // axis lower bound, default 2023-01-01
|
||||
maxISO: string; // axis upper bound, today
|
||||
fromISO: string | null; // current From (null = parked at min)
|
||||
toISO: string | null; // current To (null = parked at max)
|
||||
onChange: (from, to) => void; // emits on every slider change
|
||||
testid?: string;
|
||||
axisWidthPx?: number; // test override for jsdom
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Anchor rail + granularity
|
||||
|
||||
Below the slider rail is a **custom-rendered anchor rail** (the lib's own pips are hidden via `pips={false}` because they're evenly-spaced approximations — issue #42 in upckommentar). Anchor day-numbers come from `pipAnchorsFor(granularity, minDay, maxDay)`:
|
||||
|
||||
- **year:** every Jan 1 in range.
|
||||
- **month:** every 1st-of-month.
|
||||
- **day:** every Monday.
|
||||
|
||||
Edges (`minDay`, `maxDay`) are always anchors so the user can park at the slider's extremes.
|
||||
|
||||
Granularity has **+/- zoom buttons** in the top-right of the slider (`year → month → day`), with each level showing more anchors.
|
||||
|
||||
### 2.3 Click-to-snap (left half / right half)
|
||||
|
||||
`DateRangeSlider.svelte:219-240` + pure helper `endOfPeriodDay()`:
|
||||
|
||||
- **Left half of an anchor label** → snap closest handle to **start** of period (the anchor day itself, e.g. Jan 1).
|
||||
- **Right half of the same label** → snap to **end** of period (Dec 31 for year, last-of-month for month, Sunday for day).
|
||||
- Keyboard activation falls back to left-half (start-of-period) deterministically.
|
||||
|
||||
### 2.4 Label thinning + two-row alternation
|
||||
|
||||
`pipLabelStrideFor()` + `pipLabelRow()` (pure helpers):
|
||||
|
||||
- Measures rail width via `ResizeObserver`.
|
||||
- Computes a stride — only every Nth label is rendered.
|
||||
- Adjacent rendered labels alternate row 0 / row 1 (~1.1em offset down) so they can sit closer horizontally without colliding.
|
||||
|
||||
### 2.5 Handle behaviour
|
||||
|
||||
- `range=true` draws a colored bar between handles.
|
||||
- `draggy=true` lets the user drag the **bar itself** to shift the window without changing its width.
|
||||
- `pushy=true` — handles push each other when crossed.
|
||||
- `float=true` — tooltip floats above the dragged handle showing `DD.MM.YYYY`.
|
||||
|
||||
### 2.6 URL contract on host
|
||||
|
||||
`InboxFilterBar.svelte` debounces `onChange` at 250ms, then writes:
|
||||
|
||||
```
|
||||
?date_from=2024-03-15&date_to=2024-09-30
|
||||
```
|
||||
|
||||
When a handle is parked at min/max, that bound is **omitted** from the URL (`valuesToFromTo()` in the pure module). So `?date_from=2024-03-15` alone means "from March 15 onwards, no upper bound."
|
||||
|
||||
### 2.7 What's worth borrowing for paliad
|
||||
|
||||
| Element | Borrow? | Why |
|
||||
|---|---|---|
|
||||
| Two-handle drag | **Yes — but defer to Slice D** | Excellent fine-tune UX. Non-trivial to port without `svelte-range-slider-pips` (or a Svelte ↔ TSX adapter). |
|
||||
| Anchor rail with click-to-snap | Yes (in Slice D) | Year/month/Monday anchors are the right granularities. |
|
||||
| Label thinning + two-row alternation | Yes (in Slice D) | Makes the rail readable at any width. |
|
||||
| Granularity + zoom +/- | Yes (in Slice D) | Single most useful interaction; users don't drag pixel-precise. |
|
||||
| Epoch-day pure math | Yes — verbatim | The `date-range-slider-pure.ts` module is well-tested and dependency-free. Port to TS in paliad's pure-helper layer. |
|
||||
| `null` = parked at edge | Yes — already aligned | TimeHorizon's `past_all` / `next_all` map cleanly to "one bound parked at infinity." |
|
||||
| The library `svelte-range-slider-pips` itself | **No** | Adds a Svelte dependency to a non-Svelte project. Slice D would build a tiny equivalent on top of `<input type="range">` × 2 + CSS — or vendor the lib's pure parts. |
|
||||
|
||||
### 2.8 What does NOT apply to paliad
|
||||
|
||||
- **Floor at 2023-01-01.** upckommentar starts at the UPC's first day. paliad has decade-old patents and future-projecting deadlines; the axis must extend in both directions. We use `today ± 5 years` as the default visible range with `past_all` / `next_all` chips to escape it.
|
||||
- **Single granularity locked per session.** upckommentar's UI shows one of year/month/day at a time. paliad's typical use ("next 30 days for the deadline list") doesn't benefit from a zoom; the chips ARE the granularity. Slicer in Slice D only opens when the user picks "Anpassen" — at which point the zoom UI makes sense.
|
||||
|
||||
---
|
||||
|
||||
## §3 Component design — `<DateRangePicker>`
|
||||
|
||||
### 3.1 Public API
|
||||
|
||||
```ts
|
||||
type TimeHorizonExt =
|
||||
| "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "custom";
|
||||
|
||||
interface DateRangePickerProps {
|
||||
// Current state. The component is fully controlled.
|
||||
value: TimeSpec;
|
||||
onChange: (next: TimeSpec) => void;
|
||||
|
||||
// Per-surface preset filter — omit a chip by leaving it out of the array.
|
||||
// Default: all symmetric chips + "any" + "custom".
|
||||
presets?: TimeHorizonExt[];
|
||||
|
||||
// Closed-state button label override. Defaults to the i18n key for value.horizon
|
||||
// (e.g. "Letzte 30 Tage"). Override for surfaces that want a heading prefix
|
||||
// like "Zeitraum: Letzte 30 Tage".
|
||||
labelPrefix?: string;
|
||||
|
||||
// i18n strings consumed via the i18n.ts dictionary. No props for individual labels.
|
||||
// Localisation flows through existing data-i18n attributes.
|
||||
|
||||
// Surface tag — used to derive a stable testid and URL-param namespace if
|
||||
// the host wires URL serialization through helpers we provide (see §4).
|
||||
surface: string; // e.g. "agenda" | "audit-log" | "filter-bar"
|
||||
|
||||
// Mode — popover (default) or modal (rare).
|
||||
mode?: "popover" | "modal";
|
||||
|
||||
// Anchor / placement for popover mode. Defaults to "below".
|
||||
placement?: "below" | "above" | "right";
|
||||
}
|
||||
```
|
||||
|
||||
`TimeSpec` mirrors the existing shape (`internal/services/filter_spec.go:107-112`), extended with the 4 new horizon values:
|
||||
|
||||
```ts
|
||||
interface TimeSpec {
|
||||
horizon: TimeHorizonExt;
|
||||
field?: "auto" | "created_at";
|
||||
from?: string; // ISO YYYY-MM-DD; set only when horizon === "custom"
|
||||
to?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 States
|
||||
|
||||
The component is a small state machine:
|
||||
|
||||
```
|
||||
closed ────[click button]────► open
|
||||
▲ │
|
||||
└──[click outside / Esc]───────┘
|
||||
│
|
||||
open ───[click chip]──── closed (commit immediately)
|
||||
│
|
||||
open ───[click "Anpassen"]► custom-editor
|
||||
│
|
||||
custom-editor ─[Anwenden]► closed (commit)
|
||||
custom-editor ─[Esc]─────► open
|
||||
```
|
||||
|
||||
- **closed** — single button with current selection label and a chevron `▾`. No outline/highlight unless the value is not the default for this surface.
|
||||
- **open** — popover anchored below the button (or below-then-flip-up on viewport-bottom). Contains the symmetric chip row + ALL center + "Anpassen" sub-section.
|
||||
- **custom-editor** — replaces the "Anpassen" link with two `<input type="date">` + "Anwenden" / "Abbrechen" buttons. (In Slice D this becomes the slicer.)
|
||||
|
||||
### 3.3 Symmetric chip layout
|
||||
|
||||
The popover body — full ASCII sketch:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ╭ Vergangenheit ────────╮ ╭ ALLES ╮ ╭ Zukunft ───────────╮ │
|
||||
│ │ [Ganze Vergangenheit] │ │ [⌖] │ │ [Ganze Zukunft] │ │
|
||||
│ │ [Letzte 90 Tage] │ │ │ │ [Nächste 7 Tage] │ │
|
||||
│ │ [Letzte 30 Tage] │ │ │ │ [Nächste 14 Tage] │ │
|
||||
│ │ [Letzte 14 Tage] │ │ │ │ [Nächste 30 Tage] │ │
|
||||
│ │ [Letzte 7 Tage] │ │ │ │ [Nächste 90 Tage] │ │
|
||||
│ ╰───────────────────────╯ ╰───────╯ ╰────────────────────╯ │
|
||||
│ │
|
||||
│ ── Anpassen ────────────────────────────────────────── │
|
||||
│ Von [____.____.____] Bis [____.____.____] [Anwenden] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Visual cues:
|
||||
|
||||
- The currently-selected chip gets the **lime accent** (`--color-bg-lime-tint` background, `--color-text` text, `--color-accent` border) — matches existing `.agenda-chip-active` so we don't introduce a new active state.
|
||||
- The "ALLES" center button is **larger** than the fan chips (44px tall vs. 32px), drawn with a target-style glyph `⌖` (or `∞` — see Q3.B). Inventor pick: `⌖` plus the word "ALLES" beneath. Larger so it reads as "the no-filter affordance," not as one chip among many.
|
||||
- The two fans are visually **mirrored** — past on the left, future on the right. Both have a "Ganze …" terminal chip at the outer edge (left-most for past_all, right-most for next_all) and decreasing-magnitude chips fanning toward the center. The ordering matches the human intuition: "left = back in time, right = forward in time."
|
||||
- On viewports < 480px the popover stacks vertically (past fan above, ALL middle, future fan below). On viewports < 360px the popover becomes a modal-feeling slide-up sheet (existing inbox modal CSS pattern reusable).
|
||||
|
||||
### 3.4 Sketch of the closed button states
|
||||
|
||||
```
|
||||
default: ┌─Zeitraum: Nächste 30 Tage ▾─┐
|
||||
custom: ┌─Zeitraum: 15.03.2026 – 30.04.2026 ▾─┐
|
||||
any: ┌─Zeitraum: Alles ▾─┐
|
||||
past_all: ┌─Zeitraum: Ganze Vergangenheit ▾─┐
|
||||
hover/open: same + outline + bg-accent-tint
|
||||
```
|
||||
|
||||
When the value is **not** the surface default, an additional small `●` dot appears between "Zeitraum:" and the value — the existing universal "filter is non-default" indicator used by the filter-bar.
|
||||
|
||||
### 3.5 Keyboard
|
||||
|
||||
- `Tab` lands on the button. `Enter`/`Space` opens the popover.
|
||||
- `Esc` from open state closes it. `Esc` from custom-editor returns to chip view (one level back).
|
||||
- Chips are focusable buttons in the natural left-to-right reading order: past_all → past_90 → past_30 → past_14 → past_7 → any (center) → next_7 → next_14 → next_30 → next_90 → next_all.
|
||||
- The custom date inputs are `<input type="date" lang="de">` — gets the OS-native picker on macOS / iOS / Android / Windows. No new custom calendar widget.
|
||||
|
||||
### 3.6 Accessibility
|
||||
|
||||
- The button has `aria-haspopup="dialog"` and `aria-expanded` toggled on open/close.
|
||||
- The popover has `role="dialog"` with `aria-label` = `t("date_range.dialog.label")` ("Zeitraum wählen" / "Choose date range").
|
||||
- Chips are `<button>` with `aria-pressed="true"` on the active one.
|
||||
- The two fan groups have `role="group"` + `aria-label="Vergangenheit"` / `aria-label="Zukunft"`.
|
||||
|
||||
### 3.7 Module layout
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── components/
|
||||
│ └── DateRangePicker.tsx ← TSX shell (markup only)
|
||||
├── client/
|
||||
│ ├── date-range-picker.ts ← mount() + state machine + DOM event wiring
|
||||
│ └── date-range-picker-pure.ts ← horizon-bounds math, label resolver, parse/serialize
|
||||
└── styles/
|
||||
└── global.css ← .date-range-* classes
|
||||
```
|
||||
|
||||
`-pure.ts` is the headless module — fully testable under `bun test`. The boot client in `-picker.ts` consumes it, mirroring the pattern used by `shape-timeline-chart.ts` + `shape-timeline-chart.test.ts` (see memory: t-paliad-173 / gauss).
|
||||
|
||||
Pure module exports (preliminary):
|
||||
|
||||
```ts
|
||||
export function horizonBounds(h: TimeHorizonExt, now: Date): { from?: Date; to?: Date }
|
||||
export function labelForHorizon(h: TimeHorizonExt, lang: "de"|"en"): string
|
||||
export function labelForCustom(from: string, to: string, lang: "de"|"en"): string
|
||||
export function parseURL(params: URLSearchParams): TimeSpec
|
||||
export function serializeURL(spec: TimeSpec, defaults: Partial<TimeSpec>): URLSearchParams
|
||||
export function isDefault(spec: TimeSpec, default_: TimeSpec): boolean
|
||||
```
|
||||
|
||||
### 3.8 Go-side additions
|
||||
|
||||
`internal/services/filter_spec.go`:
|
||||
|
||||
```go
|
||||
// Add four new constants alongside the existing TimeHorizon block.
|
||||
HorizonNext14d TimeHorizon = "next_14d"
|
||||
HorizonPast14d TimeHorizon = "past_14d"
|
||||
HorizonNextAll TimeHorizon = "next_all"
|
||||
HorizonPastAll TimeHorizon = "past_all"
|
||||
```
|
||||
|
||||
`internal/services/view_service.go:computeViewSpecBounds()`:
|
||||
|
||||
```go
|
||||
case HorizonNext14d:
|
||||
bounds.from = &startOfDay; t := startOfDay.AddDate(0, 0, 14); bounds.to = &t
|
||||
case HorizonPast14d:
|
||||
f := startOfDay.AddDate(0, 0, -14); bounds.from = &f; bounds.to = &startOfTomorrow
|
||||
case HorizonNextAll:
|
||||
bounds.from = &startOfDay
|
||||
// bounds.to left nil → "no upper bound"
|
||||
case HorizonPastAll:
|
||||
bounds.to = &startOfTomorrow
|
||||
// bounds.from left nil
|
||||
```
|
||||
|
||||
`HorizonNextAll` and `HorizonPastAll` are **one-sided unbounded** — distinct from existing `HorizonAll` (bidirectional unbounded) and `HorizonAny` (no filter at all, same effect as `HorizonAll` for view-spec runtime but different in intent).
|
||||
|
||||
`filter_spec.go:validate()` (line 280-292) gains the two new past/next constants in the switch.
|
||||
|
||||
### 3.9 i18n keys
|
||||
|
||||
Two-language matrix (DE primary, EN secondary):
|
||||
|
||||
```
|
||||
date_range.button.label "Zeitraum" / "Time range"
|
||||
date_range.button.label.custom "Von … bis …" / "From … to …"
|
||||
date_range.horizon.next_7d "Nächste 7 Tage" / "Next 7 days"
|
||||
date_range.horizon.next_14d "Nächste 14 Tage" / "Next 14 days"
|
||||
date_range.horizon.next_30d "Nächste 30 Tage" / "Next 30 days"
|
||||
date_range.horizon.next_90d "Nächste 90 Tage" / "Next 90 days"
|
||||
date_range.horizon.next_all "Ganze Zukunft" / "All future"
|
||||
date_range.horizon.past_7d "Letzte 7 Tage" / "Last 7 days"
|
||||
date_range.horizon.past_14d "Letzte 14 Tage" / "Last 14 days"
|
||||
date_range.horizon.past_30d "Letzte 30 Tage" / "Last 30 days"
|
||||
date_range.horizon.past_90d "Letzte 90 Tage" / "Last 90 days"
|
||||
date_range.horizon.past_all "Ganze Vergangenheit" / "All past"
|
||||
date_range.horizon.any "Alles" / "All"
|
||||
date_range.horizon.custom "Benutzerdefiniert" / "Custom"
|
||||
date_range.dialog.label "Zeitraum wählen" / "Choose date range"
|
||||
date_range.fan.past.label "Vergangenheit" / "Past"
|
||||
date_range.fan.future.label "Zukunft" / "Future"
|
||||
date_range.center.label "Alles" / "All"
|
||||
date_range.custom.from "Von" / "From"
|
||||
date_range.custom.to "Bis" / "To"
|
||||
date_range.custom.apply "Anwenden" / "Apply"
|
||||
date_range.custom.cancel "Abbrechen" / "Cancel"
|
||||
date_range.custom.invalid "Bis-Datum muss nach Von-Datum liegen." / "End date must be after start date."
|
||||
```
|
||||
|
||||
Total: 21 keys × 2 langs = 42 new entries in `i18n.ts`. Existing per-surface keys (`agenda.range.7`, `admin.audit.range.24h`, `views.bar.time.next_30d` etc.) stay until each surface migrates, then get retired.
|
||||
|
||||
---
|
||||
|
||||
## §4 URL / form serialization contract
|
||||
|
||||
### 4.1 Canonical URL shape
|
||||
|
||||
The picker writes (and reads) **canonical** params on the host's URL:
|
||||
|
||||
```
|
||||
?horizon=next_30d
|
||||
?horizon=past_all
|
||||
?horizon=any ← omitted if it matches the surface default
|
||||
?horizon=custom&from=2026-03-15&to=2026-04-30
|
||||
```
|
||||
|
||||
The host page's URL-init code (`bootDateRangePicker(surface, opts)`) calls `parseURL(searchParams)` to derive the initial `TimeSpec`, then calls `serializeURL(spec, defaults)` on every change. Params equal to the surface default are **omitted** so the canonical URL stays short and dedupable — matches the existing `writeParamToURL` pattern in `projects-chart.ts:144-154`.
|
||||
|
||||
### 4.2 Backwards-compat aliases
|
||||
|
||||
Each migrating surface keeps its existing alias parser for the transition window:
|
||||
|
||||
| Surface | Legacy URL | Canonical URL | Adapter |
|
||||
|---|---|---|---|
|
||||
| `/agenda` | `?range=30` | `?horizon=next_30d` | `range=N → horizon=next_${N}d` if `N ∈ {7,14,30,90}`, else `next_all` for `N>90`. Read both, write canonical. |
|
||||
| `/admin/audit-log` | `?range=7d` | `?horizon=past_7d` | `range=24h → horizon=past_1d` (new, see Q5) or kept as `past_7d` fallback. `range=all → horizon=any`. |
|
||||
| `/projects/:id/chart` | `?range=1y` | `?range=1y` (kept) | **NOT migrated to TimeHorizon** — projects-chart is symmetric-around-today. It uses DateRangePicker only for its **custom**-mode UI (the date-pair → slicer in Slice D). The 1y/2y/all presets stay surface-specific. |
|
||||
|
||||
The Go side is unaffected by aliasing — handlers receive whatever shape they always have, and the URL alias adapter lives entirely client-side per surface. **No backend route signature changes** in Slice A.
|
||||
|
||||
### 4.3 Custom Views (persisted JSON)
|
||||
|
||||
`paliad.user_views.filter_spec` is a JSON column. The TimeSpec extension is additive (new enum values, no shape change). Existing rows continue to validate. Migration not needed.
|
||||
|
||||
### 4.4 Form fields (Custom Views editor)
|
||||
|
||||
`views-editor.tsx:102-109` migrates from `<select>` to the picker. The form submits the same FormData shape (just one extra key for custom from/to — already plumbed via TimeSpec.from / TimeSpec.to). The Go-side `parseViewForm()` (TBD by coder) gains 4 new acceptable horizon values; existing test cases continue to pass.
|
||||
|
||||
---
|
||||
|
||||
## §5 Migration plan
|
||||
|
||||
### Slice A — substrate + filter-bar `time` axis
|
||||
|
||||
**Backend** (single migration not needed — additive constants only):
|
||||
|
||||
- `internal/services/filter_spec.go` — 4 new `TimeHorizon` constants + validate switch arms.
|
||||
- `internal/services/view_service.go` — `computeViewSpecBounds()` 4 new switch cases.
|
||||
- Pure unit tests for each new horizon (zero DB).
|
||||
|
||||
**Frontend**:
|
||||
|
||||
- New `frontend/src/components/DateRangePicker.tsx` + boot client + pure module.
|
||||
- New i18n keys (42 entries).
|
||||
- `frontend/src/client/filter-bar/axes.ts:renderTimeAxis()` — replace the disabled "Anpassen" stub with the picker. The chip cluster either becomes the picker's open-state (preferred) OR the chips stay flat and the picker only opens on "Anpassen" click (fallback if popover-in-bar is visually noisy). **Inventor pick (R): chips stay flat in the bar; "Anpassen" chip becomes the picker trigger. Picker emits TimeSpec back into the bar's state, same patch path.**
|
||||
|
||||
**Surfaces lit up automatically**: Verlauf (`/projects/:id`), Custom Views (`/views`, `/views/:id`), InboxFilterBar (`/inbox`).
|
||||
|
||||
**LoC estimate**: ~600 LoC (pure: 180 / boot: 180 / TSX: 100 / CSS: 80 / Go: 30 / tests: 240). Tests-first per `docs/design-paliad-test-strategy-2026-05-19.md`.
|
||||
|
||||
### Slice B — `/agenda`
|
||||
|
||||
- `agenda.tsx:51-69` — replace chip rows with `<DateRangePicker surface="agenda" presets={["next_7d","next_14d","next_30d","next_90d","next_all","custom"]} />`.
|
||||
- `client/agenda.ts:85-104` — replace `wireControls()` chip wiring with picker subscription.
|
||||
- URL alias adapter — accept `?range=N` for back-compat, emit `?horizon=…`.
|
||||
|
||||
**LoC**: ~80 LoC delta, mostly deletion.
|
||||
|
||||
### Slice C — `/admin/audit-log` + `/projects/:id/chart`
|
||||
|
||||
- `admin-audit-log.tsx:50-65` — replace `<select>` + date-pair with `<DateRangePicker surface="audit-log" presets={["past_7d","past_14d","past_30d","past_90d","past_all","custom"]} />`.
|
||||
- `projects-chart.tsx:75-83` — **wrap** the existing 1y/2y/all presets in a custom-prop variant (a sibling component `<SymmetricRangePicker>` that shares the picker's popover scaffolding but emits the surface-specific `range_preset`). Or — if the head/m prefers — fold 1y/2y/all into TimeHorizon as `sym_1y` / `sym_2y` / `sym_all`. **Inventor pick (R): sibling component**, because symmetric-around-today is conceptually different from past/future fan. See §8 Q1.
|
||||
|
||||
**LoC**: ~120 LoC for audit-log, ~80 LoC for projects-chart wrap.
|
||||
|
||||
### Slice D *(optional, separate task)* — slicer
|
||||
|
||||
- Add `<DateRangeSlicer>` for the custom-editor sub-pane. Built on `<input type="range">` × 2 with a custom anchor rail above, ported from `date-range-slider-pure.ts`.
|
||||
- Replaces inline date-pair when `horizon === "custom"` and `surface ∈ {agenda, audit-log, filter-bar}`. Projects-chart keeps inline date-pair OR also uses slicer — its choice.
|
||||
- No new dependency.
|
||||
- ~400 LoC including pure helpers + DOM scaffolding + tests.
|
||||
|
||||
### Per-slice rollout
|
||||
|
||||
| Slice | Risk | Surfaces affected | Coder profile |
|
||||
|---|---|---|---|
|
||||
| A | Low — additive only | 4 (filter-bar + 3 consumers) | Pattern-fluent Sonnet |
|
||||
| B | Low | 1 | Same coder |
|
||||
| C | Medium (projects-chart sibling) | 2 | Same coder |
|
||||
| D | Medium (new slicer) | 0 (additive on top of A) | Separate task |
|
||||
|
||||
---
|
||||
|
||||
## §6 Visual decisions
|
||||
|
||||
### 6.1 Chip labels
|
||||
|
||||
Final labels — bilingual (DE first):
|
||||
|
||||
| Chip | DE | EN |
|
||||
|---|---|---|
|
||||
| past_all | Ganze Vergangenheit | All past |
|
||||
| past_90d | Letzte 90 Tage | Last 90 days |
|
||||
| past_30d | Letzte 30 Tage | Last 30 days |
|
||||
| past_14d | Letzte 14 Tage | Last 14 days |
|
||||
| past_7d | Letzte 7 Tage | Last 7 days |
|
||||
| any (center) | Alles | All |
|
||||
| next_7d | Nächste 7 Tage | Next 7 days |
|
||||
| next_14d | Nächste 14 Tage | Next 14 days |
|
||||
| next_30d | Nächste 30 Tage | Next 30 days |
|
||||
| next_90d | Nächste 90 Tage | Next 90 days |
|
||||
| next_all | Ganze Zukunft | All future |
|
||||
| custom | Anpassen | Customize |
|
||||
|
||||
Rationale on "Anpassen" vs "Benutzerdefiniert":
|
||||
- "Anpassen" matches existing `views.bar.time.custom` key value in `i18n.ts`.
|
||||
- "Benutzerdefiniert" is used in admin-audit-log's dropdown — verbose, but more accurate.
|
||||
- (R): **Anpassen** (consistent with filter-bar; six chars vs. eighteen).
|
||||
|
||||
### 6.2 Accent / active state
|
||||
|
||||
Reuse the existing **lime accent** chip-active state (`--color-bg-lime-tint` background, `--color-accent` border, `--color-text` text). This is the established affordance for the `agenda-chip-active` class — same visual reused, no new accent token.
|
||||
|
||||
### 6.3 The "ALLES" center button
|
||||
|
||||
A larger, target-glyph button — visually distinct from the fan chips so the user reads it as the "no time filter" exit, not as one chip among many:
|
||||
|
||||
```
|
||||
╭──────╮
|
||||
│ ⌖ │
|
||||
│ ALLES│
|
||||
╰──────╯
|
||||
```
|
||||
|
||||
(R) glyph: `⌖` (Unicode U+2316 POSITION INDICATOR). Alternatives considered: `∞` (too math-y), `⊕` (too connect-y), `▣` (too checkbox-y), no glyph (chip then looks like every other chip). See §8 Q3.B.
|
||||
|
||||
### 6.4 Custom-range entry
|
||||
|
||||
In Slice A: **inline date-pair below the chip rows**, with an "Anwenden" button that commits + closes the picker. Plain `<input type="date" lang="de">` — gets the OS-native picker.
|
||||
|
||||
In Slice D (later): same slot becomes the slicer. The chip rows remain; the slicer collapses under them so the user can switch back to a chip with one click.
|
||||
|
||||
### 6.5 Hover / focus
|
||||
|
||||
- Chip hover: existing `.agenda-chip:hover` (lighter background tint).
|
||||
- Chip focus-visible: 2px outline using `--color-accent`.
|
||||
- Button focus-visible: same.
|
||||
- Popover entry: 120ms fade-in via `transform: translateY(-4px) → 0` + opacity. Reduced-motion users (prefers-reduced-motion: reduce) get instant show.
|
||||
|
||||
### 6.6 Indication that the filter is non-default
|
||||
|
||||
The closed button shows a small `●` dot to the left of the label when the value is **not** the surface default. This matches the existing filter-bar non-default-indicator pattern (`frontend/src/client/filter-bar/index.ts` has a similar dot but on the whole bar; we adopt it per-control).
|
||||
|
||||
---
|
||||
|
||||
## §7 Edge cases
|
||||
|
||||
### 7.1 Timezones
|
||||
|
||||
All horizon math runs against **UTC `startOfDay`** of `new Date()` — same convention as `horizonBounds()` in `projects-detail.ts:393-406`. The user's browser may be in CEST in summer or CET in winter; the picker still treats "today" as a UTC date for filter purposes. The date-input localizes display (German locale → DD.MM.YYYY) but the underlying ISO is `YYYY-MM-DD` parsed as UTC midnight.
|
||||
|
||||
Practical impact: a user in CEST clicking "Letzte 7 Tage" at 01:30 local on 2026-06-15 sees `from=2026-06-07T00:00Z, to=2026-06-15T00:00Z` even though their local clock shows the 15th. This matches every other date-filter in paliad and avoids "the same row vanishes at 01:00 vs. 23:00" surprises. Document the convention in the pure module's header comment.
|
||||
|
||||
### 7.2 Far past truncation
|
||||
|
||||
`past_all` materialises to `from: nil`. The Go side (view_service.go) treats nil as "no lower bound" — the SQL `WHERE due_date >= ?` clause is omitted. No truncation needed.
|
||||
|
||||
For projects-chart's symmetric "all" mode, "all" still means **bounds derived from loaded events** (status quo) — the picker for projects-chart's surface uses the sibling `<SymmetricRangePicker>` which doesn't have `past_all`/`next_all` chips, only `1y/2y/all`.
|
||||
|
||||
### 7.3 Overlapping selections — past_7 + next_7 simultaneously?
|
||||
|
||||
The picker is **single-select** — one chip active at a time, OR custom mode. m's brief doesn't mention multi-select and the existing TimeSpec is single-valued. Multi-select would require a fundamental contract change. Don't.
|
||||
|
||||
If a user genuinely wants "last 7 days OR next 7 days," they use the custom-range with `from=today-7d`, `to=today+7d` — which is what `±1w` would mean. The fact that this is two chip-clicks vs. one isn't a real ergonomic loss.
|
||||
|
||||
### 7.4 Custom dates with from > to
|
||||
|
||||
Validate client-side: when both inputs are filled and `from > to`, the "Anwenden" button is disabled and a hint appears: "Bis-Datum muss nach Von-Datum liegen" (i18n key `date_range.custom.invalid`). The picker does **not** auto-swap.
|
||||
|
||||
### 7.5 Empty inputs in custom mode
|
||||
|
||||
If the user clicks "Anpassen" then clicks elsewhere before filling inputs, the picker reverts to whatever horizon was active before (state cached on entry to custom-editor). No "half-custom" state persists.
|
||||
|
||||
### 7.6 Surface-specific preset overrides
|
||||
|
||||
Each surface declares its own presets via the `presets` prop. The picker hides chips not in the array. The default surface preset (read from `defaults` prop, or hardcoded if absent) is what `serializeURL()` omits from the URL.
|
||||
|
||||
Important invariant: `defaults` must be a member of `presets`, OR be a special value like `any` that's always rendered. The component asserts this at boot and falls back to `any` if violated.
|
||||
|
||||
### 7.7 Bilingual labels mid-session
|
||||
|
||||
`labelForHorizon()` consults the live `i18n.ts` dictionary on every render, so a language toggle updates the picker immediately — including the closed-button label.
|
||||
|
||||
### 7.8 Embedded picker inside a filter bar
|
||||
|
||||
When the picker is mounted inside `filter-bar`, it should NOT use a full popover overlay — the filter bar already wraps controls. Instead the open-state's chip rows render **inline below the time chip cluster**, expanding the bar's height. This is `mode="inline"` (a third mode beyond popover/modal). Slice A picks this for filter-bar consumers; standalone surfaces (`/agenda`, `/admin/audit-log`) use popover mode.
|
||||
|
||||
### 7.9 What happens if a saved Custom View references `past_14d` before Slice A ships?
|
||||
|
||||
The JSON validator rejects it (`filter_spec.go:validate()` enum check). Saved views are migration-safe in one direction only — adding new enum values is fine; removing is not. Slice A adds, doesn't remove. No issue.
|
||||
|
||||
### 7.10 Race: URL change while picker is open
|
||||
|
||||
If the user has the picker open and a URL change happens via another control (e.g. they Cmd-Click a sidebar link), the picker is unmounted naturally with the page navigation. No state to preserve across navigations.
|
||||
|
||||
---
|
||||
|
||||
## §8 Open questions for m
|
||||
|
||||
Per task brief: **no AskUserQuestion**. Material picks escalated via `mai instruct head`; everything else defaults to (R) below. The head decides whether to forward to m or rule on the spot.
|
||||
|
||||
### Q1 [MATERIAL — escalate]: How to handle `/projects/:id/chart`?
|
||||
|
||||
The chart's range presets are **symmetric around today** (1y / 2y / all = ±1y / ±2y / all-data-bounds), conceptually different from past/future fans. Options:
|
||||
|
||||
- **(R) A — sibling component.** Keep a separate `<SymmetricRangePicker>` for the chart surface. Same popover scaffolding, different chip set. Chart's URL stays `?range=1y`. Doesn't add to TimeHorizon.
|
||||
- **B — fold into TimeHorizon.** Add `sym_1y`, `sym_2y`, `sym_all` constants. Picker prop selects which fan vs. symmetric. Saved views could then express "±1y" too.
|
||||
- **C — leave the chart as-is.** Don't migrate. Accept the visual inconsistency.
|
||||
|
||||
(R) **A.** Symmetric vs fan is a real semantic difference; one component trying to be both is muddier than two components sharing scaffolding. The chart isn't a "filter" — it's a viewport, and viewports legitimately want symmetric panning.
|
||||
|
||||
### Q2 [MATERIAL — escalate]: Modal vs popover for the standalone case?
|
||||
|
||||
m's brief says "mini modal." Options:
|
||||
|
||||
- **(R) A — popover always.** Anchored to the trigger button, click-outside dismiss. In-context, lightweight.
|
||||
- **B — modal for explicit "open date filter" intent.** Use a centered modal with scrim when the picker is the page's primary filter (e.g. `/admin/audit-log` where date is the most prominent control). Popover for embedded uses.
|
||||
- **C — modal everywhere.** Strong visual hierarchy, but interrupts the user.
|
||||
|
||||
(R) **A.** Modal feels heavy for what is conceptually a chip cluster. The "mini" qualifier in m's wording suggests popover, not full modal. If a surface specifically needs the modal weight, the `mode="modal"` prop is available — but no default surface picks it.
|
||||
|
||||
### Q3 [MATERIAL — escalate]: Slice priority — what migrates first?
|
||||
|
||||
- **(R) A — filter-bar `time` axis first** (Slice A). Lights up 4 surfaces simultaneously (Verlauf, InboxFilterBar, views runtime, Custom Views editor) by replacing the existing Phase-2 disabled stub.
|
||||
- **B — `/agenda` first** (per task brief default). Highest-traffic standalone surface, simplest migration.
|
||||
- **C — both A and B in parallel** (head splits between two coders).
|
||||
|
||||
(R) **A.** Filter-bar is the substrate everything else either uses or should use. Lighting it up first turns three downstream surfaces from "almost working" (the stubbed custom-range chip with "coming_soon" tooltip) to "fully working." Agenda then migrates as Slice B, on top of a proven component.
|
||||
|
||||
### Q3.B [DEFAULT — no escalation needed]: ALL center button glyph?
|
||||
|
||||
- **(R) `⌖`** (POSITION INDICATOR, U+2316). Implies "center / pin to here."
|
||||
- B `∞` (infinity). Mathy.
|
||||
- C `⊕` (circled plus). Looks like a button.
|
||||
- D No glyph, just "ALLES" in bold.
|
||||
|
||||
(R) `⌖`. If the head/m doesn't like the unicode lookup, D is the safe fallback.
|
||||
|
||||
### Q4 [DEFAULT — no escalation]: Custom-range entry in Slice A?
|
||||
|
||||
- **(R)** Inline `<input type="date">` pair, OS-native picker. Slice D adds the slicer.
|
||||
|
||||
### Q5 [DEFAULT — no escalation]: Past `24h` in audit-log?
|
||||
|
||||
audit-log currently has a `24h` preset; the picker would express this as `past_1d`. Options:
|
||||
|
||||
- **(R)** Map legacy `?range=24h` → `?horizon=past_1d`. Add a new `past_1d` constant.
|
||||
- B Drop `24h` — audit log defaults to `past_7d` like other surfaces. Users wanting "last 24h" use custom mode.
|
||||
|
||||
(R) Add `past_1d`. It's a one-line addition and audit-log users genuinely use "last 24h" for incident triage.
|
||||
|
||||
(Note: this means the picker actually has 5 past chips + 5 future chips + center + custom = 12 chips total, which fits comfortably in the popover.)
|
||||
|
||||
### Q6 [DEFAULT — no escalation]: Slice D (slicer) — separate task or fold in?
|
||||
|
||||
- **(R) Separate task.** Slice A-C are independently shippable. Slice D is meaningful design + ~400 LoC and shouldn't gate the main migration.
|
||||
|
||||
### Q7 [DEFAULT — no escalation]: Per-surface defaults?
|
||||
|
||||
Each migrating surface keeps its current default exactly:
|
||||
|
||||
- `/agenda` → `next_30d` (was 30).
|
||||
- `/admin/audit-log` → `past_7d` (was 7d).
|
||||
- `/projects/:id` Verlauf → `past_30d` (was past_30d in `projects-detail.ts:2310`).
|
||||
- `/views/:id` runtime → whatever the saved view has (no change).
|
||||
- `/inbox` (InboxFilterBar) → whatever filter-bar's surface defines.
|
||||
|
||||
### Q8 [DEFAULT — no escalation]: Should `past_14d` and `next_14d` retroactively appear in `views-editor.tsx`'s `<select>`?
|
||||
|
||||
(R) **Yes** — once Slice A ships, the `<select>` in `views-editor.tsx` is replaced by the picker (part of Slice A, as filter-bar consumers all flip in one commit). All 12 preset values become available for new Custom Views.
|
||||
|
||||
---
|
||||
|
||||
## §9 Implementer notes (for the coder shift, if approved)
|
||||
|
||||
### Lessons embedded
|
||||
|
||||
- **TimeSpec extension is additive only** — Go enum + TS union + i18n keys + horizonBounds switch. No DB migration, no contract break.
|
||||
- **Pure module is testable under `bun test`** — no DOM needed for horizon math, label resolution, URL serialization. Aim for 95%+ coverage of the pure module before touching the boot client.
|
||||
- **Reuse `.agenda-chip` styling** — adds no new tokens, no new dark-mode contrast risk (cf. memory t-paliad-150 / fritz — fritz lost 90 minutes to a `var(--token, #hex)` fallback bug because the token wasn't defined in dark mode).
|
||||
- **`mode="inline"` for filter-bar consumers** — the bar already wraps its own popover-like layout; nesting popovers gets visually noisy.
|
||||
- **Surface defaults must be members of `presets`** — assert at boot, fail loud in dev, fall back to `any` in prod.
|
||||
|
||||
### Recommended coder profile
|
||||
|
||||
Pattern-fluent Sonnet. Substrate is well-trodden (TimeSpec/TimeHorizon already lives, chip-cluster CSS exists, URL-codec pattern documented in `projects-chart.ts`). The novel piece is the popover scaffolding — paliad doesn't have a generic Popover primitive today; the picker builds its own DOM-anchored overlay. ~80 LoC of plain JS, no dependency.
|
||||
|
||||
### Build hygiene checklist
|
||||
|
||||
- `go build ./...` clean
|
||||
- `go vet ./...` clean
|
||||
- `go test ./...` clean (existing tests must continue passing — additive constants change zero behaviour)
|
||||
- `bun run build` clean (i18n scan: 21 new keys added, all `data-i18n` attributes present)
|
||||
- bun:test covers the pure module (horizon math, label resolver, URL parser/serializer)
|
||||
- Playwright smoke (manual, not gated): on `/inbox` the time axis "Anpassen" chip is now functional; custom-from/to date pair commits a usable filter.
|
||||
|
||||
### Out of scope for the coder
|
||||
|
||||
- Slicer (Slice D) — separate task.
|
||||
- Per-language adjustments beyond DE/EN (per task brief, out of scope).
|
||||
- Time-of-day picking — separate concern.
|
||||
- Recurring-event windows — events feed handles separately.
|
||||
- A generic Popover primitive — extract only if a second consumer appears in the same slice.
|
||||
|
||||
### Acceptance criteria for Slice A
|
||||
|
||||
1. New `<DateRangePicker>` mounts on filter-bar's `time` axis, replacing the disabled "Anpassen" chip.
|
||||
2. The 4 new horizon values (`past_14d`, `next_14d`, `past_all`, `next_all`) are accepted by Go's `TimeSpec.validate()` and produce correct `(from, to)` bounds in `computeViewSpecBounds()`.
|
||||
3. The 4 new horizons round-trip through saved Custom Views (`paliad.user_views.filter_spec` JSON).
|
||||
4. URL serialization is canonical (`?horizon=…&from=…&to=…`) and surface-default values are omitted.
|
||||
5. Verlauf (`/projects/:id`), `/views`, `/views/:id`, and `/inbox` continue to function with their existing presets unchanged — they pick up the new picker but don't switch their preset list yet.
|
||||
6. Pure-module unit tests cover: 12 horizons × bound calculation; URL parse / serialize round-trip; default-omission rule; custom-mode date validation.
|
||||
7. `bun run build` reports the new i18n keys (no missing-key warnings).
|
||||
8. No regression in `go test ./internal/services/...` (existing TimeSpec tests stay green).
|
||||
|
||||
---
|
||||
|
||||
## §10 Material picks summary — escalation message
|
||||
|
||||
To be sent via `mai instruct head` after this doc is pushed:
|
||||
|
||||
> Three material picks for m on date-range-picker design:
|
||||
>
|
||||
> 1. **`/projects/:id/chart` migration** — keep symmetric (1y/2y/all) presets as a sibling component, NOT fold into TimeHorizon. Chart is a viewport, not a filter.
|
||||
> 2. **Popover vs modal** — popover by default. Modal is a `mode` prop available per surface but no surface picks it in Slice A.
|
||||
> 3. **Slice A first migrates filter-bar time axis** (lights up Verlauf + InboxFilterBar + Views + Custom-Views-editor simultaneously by un-stubbing the existing "Anpassen" chip), not `/agenda` as the task brief defaulted. `/agenda` is Slice B.
|
||||
>
|
||||
> Everything else (chip labels, accent, glyph, custom-mode entry, surface defaults, past_1d for audit, slicer-as-Slice-D, 42 i18n keys) defaults per (R) in §8. Doc at `docs/design-date-range-picker-2026-05-25.md`.
|
||||
|
||||
---
|
||||
|
||||
*Verified premises (live, before designing):*
|
||||
|
||||
- `internal/services/filter_spec.go:107-126` — TimeHorizon enum at 9 values today.
|
||||
- `internal/services/view_service.go:156-187` — `computeViewSpecBounds()` switches on the same enum.
|
||||
- `frontend/src/client/views/types.ts:21-33` — TimeHorizon TS mirror; same 9 values.
|
||||
- `frontend/src/client/filter-bar/axes.ts:65-115` — chip cluster renderer; "Anpassen" stub at line 105-112 marked Phase 2, disabled, "coming_soon" tooltip.
|
||||
- `frontend/src/agenda.tsx:64-67` — chip row exact values `7|14|30|90`.
|
||||
- `frontend/src/admin-audit-log.tsx:50-65` — select exact values `24h|7d|30d|custom|all`.
|
||||
- `frontend/src/projects-chart.tsx:78-82` + `frontend/src/client/projects-chart.ts:73-118` — RangePreset `1y|2y|all|custom`, symmetric around today.
|
||||
- `frontend/src/views-editor.tsx:102-109` — select exact values `next_7d|next_30d|next_90d|past_30d|past_90d|any`.
|
||||
- `/home/m/dev/web/upc-kommentar/src/lib/components/DateRangeSlider.svelte` — 448 lines, wraps `svelte-range-slider-pips@4`, custom anchor rail above the lib's hidden pips, click-to-snap left/right halves, granularity year/month/day zoom.
|
||||
- `/home/m/dev/web/upc-kommentar/src/lib/modules/date-range-slider/date-range-slider-pure.ts` — 487 lines, fully testable pure helpers, dependency-free, portable to paliad's TS.
|
||||
|
||||
*Not verified live:* upckommentar.de in a browser (requires author auth; the source code IS the source of truth and was read end-to-end).
|
||||
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.
|
||||
668
docs/design-submission-page-2026-05-22.md
Normal file
668
docs/design-submission-page-2026-05-22.md
Normal file
@@ -0,0 +1,668 @@
|
||||
# Design — Dedicated Submission/Schriftsätze page (t-paliad-238)
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-22
|
||||
**Issue:** m/paliad (mai task t-paliad-238)
|
||||
**Branch:** `mai/cronus/inventor-dedicated`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
**Prior art:** `docs/design-submission-generator-2026-05-19.md` (t-paliad-215). This doc deepens that design rather than replacing it — every section below references the corresponding §x there when the shape is reused.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Today's "Schriftsätze" tab on the project detail page lists each filing-type rule and offers a one-click [Generieren] that streams a clean (format-only) `.docx` of the universal HL Patents Style template. There is no customization — variables, parties, dates, optional passages: none of it is filled in. The lawyer downloads the firm style, opens it in Word, and types everything by hand.
|
||||
|
||||
This design adds a **dedicated submission page** at `/projects/{id}/submissions/{code}/draft` where the lawyer:
|
||||
|
||||
1. Picks (or creates) a named **draft** for one (project, submission_code).
|
||||
2. Sees a sidebar with every `{{placeholder}}` the merge engine knows, pre-filled from the project's data (parties, court, case number, dates, legal_source) — editable inline. Auto-saved.
|
||||
3. Sees a read-only preview pane showing the merged document body as HTML.
|
||||
4. Clicks **Export → .docx** to download a fully-merged Word file (template + project + lawyer overrides), ready to edit.
|
||||
|
||||
Old [Generieren] button stays as the one-click "quick export with empty placeholders" path; the new [Bearbeiten] button next to it deep-links to the draft editor. Drafts persist as `paliad.submission_drafts` rows so the lawyer can come back next week, multiple drafts per submission code, RLS through `paliad.can_see_project`.
|
||||
|
||||
**Reuses** the deleted Slice 1 backend (`SubmissionVarsService` from commit `3677c81`, in-house `SubmissionRenderer` from `8ea3509`, `patent_number_upc` helper from `1765d5e`) wholesale — those files are salvageable from git history and slot back in with one new service (`SubmissionDraftService`) and the new schema. **Reuses** the `internal/handlers/files.go` Gitea proxy pattern for per-submission_code templates in `m/mWorkRepo/templates/{FIRM_NAME}/{code}.docx` (chain: firm → base/code → base/family → skeleton) — same fallback chain m locked in the 2026-05-19 design (§5).
|
||||
|
||||
Three slices: **A** = schema + new page + variables-only export against the universal `.dotm` (one slice; ships the editor end-to-end); **B** = per-`submission_code` `.docx` templates with the fallback-chain registry (template authoring is the bottleneck, not code); **C** = toggleable passages (boilerplate sections the lawyer can include/exclude before export).
|
||||
|
||||
Read-only inventor design. Implementation gate is m's go/no-go on this doc through head.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-22)
|
||||
|
||||
Anchored against the running paliad codebase + youpc Supabase, not against CLAUDE.md or memory. Every claim that load-bears the design was checked against the live system.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| Today's `/api/projects/{id}/submissions` is **format-only**; no variables. | `internal/handlers/submissions.go:155-245`: handler fetches the universal `hl-patents-style.dotm` from the in-process `fileRegistry` cache, calls `services.ConvertDotmToDocx`, writes one `system_audit_log` row, streams. No project data merged. `frontend/src/client/submissions.ts` confirms the client side: POST → blob → download. The richer engine (`SubmissionVarsService` + `TemplateRegistry` + `SubmissionRenderer`) was reverted to format-only in commit `d86cac0` (t-paliad-230). |
|
||||
| Original Slice 1 backend is preserved in git history and salvageable. | `git show 3677c81:internal/services/submission_vars.go` (484 LoC, 7-namespace placeholder bag), `git show 8ea3509:internal/services/submission_render.go` (in-house run-fragmentation-aware merger, ~315 LoC), `git show 3677c81:internal/services/submission_templates.go` (442 LoC fallback-chain registry), `git show 1765d5e:internal/services/submission_vars.go` (Slice 2 added `{{project.patent_number_upc}}` helper). All four files compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`) — no API drift since 2026-05-20. |
|
||||
| `paliad.projects` carries everything the variable bag needs. | `internal/models/project.go:123` — `Title, Reference, CaseNumber, Court, PatentNumber, FilingDate, GrantDate, OurSide, InstanceLevel, ProceedingTypeID, ClientNumber, MatterNumber`. Unchanged since the original design. |
|
||||
| `paliad.parties` carries party data scoped per project. | `internal/models/project.go:567` (`Party{ID, ProjectID, Name, Role, Representative, ContactInfo}`); `PartyService.ListForProject(ctx, userID, projectID)` already exists at `internal/services/party_service.go`. Visibility flows from `ProjectService.GetByID` → `can_see_project`. |
|
||||
| `paliad.deadline_rules` published rows resolve by `submission_code`. | The same query the format-only handler uses (`internal/handlers/submissions.go:255-280`) — `lifecycle_state='published' AND is_active=true ORDER BY sequence_order LIMIT 1`. Today the corpus carries ~254 rules, ~214 published; covers DE-inf-LG, DE-inf-OLG, DE-inf-BGH, UPC-inf-CFI, DE-PatG-DPMA, DE-PatG-BPatG, EPO oppositions. |
|
||||
| Migration tracker is at 106; file list extends to 118 (other-branch work) on the worktree. | `SELECT version FROM paliad.paliad_schema_migrations ORDER BY version DESC LIMIT 1` → 106. `ls internal/db/migrations/` → `…118_paliadin_aichat_conversation`. The next free number for *this* branch's migration is **119** (collisions only if another worktree commits 119 first, in which case the coder picks the next unused). |
|
||||
| `paliad.can_see_project(uuid)` is the canonical RLS predicate. | Mig 055; every other table that gates on project visibility uses it. The new `submission_drafts` table follows the same pattern. |
|
||||
| The Schriftsätze tab already exists on project detail. | `frontend/src/projects-detail.tsx:91` (`data-tab="submissions"`), section `#tab-submissions` at line 629. Empty / no-proceeding / table-of-rules states already wired. The page-level route `GET /projects/{id}/submissions` exists at `internal/handlers/handlers.go:472` and renders the same project detail page with the submissions tab pre-selected (`#tab-submissions` URL fragment + tab activation). **No new top-level route needed**; this design adds a *deeper* route `/projects/{id}/submissions/{code}/draft` and `/draft/{draftID}`. |
|
||||
| `internal/handlers/files.go` carries the Gitea proxy + SHA-cache pattern. | Same template the original Slice 1 design lifted (in `templates/_base/de.inf.lg.erwidg.docx @ SHA 7f97b7f9` per memory). 5-min refresh, in-process cache, single-replica deployment. Reusable wholesale. |
|
||||
| `lukasjarosch/go-docx` is NOT a deal we made. | The original 2026-05-19 design recommended it, but the shipped Slice 1 (commit `8ea3509`) went with an **in-house renderer** because the library refuses to replace sibling `{{a}} ./. {{b}}` placeholders in the same run. The in-house engine handles cross-run fragmentation in ~315 LoC. **This design reuses the in-house engine, no new Go dependency.** Memory entry `ca6de586` corroborates the engine decision verbatim. |
|
||||
| `FIRM_NAME` defaults to "HLC", overridable. | `internal/branding.Name` (read once at process start). Templates land under `templates/HLC/...` for the default; the fallback chain handles per-firm overrides without code change. |
|
||||
| The PoC Paliadin is owner-gated; the submission page is NOT. | `internal/services/paliadin.go:52` — `PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"`. This is the LLM-shell-out boundary, irrelevant to template-merge. Every paliad user who can see a project can edit its submission drafts. |
|
||||
| The `entity-table` row contract is enforced. | `.claude/CLAUDE.md` → "Whole-card / whole-row click → use a JS row handler". The new draft list (when a submission_code has multiple drafts) follows the same pattern. |
|
||||
|
||||
**Doc-vs-live conflicts found:** none material. `docs/project-status.md` doesn't mention t-paliad-215 or t-paliad-230 yet — that's a documentation lag, not a design risk.
|
||||
|
||||
---
|
||||
|
||||
## §2 m's decisions (2026-05-22)
|
||||
|
||||
The task brief (mai task t-paliad-238 description) carries inventor recommendations (R) for ten open questions. Per project CLAUDE.md inventor → head escalation policy: inventor defaults to (R) unless the pick is materially expensive or risk-bearing, in which case the head escalates to m. The matrix below records the (R) defaults this design adopts; the four genuinely-material picks are escalated to head in §11.
|
||||
|
||||
| # | Question (from task brief) | Default adopted | Source |
|
||||
|---|---|---|---|
|
||||
| Q1 | Page location | **Deep page under project — `/projects/{id}/submissions/{code}/draft` and `…/draft/{draftID}`** | (R) — keeps URL self-describing and shareable with the project. |
|
||||
| Q2 | State persistence | **Server-side draft, `paliad.submission_drafts` keyed on `(project_id, submission_code, user_id, name)` with autosave** | (R) — multiple drafts per code, named; resumable across sessions. |
|
||||
| Q3 | Variable layer | **Resurrect `submission_vars.go` from commit `3677c81` + `1765d5e` (incl. `patent_number_upc`); resolve at export time** | (R) — proven shape, ~30 placeholders, 7 namespaces. |
|
||||
| Q4 | Customization surface (UI) | **Structured sidebar (variable list, editable values) + read-only HTML preview pane** | (R) — sidebar drives the merge; preview reflects the result. |
|
||||
| Q5 | Template source | **(a) per-`submission_code` `.docx` in `m/mWorkRepo/templates/{FIRM_NAME}/...` via Gitea proxy + fallback chain** | (R) — Word is the authoring surface lawyers know; mWorkRepo is the existing vehicle. |
|
||||
| Q6 | Customization options beyond variables | **v1: variables only. Toggleable passages = Slice C** | (R) — citation insertion waits for the sources system. |
|
||||
| Q7 | Migration / data shape | **See §4** | (R) — followed task brief's column list, refined for RLS + cascade + index. |
|
||||
| Q8 | Export endpoint | **`POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export` → `.docx`** | (R) — reuses `ConvertDotmToDocx` strip-macros path but adds merge step. |
|
||||
| Q9 | Schriftsätze tab integration | **Keep list + existing "Generieren" (one-click format-only); add new "Bearbeiten" button per row that deep-links to `/projects/{id}/submissions/{code}/draft`** | (R) — additive, no churn for users who only want the firm style. |
|
||||
| Q10 | Variable-merge library | **In-house renderer from commit `8ea3509` (no new Go module)** | (R) — the 2026-05-19 design recommended `lukasjarosch/go-docx`, but the shipped Slice 1 reverted to an in-house engine because the library refused sibling placeholders. The in-house engine handles run-fragmentation in ~315 LoC and is already battle-tested against the corpus. |
|
||||
|
||||
Inventor-defaulted (not in the (R) matrix; clear right answer):
|
||||
|
||||
| # | Topic | Default | Reasoning |
|
||||
|---|---|---|---|
|
||||
| D1 | Authorization | `paliad.can_see_project(project_id)` only. No profession floor. | Matches every other write surface on a project. Draft is a Word doc; lawyer's substantive review happens downstream. |
|
||||
| D2 | Missing-placeholder behaviour | `[KEIN WERT: {key}]` / `[NO VALUE: {key}]` in the rendered preview AND in the exported .docx, per `DefaultMissingMarker(lang)` from `8ea3509` | Same call the original design made. Lawyer sees the gap in Word, fixes in paliad, regenerates. Better than 400ing. |
|
||||
| D3 | Editor surface for templates | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo). | Per the original design §5. A paliad-side uploader is a Slice C+ affordance only if Gitea round-trip is friction. |
|
||||
| D4 | Audit trail | One `paliad.system_audit_log` row per export (`event_type='submission.exported'`) + one `paliad.project_events` row (`event_type='submission_exported'`) so the export surfaces in Verlauf / SmartTimeline. **Draft create/update do NOT audit** — autosave noise would dominate the log. | Mirrors the existing `submission.generated` row from `internal/handlers/submissions.go:333-339`. The Verlauf entry is the user-visible footprint; the system_audit_log entry is the admin-visible audit footprint. |
|
||||
| D5 | Preview engine | Server-side merge → render to HTML for preview pane. Same `SubmissionRenderer` walks the .docx, but for the preview it strips `<w:r>` / `<w:p>` to plain HTML paragraphs (no styling beyond paragraph breaks + bold/italic carry-through). The export endpoint produces the real .docx with all formatting preserved. | Cheaper than client-side OOXML parsing; matches the read-only Q4 contract. The preview is a fidelity guide, not a WYSIWYG editor — final formatting comes from Word. |
|
||||
| D6 | Draft autosave cadence | Debounce 500ms after the lawyer stops typing in a variable field; PATCH `…/drafts/{draftID}` with the diff. No optimistic locking — last-write-wins per (project, submission_code, user) draft, and we never multi-user a single draft (one row per `user_id`). | Standard textarea autosave; the data is the lawyer's own draft, not a shared object. |
|
||||
| D7 | Variable contract surfacing | Each placeholder in the sidebar shows: dotted key (e.g. `project.case_number`), human label (DE/EN), current resolved value (from project state), and an editable override field. Override empty → fall back to project state at export. Override filled → carry the lawyer's value into the merge. | Lawyer never has to leave the page to fix a project-level field; AND lawyer can locally override (e.g. "Court is wrong on this draft, but I don't want to edit the project") without polluting project state. |
|
||||
| D8 | Draft naming | First draft per (project, submission_code, user) auto-named "Entwurf 1" (DE) / "Draft 1" (EN). Lawyer can rename inline. Subsequent drafts auto-name "Entwurf 2", etc. The (project_id, submission_code, user_id, name) tuple is the unique constraint — two drafts can't share a name for the same submission of the same project. | Lets the lawyer keep a "submitted version" and a "scratch" version side-by-side. |
|
||||
|
||||
---
|
||||
|
||||
## §3 Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Project detail (existing) — /projects/{id} with #tab-submissions │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Schriftsätze tab │ │
|
||||
│ │ Klageerwiderung [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Schriftsatz der Klägerin (SoC) [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Replik [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ └────────┬──────────────────────────────────────────┬─────────────────────┘ │
|
||||
│ │ "Bearbeiten" deep-link │ "Generieren" = │
|
||||
│ │ │ existing one-click │
|
||||
│ ▼ │ format-only export │
|
||||
└───────────┼───────────────────────────────────────────┼──────────────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ NEW Submission draft page — /projects/{id}/submissions/{code}/draft │
|
||||
│ (lands on most-recent draft for this user, or creates "Entwurf 1") │
|
||||
│ │
|
||||
│ ┌──── Sidebar (sticky left) ────────┐ ┌──── Preview pane (right) ───────┐ │
|
||||
│ │ Schriftsatz: Klageerwiderung │ │ [HTML-rendered merge of │ │
|
||||
│ │ Entwurf 1 ▼ [+ Neuer Entwurf] │ │ template + variables + │ │
|
||||
│ │ ───────────────────────────────── │ │ overrides] │ │
|
||||
│ │ project.case_number │ │ │ │
|
||||
│ │ 2 O 123/25 [override?] │ │ Klage gegen die │ │
|
||||
│ │ parties.claimant.name │ │ Beklagte BMW AG, vertreten │ │
|
||||
│ │ BMW AG [override?] │ │ durch … │ │
|
||||
│ │ deadline.due_date │ │ │ │
|
||||
│ │ 2026-06-12 [override?] │ │ Sehr geehrte Damen und Herren, │ │
|
||||
│ │ ... │ │ │ │
|
||||
│ │ │ │ [KEIN WERT: project.our_side] │ │
|
||||
│ │ [✎ Bearbeiten] inline │ │ │ │
|
||||
│ └───────────────────────────────────┘ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
└──────────────────────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ handlers/submission_drafts.go (NEW) │
|
||||
│ 1. Auth: ProjectService.GetByID → can_see_project │
|
||||
│ 2. Load draft row (RLS via project visibility) │
|
||||
│ 3. SubmissionVarsService.Build (project + parties + rule + next-Frist) │
|
||||
│ 4. Apply draft.overrides on top of bag │
|
||||
│ 5. TemplateRegistry.Resolve(code) — fallback chain → bytes + SHA │
|
||||
│ (Slice A: skips registry, fetches the universal .dotm directly) │
|
||||
│ 6. SubmissionRenderer.Render(bytes, bag, missingMarker) → .docx bytes │
|
||||
│ 7. Audit: system_audit_log + project_events │
|
||||
│ 8. Stream .docx with Content-Disposition: attachment │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**No backend changes to today's Schriftsätze tab** — its list endpoint + one-click generate stay exactly as they are. The new page is additive.
|
||||
|
||||
---
|
||||
|
||||
## §4 Schema (`paliad.submission_drafts`)
|
||||
|
||||
Migration `119_submission_drafts.up.sql` (next free number on this branch; coder bumps if 119 is taken at write time).
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.submission_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL, -- "Entwurf 1", lawyer-renameable
|
||||
|
||||
overrides jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "project.case_number": "2 O 999/25", ... }
|
||||
-- empty value = "don't override, use bag"
|
||||
-- present key = "use this verbatim"
|
||||
|
||||
last_exported_at timestamptz, -- NULL until first export
|
||||
last_exported_sha text, -- template SHA at last export (audit aid)
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_drafts_unique_per_user
|
||||
UNIQUE (project_id, submission_code, user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX submission_drafts_project_user_idx
|
||||
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY submission_drafts_visible
|
||||
ON paliad.submission_drafts
|
||||
FOR ALL
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
-- updated_at trigger pattern (same shape as paliad.notizen, etc.).
|
||||
CREATE TRIGGER submission_drafts_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
```
|
||||
|
||||
**No changes to `paliad.deadline_rules`.** The task brief floats a `template_body_de/_en` Markdown column as alternative (b) — rejected per Q5 default. Templates stay in Gitea.
|
||||
|
||||
**No changes to `paliad.documents`.** The original Slice 1 design wrote an `audit-only` row (`file_path NULL`) per generation; this design **does not** — `system_audit_log` + `project_events` carry the audit trail, and `paliad.documents` is reserved for actually-uploaded documents (a Phase 2 affordance per §13.5 of the 2026-05-19 design). If m wants the `documents` row for symmetry with future "I uploaded my edited version" UX, the coder can land it in a follow-up migration; it's not load-bearing for this design.
|
||||
|
||||
### 4.1 RLS read-vs-write
|
||||
|
||||
`can_see_project` is the only gate — the policy applies to FOR ALL operations. Anyone who can see a project can create / read / update / export drafts under that project, for their own user_id. Inter-user draft visibility (paralegal sees associate's drafts) is **NOT** a requirement in v1 — the unique constraint includes `user_id` and we don't expose a "drafts by other users on this project" endpoint. Multi-user collaboration on a single draft is out of scope.
|
||||
|
||||
### 4.2 Down migration
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS paliad.submission_drafts;
|
||||
```
|
||||
|
||||
No data loss concern at design time — feature ships without legacy drafts.
|
||||
|
||||
---
|
||||
|
||||
## §5 Service layer
|
||||
|
||||
### 5.1 Resurrect from git (no new code)
|
||||
|
||||
```
|
||||
internal/services/submission_vars.go RESURRECT from 3677c81 + Slice 2 patch from 1765d5e (patent_number_upc)
|
||||
internal/services/submission_render.go REPLACE the format-only convert with the in-house renderer from 8ea3509.
|
||||
KEEP the convert helper (ConvertDotmToDocx) — Slice A still needs it to
|
||||
strip macros from the universal .dotm before the merge step runs.
|
||||
internal/services/submission_templates.go RESURRECT from 3677c81 — Gitea-backed TemplateRegistry with fallback chain.
|
||||
NOT wired in Slice A (universal .dotm only); wired in Slice B.
|
||||
```
|
||||
|
||||
The three files were ~926 LoC + 350 LoC + 35 LoC patch when shipped. They compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`); zero API drift since their deletion. The resurrection is a copy-paste from `git show`, plus a one-line wiring in `cmd/server/main.go` + `internal/handlers/handlers.go`.
|
||||
|
||||
### 5.2 New service — `SubmissionDraftService`
|
||||
|
||||
```go
|
||||
// internal/services/submission_draft_service.go (NEW, ~300 LoC)
|
||||
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
}
|
||||
|
||||
type SubmissionDraft struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
SubmissionCode string
|
||||
UserID uuid.UUID
|
||||
Name string
|
||||
Overrides PlaceholderMap // jsonb → map[string]string
|
||||
LastExportedAt *time.Time
|
||||
LastExportedSHA *string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// List returns every draft for (project, submission_code, user) ordered by updated_at DESC.
|
||||
// Visibility flows through projects.GetByID.
|
||||
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error)
|
||||
|
||||
// Get returns a single draft by ID, gated on project visibility.
|
||||
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error)
|
||||
|
||||
// EnsureLatest returns the user's most-recently-updated draft for (project, submission_code).
|
||||
// Creates "Entwurf 1" / "Draft 1" if none exists. Idempotent on repeat calls.
|
||||
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Create makes a new draft with an auto-incremented "Entwurf N" name (lawyer can rename via Update).
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Update patches the draft. Permitted fields: name, overrides. last_exported_* is set by the export handler.
|
||||
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error)
|
||||
|
||||
// Delete archives the draft. ON DELETE CASCADE from project takes care of project-archival fallout.
|
||||
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error
|
||||
|
||||
// MarkExported updates last_exported_at + last_exported_sha after a successful export.
|
||||
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, sha string) error
|
||||
```
|
||||
|
||||
`DraftPatch` is a `struct { Name *string; Overrides *PlaceholderMap }` — nil pointer = "no change", non-nil = "set to this". `Overrides` is replace-semantics (lawyer's sidebar sends the full map); the service does not merge.
|
||||
|
||||
### 5.3 Wiring
|
||||
|
||||
```go
|
||||
// cmd/server/main.go (additions, no replacements)
|
||||
draftSvc := services.NewSubmissionDraftService(db, projectSvc)
|
||||
varsSvc := services.NewSubmissionVarsService(db, projectSvc, partySvc, userSvc)
|
||||
// Slice B only:
|
||||
tplRegistry := services.NewTemplateRegistry(os.Getenv("GITEA_TOKEN"), branding.Name)
|
||||
```
|
||||
|
||||
No new env var. `GITEA_TOKEN` is already documented in CLAUDE.md and used by `internal/handlers/files.go`.
|
||||
|
||||
---
|
||||
|
||||
## §6 UI surface
|
||||
|
||||
### 6.1 Page layout
|
||||
|
||||
`/projects/{id}/submissions/{code}/draft` lands on the user's latest draft for that (project, code). `/projects/{id}/submissions/{code}/draft/{draftID}` opens a specific draft (e.g. "Entwurf 2"). Both routes call the same renderer + client bundle; the difference is which draft `EnsureLatest` vs `Get` returns.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ← Zurück zum Projekt: BMW AG ./. Bosch GmbH │
|
||||
│ Schriftsatz: Klageerwiderung (DE.ZPO.276.1) • Entwurf 1 │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
├────────── Sidebar (sticky) ────────────────┬─────── Preview ────────────────┤
|
||||
│ Entwurf 1 ▼ │ [HTML-rendered merge] │
|
||||
│ • Entwurf 1 (zuletzt 23 Mai 2026) │ │
|
||||
│ • Entwurf 2 (zuletzt 20 Mai 2026) │ An das Landgericht München I │
|
||||
│ • [+ Neuer Entwurf] │ Pacellistr. 5 │
|
||||
│ │ 80333 München │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ Variablen │ In der Sache │
|
||||
│ firm.name HLC │ │
|
||||
│ project.case_number 2 O 123/25 [✎] │ BMW AG, vertreten durch … │
|
||||
│ project.court LG München I [✎] │ — Klägerin — │
|
||||
│ parties.claimant.name BMW AG [✎] │ │
|
||||
│ parties.defendant.name Bosch GmbH [✎] │ gegen │
|
||||
│ parties.defendant.representative │ │
|
||||
│ Dr. Maria Schmidt [✎] │ Bosch GmbH … │
|
||||
│ deadline.due_date 2026-06-12 [✎] │ — Beklagte — │
|
||||
│ rule.legal_source_pretty │ │
|
||||
│ § 276 Abs. 1 ZPO ✓ │ Sehr geehrte Damen und Herren,│
|
||||
│ … │ │
|
||||
│ │ [KEIN WERT: project.our_side] │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ [Entwurf umbenennen] [Entwurf löschen] │ │
|
||||
└────────────────────────────────────────────┴────────────────────────────────┘
|
||||
```
|
||||
|
||||
Sidebar grouping (top-to-bottom, locale-aware labels):
|
||||
|
||||
1. **Schriftsatz** (rule.* — read-only metadata: name, legal_source_pretty, primary_party)
|
||||
2. **Mandanten & Parteien** (parties.*)
|
||||
3. **Verfahren** (project.* — case_number, court, patent_number, patent_number_upc, our_side, …)
|
||||
4. **Frist** (deadline.* — due_date, computed_from)
|
||||
5. **Kanzlei & Datum** (firm.*, user.*, today.*)
|
||||
|
||||
Each placeholder row shows: human label (DE/EN), resolved value, edit icon. Click [✎] expands an inline text input pre-filled with the current value. Blur or Enter → debounced autosave (500ms). Empty override → revert to bag value.
|
||||
|
||||
### 6.2 Preview pane
|
||||
|
||||
Read-only HTML. The same `SubmissionRenderer.Render(...)` call that produces the .docx for export ALSO produces a sidecar HTML preview (the in-house renderer walks `<w:p>` / `<w:r>` runs and emits `<p>` / inline `<strong>` / `<em>` based on `<w:b>` / `<w:i>` flags). Preview re-renders on every autosave round-trip (cheap: server-side merge, ~10ms for a 5-page brief). Loading state: ghost-skeleton paragraphs during the round-trip.
|
||||
|
||||
This is the SLIGHTLY non-trivial coder piece: the in-house renderer today emits .docx; the coder adds a parallel `RenderHTML` path that walks the same tree but emits HTML. Same regex, same run-merge logic, different writer. Coder estimates ~120 LoC on top of the resurrected `submission_render.go`.
|
||||
|
||||
### 6.3 Routing + handlers
|
||||
|
||||
```
|
||||
GET /projects/{id}/submissions/{code}/draft → page (lands on latest, creates if none)
|
||||
GET /projects/{id}/submissions/{code}/draft/{draftID} → page (specific draft)
|
||||
GET /api/projects/{id}/submissions/{code}/drafts → list drafts for current user
|
||||
POST /api/projects/{id}/submissions/{code}/drafts → create new draft → returns row + redirect target
|
||||
GET /api/projects/{id}/submissions/{code}/drafts/{draftID} → single draft + resolved bag + HTML preview
|
||||
PATCH /api/projects/{id}/submissions/{code}/drafts/{draftID} → update name / overrides; returns new preview
|
||||
DELETE /api/projects/{id}/submissions/{code}/drafts/{draftID} → delete
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
→ .docx download (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
|
||||
```
|
||||
|
||||
Page-route handler is `handleSubmissionDraftPage` in `internal/handlers/submission_drafts.go` — calls `handleProjectsDetailPage` shape (returns HTML for an SSR layout) with a deep-route flag, OR ships an entirely new TSX page module. Inventor default: **new TSX page** at `frontend/src/submission-draft.tsx` rendering its own layout. Lighter than retrofitting the existing project-detail page with conditional panels, and the URL semantics demand it (`#tab-submissions` is the tab; `/draft/{id}` is a distinct page).
|
||||
|
||||
### 6.4 Schriftsätze tab — additive change
|
||||
|
||||
```diff
|
||||
- // Each row: [Generieren ↓]
|
||||
+ // Each row: [Bearbeiten ↗] [Generieren ↓]
|
||||
```
|
||||
|
||||
`[Bearbeiten ↗]` → `window.location.href = "/projects/{id}/submissions/{code}/draft"`. `[Generieren ↓]` stays as today (one-click format-only export of the universal .dotm). For users who want zero-config "give me a clean firm style template", `[Generieren]` is the path; for users who want a merged draft, `[Bearbeiten]` is the path.
|
||||
|
||||
Per the `.entity-table` row contract in CLAUDE.md, the row itself becomes clickable (navigates to `/draft`), with the `Generieren` button stopping propagation. The `entity-table--readonly` modifier is removed.
|
||||
|
||||
---
|
||||
|
||||
## §7 Variable contract (v1 placeholder set)
|
||||
|
||||
Reproduced from the resurrected `submission_vars.go` (commits `3677c81` + `1765d5e`). The sidebar's "Variablen" section enumerates this list in the exact same order as `addProjectVars` / `addPartyVars` / etc., grouped per §6.1.
|
||||
|
||||
```
|
||||
firm.name — branding.Name (HLC or FIRM_NAME override)
|
||||
firm.signature_block — empty in v1 (Phase 2 affordance)
|
||||
|
||||
today — 2026-05-22 (ISO, Europe/Berlin)
|
||||
today.iso — ISO short
|
||||
today.long_de — "22. Mai 2026"
|
||||
today.long_en — "22 May 2026"
|
||||
|
||||
user.display_name — paliad.users.display_name
|
||||
user.email — paliad.users.email
|
||||
user.office — paliad.users.office
|
||||
|
||||
project.title — paliad.projects.title
|
||||
project.reference — paliad.projects.reference
|
||||
project.case_number — paliad.projects.case_number
|
||||
project.court — paliad.projects.court
|
||||
project.patent_number — DE/inline form "EP 1 234 567 B1"
|
||||
project.patent_number_upc — UPC parenthesised form "EP 1 234 567 (B1)" (Slice 2 helper, 1765d5e)
|
||||
project.filing_date — ISO date
|
||||
project.grant_date — ISO date
|
||||
project.our_side — claimant | defendant
|
||||
project.our_side_de — "Klägerin" | "Beklagte"
|
||||
project.our_side_en — "Claimant" | "Defendant"
|
||||
project.instance_level — lg | olg | bgh | cfi | …
|
||||
project.client_number — paliad.projects.client_number
|
||||
project.matter_number — paliad.projects.matter_number
|
||||
project.proceeding.code — e.g. "de.inf.lg"
|
||||
project.proceeding.name — locale-aware (DE: Verletzungsklage am Landgericht)
|
||||
project.proceeding.name_de — explicit DE
|
||||
project.proceeding.name_en — explicit EN
|
||||
|
||||
parties.claimant.name — first paliad.parties row with role='claimant'
|
||||
parties.claimant.representative
|
||||
parties.defendant.name — first row with role='defendant'
|
||||
parties.defendant.representative
|
||||
parties.other.name — first non-claimant/defendant row
|
||||
parties.other.representative
|
||||
|
||||
rule.submission_code — "de.inf.lg.erwidg"
|
||||
rule.name — locale-aware ("Klageerwiderung" / "Statement of Defence")
|
||||
rule.name_de
|
||||
rule.name_en
|
||||
rule.legal_source — "DE.ZPO.276.1"
|
||||
rule.legal_source_pretty — "§ 276 Abs. 1 ZPO" / "Section 276(1) ZPO"
|
||||
rule.primary_party — claimant | defendant | court | both
|
||||
rule.event_type — filing | hearing | decision
|
||||
|
||||
deadline.due_date — ISO of next pending deadline for this rule on this project
|
||||
deadline.due_date_long_de — "12. Juni 2026"
|
||||
deadline.due_date_long_en — "12 June 2026"
|
||||
deadline.original_due_date — ISO if extended
|
||||
deadline.computed_from — anchor description (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen")
|
||||
deadline.title — deadline.title
|
||||
deadline.source — "rule" | "manual" | …
|
||||
```
|
||||
|
||||
Variable bag construction is `SubmissionVarsService.Build(ctx, in)` — exactly the function in `3677c81`'s `submission_vars.go`, no changes.
|
||||
|
||||
### 7.1 Override semantics
|
||||
|
||||
The lawyer's `overrides` map (jsonb in `submission_drafts`) shadows the bag at export time:
|
||||
|
||||
```go
|
||||
bag := varsSvc.Build(ctx, ...).Placeholders // ~30 keys, resolved from project state
|
||||
for k, v := range draft.Overrides { // lawyer's edits
|
||||
if v == "" {
|
||||
delete(bag, k) // empty override means "force missing marker"
|
||||
} else {
|
||||
bag[k] = v // non-empty override replaces
|
||||
}
|
||||
}
|
||||
docx := renderer.Render(templateBytes, bag, missingMarker(lang))
|
||||
```
|
||||
|
||||
Edge case: lawyer types empty string into a field that was resolved from the project. Decision: empty override forces the `[KEIN WERT: …]` marker. Lawyer's intent ("blank this out, I'll fill manually in Word") is honoured rather than silently falling back to project state. Sidebar UX: empty override field is annotated "→ [KEIN WERT: …]" so the lawyer sees the consequence before exporting.
|
||||
|
||||
---
|
||||
|
||||
## §8 Template authoring (mWorkRepo layout, naming, fallback chain)
|
||||
|
||||
### 8.1 Slice A — universal .dotm only
|
||||
|
||||
Slice A merges the variable bag into the same universal HL Patents Style .dotm that today's format-only convert ships. **The .dotm body must carry `{{placeholder}}` tokens** — currently it doesn't (it's a firm style template, not a per-submission template). m has two ways to seed Slice A:
|
||||
|
||||
- **8.1.a** — author one universal template (`m/mWorkRepo/templates/_base/_universal.docx` or similar) with `{{firm.name}}`, `{{rule.name}}`, `{{project.case_number}}`, `{{parties.claimant.name}}`, etc. The merge engine fills these and outputs a draft that's still a generic letter shape but pre-populated.
|
||||
- **8.1.b** — author one Klageerwiderung-shaped template (`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`) and route Slice A's export to that path for `submission_code='de.inf.lg.erwidg'`, with a hard-coded fall-back to `_universal.docx` for any other code. This is essentially Slice A + B's first template — wins both rounds.
|
||||
|
||||
Inventor recommendation: **8.1.b**. Strictly more useful, identical engine code, identical mWorkRepo round-trip. The Slice A → Slice B transition is then "add more templates", not "rewire the resolver".
|
||||
|
||||
### 8.2 Slice B — fallback-chain registry
|
||||
|
||||
Layout reproduced from the 2026-05-19 design §5.1:
|
||||
|
||||
```
|
||||
m/mWorkRepo (existing repo, already proxied)
|
||||
└── templates/
|
||||
├── HLC/ # FIRM_NAME-keyed override dir
|
||||
│ ├── de.inf.lg.erwidg.docx # Slice A target (per 8.1.b above)
|
||||
│ ├── de.inf.lg.klage.docx # Slice B addition
|
||||
│ ├── upc.inf.cfi.soc.docx # Slice B addition
|
||||
│ └── upc.inf.cfi.sod.docx # Slice B addition
|
||||
├── _base/ # Cross-firm baseline
|
||||
│ ├── de.inf.lg.erwidg.docx # base equivalent (Slice C+)
|
||||
│ ├── de.inf.lg.docx # proceeding-family fallback
|
||||
│ ├── upc.inf.cfi.docx
|
||||
│ ├── _skeleton.docx # ultra-generic fallback
|
||||
│ └── _universal.docx # the v1 Slice A "any code" template
|
||||
└── README.md # placeholder reference for template authors
|
||||
```
|
||||
|
||||
Naming: `{submission_code}.docx`. Family fallback uses the first three dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`). Skeleton is the ultra-generic fallback (letterhead + party block + court address + signature stub).
|
||||
|
||||
### 8.3 Lookup algorithm
|
||||
|
||||
```go
|
||||
// services/submission_templates.go (resurrected from 3677c81)
|
||||
func (r *TemplateRegistry) candidates(submissionCode string) []string {
|
||||
family := familyOf(submissionCode)
|
||||
out := []string{
|
||||
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
|
||||
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
|
||||
}
|
||||
if family != "" && family != submissionCode {
|
||||
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
|
||||
}
|
||||
out = append(out, "templates/_base/_skeleton.docx")
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
Gitea proxy: same `internal/handlers/files.go` shape. 5-min SHA refresh, in-process cache, `GITEA_TOKEN` for auth. The original `submission_templates.go` already implements this end-to-end; the coder re-applies it from `git show 3677c81`.
|
||||
|
||||
### 8.4 No template at all — Slice A vs Slice B
|
||||
|
||||
Slice A: the universal template always resolves; `ErrNoTemplate` is impossible.
|
||||
|
||||
Slice B: if every candidate in the fallback chain 404s, the handler returns 503 + `"Vorlagen-Repository nicht erreichbar"` in the UI (same handling as the original Slice 1 design §5.4). Since the chain ends at `_skeleton.docx`, this only fires when the mWorkRepo itself is misconfigured.
|
||||
|
||||
### 8.5 Template authoring task lands outside this design
|
||||
|
||||
Inventor flags but does not assign: HLC must author the per-submission_code `.docx` templates. Slice A's `_universal.docx` is one document. Slice B adds Klageerwiderung, Klageerhebung, SoC, SoD, … iteratively. **Template authoring runs in parallel with engine code**; the coder ships the engine, m + HLC ships the templates. The two converge before the slice closes.
|
||||
|
||||
This is the m-escalated piece (see §11): without per-submission templates, Slice B is engine-only.
|
||||
|
||||
---
|
||||
|
||||
## §9 Slice plan
|
||||
|
||||
### Slice A — schema + new page + variables-only export against universal .docx
|
||||
|
||||
Ships the editor end-to-end with one template.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| Migration 119 — `submission_drafts` table + RLS + trigger | `internal/db/migrations/119_submission_drafts.{up,down}.sql` |
|
||||
| `SubmissionVarsService` resurrected | `internal/services/submission_vars.go` (from `3677c81` + Slice 2 patch `1765d5e`) |
|
||||
| `SubmissionRenderer` resurrected with new `RenderHTML` | `internal/services/submission_render.go` (from `8ea3509`); adds `RenderHTML(...) string` for preview |
|
||||
| `SubmissionDraftService` | `internal/services/submission_draft_service.go` (NEW) |
|
||||
| Handlers (page + 7 API endpoints) | `internal/handlers/submission_drafts.go` (NEW) |
|
||||
| Wiring | `cmd/server/main.go`, `internal/handlers/handlers.go` |
|
||||
| Page TSX | `frontend/src/submission-draft.tsx` (NEW) |
|
||||
| Client bundle | `frontend/src/client/submission-draft.ts` (NEW) |
|
||||
| Schriftsätze tab update | `frontend/src/projects-detail.tsx` (rows get [Bearbeiten]), `frontend/src/client/submissions.ts` (handler) |
|
||||
| i18n | new keys under `projects.detail.submissions.draft.*` and `submissions.draft.*` (page-level) |
|
||||
| One template at `m/mWorkRepo/templates/_base/_universal.docx` (8.1.b → also `templates/HLC/de.inf.lg.erwidg.docx`) | mWorkRepo, separate PR by m |
|
||||
| Tests | `internal/services/submission_render_test.go` (resurrected + RenderHTML), `internal/services/submission_vars_test.go` (round-trip), handler smoke |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Opening `/projects/{id}/submissions/de.inf.lg.erwidg/draft` lands on the user's latest draft (or creates "Entwurf 1").
|
||||
2. Sidebar renders ~30 placeholders, pre-filled from project state.
|
||||
3. Editing a sidebar value autosaves within 500ms and updates the preview pane.
|
||||
4. Multiple drafts per (project, code, user) supported; switcher in sidebar.
|
||||
5. Clicking "Als .docx exportieren" downloads a merged `.docx` (universal template + project + lawyer overrides).
|
||||
6. `system_audit_log` row appears on export (`event_type='submission.exported'`).
|
||||
7. `project_events` row appears on export and surfaces in Verlauf.
|
||||
8. RLS: caller without `can_see_project` gets 404 on the page and 404 on every draft API.
|
||||
9. Schriftsätze tab on project detail shows [Bearbeiten] alongside [Generieren].
|
||||
10. `go build ./... && go vet ./... && go test ./... && bun run build` clean.
|
||||
|
||||
### Slice B — per-submission_code templates + fallback chain
|
||||
|
||||
Engine is unchanged from Slice A; this slice wires `TemplateRegistry` into the export endpoint and lights up per-code templates.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| `TemplateRegistry` resurrected | `internal/services/submission_templates.go` (from `3677c81`) |
|
||||
| Handler swaps Slice A's `fetchHLPatentsStyleBytes` for `templateRegistry.Resolve(code)` | `internal/handlers/submission_drafts.go` |
|
||||
| `has_template` boolean per row in Schriftsätze tab list (today: unconditionally true; under Slice B: depends on registry probe) | `internal/handlers/submissions.go` |
|
||||
| Templates authored in mWorkRepo: at least Klageerwiderung + Klageerhebung + SoC + SoD | mWorkRepo PR by m |
|
||||
| Tests for fallback chain | `internal/services/submission_templates_test.go` (resurrect from history if it existed; otherwise new) |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Pushing `m/mWorkRepo/templates/HLC/upc.inf.cfi.soc.docx` makes the SoC draft page resolve that template within 5 min (or instantly via `POST /api/files/refresh`).
|
||||
2. `has_template=false` rows in the Schriftsätze tab show [Keine Vorlage] instead of [Bearbeiten]/[Generieren]. Existing list ordering preserved.
|
||||
3. `last_exported_sha` on `submission_drafts` records which SHA the lawyer exported against.
|
||||
4. Misconfigured repo (every fallback 404s) → 503 with clear error.
|
||||
|
||||
### Slice C — toggleable passages
|
||||
|
||||
Lawyer can include/exclude boilerplate sections before export.
|
||||
|
||||
| Deliverable | Notes |
|
||||
|---|---|
|
||||
| `passages` jsonb column on `submission_drafts` | `migration 120` (or whatever's free at land time): `passages jsonb NOT NULL DEFAULT '{}'::jsonb` — `{"intro": true, "patent_validity_attack": false, "non_infringement": true}`. |
|
||||
| Template syntax for passage blocks | `{{#passage intro}}…{{/passage}}` — start/end markers, merger drops the block when the corresponding `passages.{key}` is false. The in-house renderer's run-fragmentation handling extends to the new tokens cleanly. |
|
||||
| Sidebar UI | "Passagen" group above "Variablen", per-passage toggle (on by default), help text per passage. |
|
||||
| Template author API | `templates/README.md` documents the passage syntax + a worked example. |
|
||||
|
||||
Acceptance: turning off `non_infringement` in the sidebar of a Klageerwiderung draft removes the corresponding section from the exported .docx; preview reflects immediately.
|
||||
|
||||
Slices D+ (not detailed here): citation insertion from the sources system (waits for that surface), per-firm template overrides (registry already supports this), `/admin/submission-templates` variable contract sidebar.
|
||||
|
||||
---
|
||||
|
||||
## §10 Out of scope
|
||||
|
||||
- AI-drafted prose (the 2026-05-19 design §11 sketch; still deferred).
|
||||
- PDF export. v1 ships `.docx` only; the lawyer's Word does the PDF step.
|
||||
- Multi-user collaboration on a single draft. Each draft is owner-scoped (`user_id`).
|
||||
- Real-time co-editing. Last-write-wins per draft; no operational transforms.
|
||||
- An in-paliad WYSIWYG editor for `.docx` content. Preview is read-only; final edits happen in Word.
|
||||
- A paliad-side template uploader. Gitea stays as the editor for templates until lawyers complain about the round-trip.
|
||||
- Translation of templates DE↔EN. Templates are mono-locale; the variable bag is bilingual.
|
||||
- Citation insertion from the sources system. Waits for the sources surface m parked.
|
||||
- Frist-detail "Exportieren" button. The submission page is reachable only from the project's Schriftsätze tab in v1; a Frist-level deep-link is a Slice D+ affordance.
|
||||
- Validation of the rendered draft against any legal rule. The engine produces text; the lawyer's substantive review is downstream.
|
||||
- Sending the draft to court / e-filing. The lawyer downloads and handles transmission outside paliad.
|
||||
|
||||
---
|
||||
|
||||
## §11 Material picks escalated to head
|
||||
|
||||
Per project CLAUDE.md inventor → head policy, the four picks below carry enough cost or risk to deserve head's read. Head ratifies (or escalates to m) before the coder shift starts.
|
||||
|
||||
### Q-E1 — Template authoring effort
|
||||
|
||||
Slice A needs at least one custom-authored template (`_universal.docx` or `de.inf.lg.erwidg.docx`) carrying `{{placeholder}}` tokens. Slice B needs four more (Klageerhebung, SoC, SoD, Erwiderung). The engine ships independently of template content, but the feature is unfinished without lawyer-authored templates.
|
||||
|
||||
**Inventor pick:** ship Slice A with **one** lawyer-authored template (8.1.b: `templates/HLC/de.inf.lg.erwidg.docx`) + the universal fallback. m + HLC owns the authoring; the coder owns the engine. Slices A and template-1 land together.
|
||||
|
||||
**Material because:** without a template, the feature looks broken in user testing. Head decides: does m commit to authoring or reviewing the first template before Slice A merges, or does Slice A merge engine-only and we accept the "format-only export with placeholders" intermediate state for a week?
|
||||
|
||||
### Q-E2 — `paliad.documents` row on export
|
||||
|
||||
The original Slice 1 design wrote an audit-only `paliad.documents` row (`file_path NULL`, `doc_type='generated_submission'`) per generation, on the theory that "Documents" would become the canonical listing UI. This design defers that.
|
||||
|
||||
**Inventor pick:** **no** `paliad.documents` write. `system_audit_log` + `project_events` carry the audit trail. The `documents` table is reserved for actually-uploaded documents (Phase 2 of the broader docs roadmap).
|
||||
|
||||
**Material because:** if head agrees, we skip a column repurpose (`ai_extracted` jsonb being used for generation provenance — the 2026-05-19 design noted this was ugly). If head disagrees, the coder lands the row inside Slice A.
|
||||
|
||||
### Q-E3 — Preview render — server or client?
|
||||
|
||||
Server-side: `RenderHTML(...)` on the in-house renderer, round-trip per autosave. Cheaper to build, costs ~10ms server-side per keystroke (debounced 500ms).
|
||||
|
||||
Client-side: ship the merged document body as JSON of paragraph runs, render in TS. Faster preview, harder to build (parallel render path in TS), and **diverges** the preview from the export shape (export still goes server-side).
|
||||
|
||||
**Inventor pick:** **server-side**. Single source of truth for the merge logic. The 500ms debounce already absorbs the round-trip; a 10ms server merge plus 50ms HTTP RTT is sub-perceptible.
|
||||
|
||||
**Material because:** if head wants the client-side preview for fully-offline draft editing, the coder needs a TS port of `substituteInDocumentXML`. Bigger build, but no round-trip latency on every keystroke.
|
||||
|
||||
### Q-E4 — Inter-user draft visibility
|
||||
|
||||
Today's design: each user sees only their own drafts. If two associates on the same project both draft a Klageerwiderung, they don't see each other's drafts (each has their own row).
|
||||
|
||||
**Inventor pick:** **owner-scoped (status quo of this design)**. The unique constraint includes `user_id`; the `List` endpoint filters by current user.
|
||||
|
||||
**Material because:** if head wants project-team visibility ("paralegal sees associate's draft for review"), the unique constraint shifts to `(project_id, submission_code, name)` (drop `user_id`), the RLS already covers the read path (`can_see_project`), and `submission_drafts` becomes a project-team resource. **This is a Phase-shape change** — the lawyer model differs. Inventor flags it because the change is cheap to make now (one column + one constraint) and expensive to make later (drafts already accumulate per-user). Head's call.
|
||||
|
||||
---
|
||||
|
||||
## §12 Implementation notes
|
||||
|
||||
For the coder, not for head.
|
||||
|
||||
- **Resurrection is `git show`, not "re-write".** The four file revisions (`3677c81:internal/services/submission_vars.go`, `1765d5e:internal/services/submission_vars.go` for the Slice 2 patch, `8ea3509:internal/services/submission_render.go`, `3677c81:internal/services/submission_templates.go`) can be applied via `git checkout 3677c81 -- internal/services/submission_vars.go` etc. The coder should verify each compiles against today's `cmd/server/main.go` wiring before applying.
|
||||
- **Renderer's `RenderHTML` is new.** The .docx walker today emits OOXML bytes; the HTML emitter walks the same tree and emits `<p>` / `<strong>` / `<em>` / `<br>`. ~120 LoC on top of the resurrected file. Same regex (`placeholderRegex`), same run-merge logic, different writer.
|
||||
- **Sidebar variable schema needs a label table.** The variable contract from §7 is keyed by dotted paths; the sidebar UI needs DE/EN labels per key. Coder adds `services/submission_var_labels.go` with a `map[string]struct{LabelDE, LabelEN, HelpDE, HelpEN}` for the ~30 keys. (Mirrors `internal/services/email_template_variables.go` shape — same lawyer-facing pattern paliad already ships at `/admin/email-templates`.)
|
||||
- **Autosave race.** The lawyer types fast → multiple PATCHes in flight. Coder uses a request-ID-debouncing pattern on the client (cancel in-flight PATCH when a new one starts) and last-write-wins on the server. No version column on the draft row in v1.
|
||||
- **Empty-override semantics in the jsonb.** `overrides = {"project.case_number": ""}` means "force missing marker". `overrides = {}` (key absent) means "fall back to bag". The service code distinguishes — careful with `omitempty`.
|
||||
- **i18n key audit.** Add `projects.detail.submissions.action.edit`, `submissions.draft.title`, `submissions.draft.export`, `submissions.draft.sidebar.{firm,project,parties,deadline,user}.group`, `submissions.draft.rename`, `submissions.draft.delete`, `submissions.draft.new`, etc. Roughly 35 new keys in DE + EN.
|
||||
- **`entity-table` row contract.** Schriftsätze tab today carries `entity-table--readonly`. Slice A removes that modifier and adds a row-click handler that navigates to `/projects/{id}/submissions/{code}/draft`, skipping clicks on the inner [Generieren] button. Matches the pattern in `frontend/src/client/checklists.ts`, `client/projects-detail.ts`, `client/deadlines.ts`.
|
||||
- **Migration 119 may collide.** Other worktrees (paliadin aichat, mig 118) may land 119 before this branch merges. Coder verifies at land time; bump to the next free number if needed.
|
||||
|
||||
---
|
||||
|
||||
## §13 Acceptance gate
|
||||
|
||||
Per inventor SKILL.md + project CLAUDE.md: this design needs head's go/no-go before any coder is hired. After head ratifies (with or without escalating §11 to m):
|
||||
|
||||
- The head decides whether to hire the same worker as `/mai-coder` with this design as the brief, or a fresh coder.
|
||||
- A coder shift takes this doc as the spec, ships Slice A, opens a PR (no self-merge).
|
||||
- Slices B and C are SEPARATE tasks — not auto-spawned.
|
||||
|
||||
Inventor parks here.
|
||||
956
docs/research-deadlines-completeness-2026-05-25.md
Normal file
956
docs/research-deadlines-completeness-2026-05-25.md
Normal file
@@ -0,0 +1,956 @@
|
||||
# Bulletproof completeness audit — paliad.deadline_rules vs statutory sources
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-25
|
||||
**Task:** t-paliad-263 (m/paliad#94)
|
||||
**Mode:** read-only research, no DB writes
|
||||
**Branch:** `mai/curie/researcher-bulletproof`
|
||||
|
||||
Scope confirmed by head (paliad/head → paliad/curie, 2026-05-25 15:13):
|
||||
**UPC Rules of Procedure + EPC + PatG / ZPO / GebrMG**, plus UPC Agreement /
|
||||
Statute where they create time-limits. No HLC-internal checklists exist in
|
||||
the current head's working tree.
|
||||
|
||||
Companion / prior audits this report supersedes-and-extends:
|
||||
|
||||
- `docs/audit-fristenrechner-completeness-2026-04-30.md` (curie, t-paliad-084) — youpc-vs-paliad gap analysis.
|
||||
- `docs/audit-upc-rop-deadlines-2026-05-08.md` (curie, t-paliad-159) — first UPC RoP gap list (52 rules / 2 duration bugs).
|
||||
- `docs/audit-fristen-logic-2026-05-13.md` (pauli, t-paliad-157) — schema audit; the codes used here (`upc.inf.cfi`, `de.inf.lg`, …) reflect the post-mig-096 rename.
|
||||
|
||||
Migration baseline: migration ≤ `122_deadlines_custom_rule_text` (live as of 2026-05-25 14:00 UTC).
|
||||
|
||||
---
|
||||
|
||||
## §0. TL;DR
|
||||
|
||||
- **20 active fristenrechner proceeding_types** (live, `is_active=true`,
|
||||
`lifecycle_state='published'`) carry **132 active rules**. One extra
|
||||
`_archived_litigation` row holds 40 retired Pipeline-A rules from
|
||||
mig 093 — not surfaced anywhere, kept only for FK validity.
|
||||
|
||||
| Jurisdiction | Active types | Active rules | Statute-bound rules audited |
|
||||
|---|---:|---:|---:|
|
||||
| UPC (CFI + CoA) | 9 (incl. upc.ccr.cfi alias) | 67 | 67 |
|
||||
| EPA | 3 | 23 | 23 |
|
||||
| DPMA | 3 | 13 | 13 |
|
||||
| DE (LG/OLG/BGH/BPatG) | 5 | 29 | 29 |
|
||||
| **Total** | **20** | **132** | **132** |
|
||||
|
||||
- **5 high-impact bugs still live** that the prior May 8 audit
|
||||
surfaced (2) plus 3 new ones identified here.
|
||||
- 🔴 **`upc.rev.cfi.defence` 3 months, RoP.49.1 says 2 months.** Flagged
|
||||
May 8; still live. ★★★ — every UPC_REV defendant.
|
||||
- 🔴 **`upc.rev.cfi.rejoin` 2 months, RoP.52 says 1 month.** Flagged
|
||||
May 8; still live. ★★★ — every UPC_REV proceeding.
|
||||
- 🟠 **`upc.apl.merits.response` 2 months, RoP.235.1 says 3 months.**
|
||||
New finding (May 8 audit recorded the rule as "3 months / present-wrong
|
||||
rule_code only" — actually live data shows 2 months, so the audit
|
||||
sample mis-recorded the duration too). ★★★ — every UPC main-track
|
||||
appeal respondent.
|
||||
- 🟠 **`de.inf.lg.beruf_begr` chains parent = berufung (1mo) + 2mo = 3mo
|
||||
from urteil. ZPO §520(2) anchors the 2-month Begründungsfrist on
|
||||
service of urteil, not on filing of Berufung.** New finding.
|
||||
★★★ — every DE-first-instance appellant.
|
||||
- 🟠 **`de.inf.lg.replik` + `.duplik` have `parent_id=NULL` so they fire
|
||||
on the trigger date (Klageerhebung) — sequence-order says 30/40 but
|
||||
the compute engine reads parent_id first.** Reported as live UI bug
|
||||
by m via head (2026-05-25 13:13); confirmed by SQL. ★★★ — every
|
||||
DE-LG-Verletzung timeline.
|
||||
|
||||
- **5 rule-code / citation drift bugs still live** from the May 8 audit
|
||||
(`upc.apl.merits.notice`, `.grounds`, `.response`, `upc.rev.cfi.reply`,
|
||||
`.rejoin`) — durations may or may not be right, but the cited
|
||||
`legal_source` / `rule_code` points at the wrong rule. Pure
|
||||
cosmetic on `.notice`/`.grounds` (durations are right); load-bearing on
|
||||
`.rev.cfi.reply` / `.rejoin` because the cited rule is what tells
|
||||
the lawyer where to look the rule up.
|
||||
|
||||
- **4 DPMA / DE citation bugs** new in this audit, all citing PatG / ZPO
|
||||
sections that don't contain the cited deadline:
|
||||
- `de.null.bpatg.erwidg` cites `DE.PatG.82.1`; the 2-month Erwiderung
|
||||
is actually `§82(3)` (§82(1) is the 1-month Erklärungsfrist).
|
||||
- `dpma.opp.dpma.erwiderung` cites `DE.PatG.59.3`; §59(3) is about
|
||||
hearings, not a 4-month proprietor response. The 4-month figure is
|
||||
DPMA-internal practice, not statutory — should be court-set.
|
||||
- `dpma.appeal.bpatg.begruendung` cites `DE.PatG.75.1`; §75 is about
|
||||
*aufschiebende Wirkung* — there is no Begründungsfrist in PatG §73-§80
|
||||
for the BPatG-Beschwerde. The 1-month figure is also non-statutory.
|
||||
- `de.null.bgh.begruendung` cites `DE.PatG.111.1`; §111 is about the
|
||||
grounds-of-appeal *content* (Verletzung des Bundesrechts), not the
|
||||
Begründungsfrist. `de.null.bgh.erwiderung` cites `DE.PatG.111.3`;
|
||||
§111(3) doesn't exist in the deadline sense.
|
||||
|
||||
- **Wide UPC coverage gap inherited from May 8 audit, mostly un-closed:**
|
||||
~25 missing UPC RoP rules. Mig 095 (t-paliad-205) closed 4 of them
|
||||
(R.19 Preliminary Objection on UPC_INF and UPC_REV, R.220.1(a)
|
||||
merits-appeal spawn on both). The other ~21 (R.20.2, R.118.4,
|
||||
R.197.3, R.198, R.207.6.a, R.207.9, R.213, R.109.1/.4/.5, R.118.5,
|
||||
R.144, R.155, R.224.2(b), R.229.2, R.235.2, R.245.x, R.262.2,
|
||||
R.321.3, R.333.2, R.353, plus the DNI family R.63-R.69) are
|
||||
unchanged.
|
||||
|
||||
- **EPC gaps:** EPA opposition + Beschwerde modelled at the
|
||||
Article level only. Missing the entire Implementing Regulations
|
||||
family that drives day-to-day deadlines — R.71(3) approval period
|
||||
is half-modelled (the 4-month figure is there but the trigger
|
||||
anchor is broken: parent_id=NULL), R.79(1) proprietor response
|
||||
is modelled as a fixed 4-month period when it's actually
|
||||
court-set, R.116 oral-proceedings cut-off is modelled as
|
||||
duration-0/parent-NULL (works for some uses, not for others),
|
||||
R.121 / R.135 Weiterbehandlung is missing entirely (concept
|
||||
exists but no rule).
|
||||
|
||||
- **DE/DPMA gaps:** the entire Wiedereinsetzung family (PatG §123)
|
||||
is absent on the proceeding-tree side. `weiterbehandlung` and
|
||||
`wiedereinsetzung` concept slugs exist in the cascade (Pathway B)
|
||||
but no `paliad.deadline_rules` row computes them. Same for
|
||||
`versaeumnisurteil-einspruch` (ZPO §339 — 2 weeks).
|
||||
|
||||
- **15 ambiguities** that need m's judgement, not a coder's fix —
|
||||
mostly around court-set vs statutory periods (e.g. richterliche
|
||||
Fristen under ZPO §276(1) S.2, §283 Schriftsatznachreichung,
|
||||
EPC R.79(1), §59(3) PatG) and around the "whichever is
|
||||
longer / later" arithmetic primitives still missing
|
||||
(R.198 / R.213 / R.245.2).
|
||||
|
||||
- **Recommended fixes (§10) — total 41 items** prioritised in 4
|
||||
tiers. Tier 0 (5 hard duration bugs + 1 sequencing bug + 9
|
||||
citation/anchor bugs) should ship first. Tier 1 (12 rule-fill
|
||||
gaps, ★★★ / ★★) next. Tier 2 + 3 are coverage breadth that
|
||||
needs scoping by m (Wiedereinsetzung, R.198 working-day
|
||||
arithmetic, full Implementing Regulations port).
|
||||
|
||||
---
|
||||
|
||||
## §1. Methodology
|
||||
|
||||
For each of the 20 active proceeding_types I:
|
||||
|
||||
1. **Pulled the live rule set** via `mcp__supabase__execute_sql` against
|
||||
the youpc Postgres on 2026-05-25 14:00–15:00 UTC. Schema = `paliad`.
|
||||
Filter: `is_active = true AND lifecycle_state = 'published'`.
|
||||
2. **Enumerated the statutory deadlines** in the relevant code for the
|
||||
proceeding's scope.
|
||||
3. **Cross-referenced each statutory deadline against the live rule
|
||||
set** on (a) duration + unit, (b) anchor / parent, (c) party,
|
||||
(d) `rule_code` / `legal_source` citation, (e) sequencing.
|
||||
4. **Marked status**: `present-correct`, `present-wrong (duration)`,
|
||||
`present-wrong (citation)`, `present-wrong (anchor)`,
|
||||
`present-wrong (party)`, `partial`, `missing`, `n/a`.
|
||||
5. **Frequency tag** for prioritisation: ★★★ every case, ★★ common,
|
||||
★ specialist.
|
||||
|
||||
### 1.1 Sources
|
||||
|
||||
All citations carry a date stamp and a URL. Where the text was checked
|
||||
against more than one source, both are listed.
|
||||
|
||||
| Source | URL | Verified on | Used for |
|
||||
|---|---|---|---|
|
||||
| UPC Rules of Procedure (consolidated 18.05.2023, in force 2023-06-01) | https://www.unifiedpatentcourt.org/sites/default/files/upc_documents/rop_application_-_consolidated_18_05_2023.pdf | 2026-05-25 | All UPC RoP citations |
|
||||
| UPC RoP verbatim text via `data.laws_contents` (youpc Postgres, law_type=`UPCRoP`, language=en) | youpc Supabase | 2026-05-25 | Cross-check on R.019.1, R.020.2, R.029.b/.c, R.049.1, R.051, R.051.p1, R.052, R.052.p1, R.220.1.a, R.224.1, R.224.1.a/.b, R.224.2, R.224.2.a/.b, R.235.1, R.235.2, R.237, R.238.1, R.238.2 |
|
||||
| European Patent Convention (EPC, 17th ed. 2020) — Articles | https://www.epo.org/en/legal/epc/2020/index.html (verbatim text per youpc `data.laws_contents`, law_type=`EPC`) | 2026-05-25 | EPC Articles 93, 99, 108, 112a, 116, 121, 123, 135 |
|
||||
| EPC Implementing Regulations — Rules (in force 2026 consolidated) | https://www.epo.org/en/legal/epc/2020/r71.html (and equivalents) | 2026-05-25 | EPC R.70(1), R.71(3), R.79(1)/(2), R.116(1), R.135 |
|
||||
| Patentgesetz (PatG) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/patg/ | 2026-05-25 | §59, §73, §75, §82, §83, §99 ff., §100, §102, §110, §111 |
|
||||
| Zivilprozessordnung (ZPO) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/zpo/ | 2026-05-25 | §253, §276, §277, §283, §296a, §339, §517, §520, §521, §524, §544, §548, §551, §554 |
|
||||
| Gebrauchsmustergesetz (GebrMG) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/gebrmg/ | 2026-05-25 | §17 (Löschung), §18 (Verfahren) — referenced only to confirm out-of-scope: no GebrMG-rooted proceeding_type exists in paliad today |
|
||||
|
||||
### 1.2 Conventions
|
||||
|
||||
- A **rule** here means a row in `paliad.deadline_rules`. paliad's local
|
||||
identifier is `submission_code` (post mig 098), e.g.
|
||||
`upc.rev.cfi.defence`.
|
||||
- A **statutory deadline** means an obligation derived directly from the
|
||||
text of a procedural code, with a fixed period.
|
||||
- "**Court-set**" / "richterliche Frist" means the statute authorises the
|
||||
court / DPMA / EPO to set the period — there is no fixed statutory
|
||||
duration. paliad models these with `is_court_set = true`
|
||||
(post mig ~079) or, legacy-style, `duration_value = 0`.
|
||||
- "**Anchoring**" refers to which event the period runs from. paliad
|
||||
models this via `parent_id` (chain anchor) or `anchor_alt` (e.g.
|
||||
`priority_date`); a NULL parent_id with non-zero duration means the
|
||||
deadline runs from the user-supplied trigger date.
|
||||
|
||||
### 1.3 Hard constraint: "no fabricated provisions"
|
||||
|
||||
Where I'm not 100% sure of a citation (because the youpc law DB only
|
||||
covers UPC + EPC, not PatG / ZPO, and my web-fetch coverage of
|
||||
PatG / ZPO is partial), I flag the finding as **"needs lawyer review"**
|
||||
in §9 rather than asserting a fix. Five PatG / ZPO findings carry that
|
||||
tag.
|
||||
|
||||
---
|
||||
|
||||
## §2. Current state inventory (per jurisdiction)
|
||||
|
||||
### 2.1 UPC
|
||||
|
||||
9 active types, 67 rules. `upc.ccr.cfi` is an alias proceeding that
|
||||
holds zero rules — it points at `upc.inf.cfi` rules under the
|
||||
`with_ccr` flag.
|
||||
|
||||
| Code | Name | Rule count | Audited against |
|
||||
|---|---|---:|---|
|
||||
| `upc.inf.cfi` | Verletzungsverfahren | 15 | RoP 19, 23, 25, 29.a-e, 30, 32, 151, 220.1(a) |
|
||||
| `upc.rev.cfi` | Nichtigkeitsverfahren | 17 | RoP 19, 32, 42, 43.3, 49.1, 49.2.a, 49.2.b, 51, 52, 56.1/3/4, 220.1(a) |
|
||||
| `upc.pi.cfi` | Einstweilige Maßnahmen | 4 | RoP 205, 207, 211 |
|
||||
| `upc.disc.cfi` | Bucheinsicht | 4 | RoP 141, 142.2, 142.3 |
|
||||
| `upc.dmgs.cfi` | Schadensbemessung | 4 | RoP 131.2, 137.2, 139 |
|
||||
| `upc.apl.merits` | Berufung | 8 | RoP 220.1, 224.1.a, 224.2.a, 235.1, 237, 238.1 |
|
||||
| `upc.apl.order` | Berufung gegen Anordnungen | 5 | RoP 220.1(c), 220.2, 220.3, 237, 238.2 |
|
||||
| `upc.apl.cost` | Berufung gegen Kostenentscheidung | 2 | RoP 221.1 |
|
||||
| `upc.ccr.cfi` | Widerklage auf Nichtigkeit (alias) | 0 | — |
|
||||
|
||||
### 2.2 EPA
|
||||
|
||||
3 active types, 23 rules.
|
||||
|
||||
| Code | Name | Rule count | Audited against |
|
||||
|---|---|---:|---|
|
||||
| `epa.grant.exa` | EP-Erteilung | 7 | EPC Art. 93, R.70(1), R.71(3) |
|
||||
| `epa.opp.opd` | EPA Einspruch | 8 | EPC Art. 99(1), 108, 116, 123; R.79(1), R.79(2), R.116(1) |
|
||||
| `epa.opp.boa` | EPA Beschwerde | 8 | EPC Art. 108, 112a; R.116(1); RPBA Art. 12 |
|
||||
|
||||
### 2.3 DPMA
|
||||
|
||||
3 active types, 13 rules.
|
||||
|
||||
| Code | Name | Rule count | Audited against |
|
||||
|---|---|---:|---|
|
||||
| `dpma.opp.dpma` | DPMA Einspruch | 4 | PatG §59(1), §59(3) |
|
||||
| `dpma.appeal.bpatg` | BPatG-Beschwerde | 5 | PatG §73(2), §74 ff. |
|
||||
| `dpma.appeal.bgh` | BGH-Rechtsbeschwerde | 4 | PatG §100, §102 |
|
||||
|
||||
### 2.4 DE (national patent / civil)
|
||||
|
||||
5 active types, 29 rules.
|
||||
|
||||
| Code | Name | Rule count | Audited against |
|
||||
|---|---|---:|---|
|
||||
| `de.inf.lg` | LG-Verletzungsklage | 8 | ZPO §253, §276, §283, §296a, §517, §520(2) |
|
||||
| `de.inf.olg` | OLG-Berufung Verletzung | 7 | ZPO §517, §520(2), §521(2), §524(2) |
|
||||
| `de.inf.bgh` | BGH-Revision Verletzung | 8 | ZPO §544, §548, §551, §554 |
|
||||
| `de.null.bpatg` | BPatG-Nichtigkeitsklage | 10 | PatG §81 ff., §82, §83 |
|
||||
| `de.null.bgh` | BGH-Nichtigkeitsberufung | 6 | PatG §110, §111 / ZPO ref via §117 PatG |
|
||||
|
||||
### 2.5 Cross-cutting: cascade vs proceeding-tree coverage
|
||||
|
||||
The cascade layer (`paliad.event_categories` + `…_concepts` +
|
||||
`paliad.deadline_concepts`) carries 56 concept "nouns" and ~153
|
||||
cascade-leaf → concept mappings. **9 concepts are orphans** (carry
|
||||
zero rules, so the cascade card dead-ends): `counterclaim-for-revocation`,
|
||||
`schriftsatznachreichung`, `versaeumnisurteil-einspruch`,
|
||||
`weiterbehandlung`, `wiedereinsetzung`, `notice-of-defence-intention`,
|
||||
plus 3 more. Inventory and recommendations live in
|
||||
`docs/audit-fristen-logic-2026-05-13.md` §3.4 — this audit covers only
|
||||
the proceeding-tree side.
|
||||
|
||||
---
|
||||
|
||||
## §3. Findings — Missing rules (statute defines, paliad doesn't)
|
||||
|
||||
### 3.1 UPC RoP — 21 missing rules (out of ~25 flagged 2026-05-08, 4 closed by mig 095)
|
||||
|
||||
Notation: ★★★ every case, ★★ common, ★ specialist. Verbatim RoP text
|
||||
sampled from youpc `data.laws_contents` (law_type=`UPCRoP`, lang=en).
|
||||
|
||||
| RoP § | Period | Trigger | Freq | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **R.20.2** | 14 days | Service of Preliminary Objection | ★ | Reply to PO. Companion to R.19 (which mig 095 added). Without R.20.2 the PO branch is half-modelled. |
|
||||
| **R.118.4** | 2 months | Final decision on validity served | ★★ | Application for orders consequential on validity. Common after central-division revocation. |
|
||||
| **R.118.5** | n/a UPC | n/a | n/a | UPC has no Versäumnisurteil-Einspruch; closest is R.355 (review of contumacy). |
|
||||
| **R.144** | 0 (anchor) | Final decision on damages quantum | ★ | UPC_DAMAGES tree end-row missing. |
|
||||
| **R.155** | 1mo / 14d | Cost-decision opposition chain | ★ | UPC_COST_APPEAL only has the leave-to-appeal step; no Defence-to-cost-app row. |
|
||||
| **R.197.3** | 30 days | Saisie order served on respondent | ★ | Review application. Trigger event 65 exists; no rule attached. |
|
||||
| **R.198** | 31 calendar days **OR 20 working days, whichever is longer** | Saisie executed | ★ | Start proceedings on the merits. Blocked on `working_days` + `combine='max'` primitives (see §7 + §9). |
|
||||
| **R.207.6.a** | 14 days | Notification of deficiency in PI application | ★★ | Registry correction. |
|
||||
| **R.207.9** | 6 months | PI filed | ★ | Renewal of protective letter. |
|
||||
| **R.213** | 31 days OR 20 working days | PI granted | ★★ | Same arithmetic gap as R.198. |
|
||||
| **R.109.1** | 1 month **before** | Oral hearing date | ★★ | Simultaneous translation request. `timing='before'` schema supported but no rule populates it (see §7 cross-cutting). |
|
||||
| **R.109.4** | 2 weeks **before** | Oral hearing date | ★★ | Interpreter cost notification. `timing='before'`. |
|
||||
| **R.109.5** | 2 weeks after | Order of judge-rapporteur to lodge translations | ★★ | trigger event 113 exists; no rule. |
|
||||
| **R.224.2.b** | 15 days | Order under R.220.1(c) or decision under R.220.2/221.3 served | ★★ | Grounds-on-orders track. `upc.apl.order` has appeal-itself but no separate grounds row. Verified verbatim against `UPCRoP.224.2.b` (youpc DB). |
|
||||
| **R.229.2** | 14 days | Notification of appeal-deficiency | ★ | Registry correction in appeal context. |
|
||||
| **R.235.2** | 15 days | Statement of grounds (orders track) served | ★★ | Verified verbatim against `UPCRoP.235.2` (youpc DB): *"Within 15 days of service of grounds of appeal pursuant to Rule 224.2(b), any other party … may lodge a Statement of response"*. `upc.apl.order` has no standalone response row. |
|
||||
| **R.245.1** | 2 months | Final decision served | ★ | Application for rehearing. |
|
||||
| **R.245.2.a** | 2 months | Discovery of fundamental defect (or final decision service, whichever is later) | ★ | Outer cap 12mo. Needs multi-anchor + `max-of-two-anchors` arithmetic. |
|
||||
| **R.245.2.b** | 2 months | Discovery of criminal offence (or final decision service, whichever is later) | ★ | Same shape as 245.2.a. |
|
||||
| **R.262.2** | 14 days | Receipt of opposing party's confidentiality application | ★★ | Daily occurrence in HLC infringement work. Trigger event 25 exists; no rule. |
|
||||
| **R.320** | 2 months (cap 12 mo) | Wegfall des Hindernisses (Wiedereinsetzung) | ★★ | Cascade card exists (mig 063) but no proceeding-tree rule computes the deadline. Bridges proceedings → no obvious home in any one tree. |
|
||||
| **R.321.3** | 10 days | Preliminary objection referral to central division | ★ | |
|
||||
| **R.333.2** | 15 days | Case-management order served | ★★ | Review-of-CMO. Routine in busy LDs. |
|
||||
| **R.353** | 1 month | Decision / order delivered | ★ | Rectification application. |
|
||||
| **DNI: R.63 / R.67.1 / R.69.1 / R.69.2** | 0 / 2mo / 1mo / 1mo | DNI cascade | ★ | No UPC_DNI proceeding_type exists. Fringe at HLC (zero published filings in 2026-Q1 per May 8 audit). |
|
||||
| **Registry-correction family: R.16.3.a, R.27.2, R.89.2, R.253.2** | 14 days each | Various deficiency notifications | ★ | All same 14-day duration; different trigger codes. Most natural home is cascade not proceeding-tree (see audit-fristenrechner-completeness-2026-04-30.md §3.1). |
|
||||
|
||||
**Closed since May 8 audit (verified by SQL):**
|
||||
- ✅ R.19 Preliminary Objection on UPC_INF — `upc.inf.cfi.prelim`, 1mo, RoP.019.1, flag-gated `with_po` — mig 095.
|
||||
- ✅ R.19 Preliminary Objection on UPC_REV — `upc.rev.cfi.prelim`, 1mo, RoP.019.1, flag-gated `with_po` — mig 095 (cites R.19 i.V.m. R.46).
|
||||
- ✅ R.220.1(a) merits-appeal spawn on UPC_INF — `upc.inf.cfi.appeal_spawn`, 2mo, is_spawn=true → upc.apl.merits — mig 095.
|
||||
- ✅ R.220.1(a) merits-appeal spawn on UPC_REV — `upc.rev.cfi.appeal_spawn`, 2mo, is_spawn=true → upc.apl.merits — mig 095.
|
||||
|
||||
### 3.2 EPC Implementing Regulations — 4 missing rules
|
||||
|
||||
| EPC ref | Period | Trigger | Freq | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **EPC R.135 (Weiterbehandlung)** | 2 months | Notification of loss of rights | ★★ | Concept `weiterbehandlung` exists in cascade (orphan); no rule. Applies broadly across `epa.grant.exa` and `epa.opp.opd`. |
|
||||
| **EPC R.99(2) / Art. 121** | 2 months | Loss-of-rights notification (further processing) | ★★ | Same family as R.135. |
|
||||
| **EPC Art. 112a(4)** | 2 months / 1 month | Discovery of grounds for review / decision served (whichever later) | ★ | paliad has `epa.opp.boa.r106` (2 months, parent=entsch2) — but the rule doesn't model the "whichever later" outer cap (12 months from decision per Art. 112a(4)). |
|
||||
| **EPC Art. 99(1) — opposition fee paid** | 9 months (no extension) | Mention of grant in Patentblatt | ★★★ | `epa.opp.opd.frist` IS modelled correctly at 9 months. **Note however:** the rule is on `epa.opp.opd` but the *trigger* is opposition-fee-paid (per Art. 99(1) S.2 — "Notice of opposition shall not be deemed to have been filed until the opposition fee has been paid"). Not a gap, but a documentation note. |
|
||||
|
||||
### 3.3 PatG / ZPO — 5 missing rules
|
||||
|
||||
| Citation | Period | Trigger | Freq | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **PatG §123 (Wiedereinsetzung)** | 2 months | Wegfall des Hindernisses (cap 1 year) | ★★ | Cascade concept `wiedereinsetzung` exists; no rule on any DE/DPMA proceeding tree. Same modelling problem as UPC R.320 — bridges proceedings. |
|
||||
| **ZPO §339 (Versäumnisurteil-Einspruch)** | 2 weeks | Service of default judgment | ★ | Cascade concept `versaeumnisurteil-einspruch` orphan. |
|
||||
| **ZPO §544 — Nichtzulassungsbeschwerde-Begründung** | 2 months | Service of OLG-Urteil (NB: NOT from filing of NZB) | ★★ | `de.inf.bgh.nzb_begr` lists `DE.ZPO.544.4`, duration 2mo, parent=urteil_olg — **modelled correctly**. Listed here only to flag that the *parent anchoring* differs from `de.inf.lg.beruf_begr` which is wrong (see §7.1). |
|
||||
| **ZPO §283 (Schriftsatznachreichung) / §296a** | court-set | post-Verhandlung schriftsatzfrist | ★ | Cascade concept `schriftsatznachreichung` orphan. Court-set period — modelling as `is_court_set=true, duration=0` would suffice. |
|
||||
| **PatG §17(2) GebrMG / §18 GebrMG** | 1 month (Beschwerdefrist) | DPMA-Beschluss | ★ | Out of scope per head's confirmation (no GebrMG-rooted proceeding_type yet). Listed to confirm the deliberate gap. |
|
||||
|
||||
### 3.4 DPMA — 0 missing rules
|
||||
|
||||
DPMA coverage is shallow but not gappy. The 3 active types (opposition,
|
||||
BPatG-Beschwerde, BGH-Rechtsbeschwerde) cover the statutory steps. The
|
||||
problems here are **citation drift** (§4.4) and **anchor modeling**
|
||||
(§7.4) rather than missing rules.
|
||||
|
||||
---
|
||||
|
||||
## §4. Findings — Misattributed legal source
|
||||
|
||||
### 4.1 UPC RoP citation drift (5 still live from May 8)
|
||||
|
||||
| Rule | Live `rule_code` | Live `legal_source` | Should be | Source verified |
|
||||
|---|---|---|---|---|
|
||||
| `upc.apl.merits.notice` | `RoP.220.1` | `UPC.RoP.220.1` | `RoP.224.1.a` / `UPC.RoP.224.1.a` | `UPCRoP.224.1.a` youpc DB |
|
||||
| `upc.apl.merits.grounds` | `RoP.220.1` | `UPC.RoP.220.1` | `RoP.224.2.a` / `UPC.RoP.224.2.a` | `UPCRoP.224.2.a` |
|
||||
| `upc.apl.merits.response` | `null` | `null` | `RoP.235.1` / `UPC.RoP.235.1` | `UPCRoP.235.1` |
|
||||
| `upc.rev.cfi.reply` | `null` | `null` | `RoP.051` / `UPC.RoP.51.p1` | `UPCRoP.051.p1` |
|
||||
| `upc.rev.cfi.rejoin` | `null` | `null` | `RoP.052` / `UPC.RoP.52.p1` | `UPCRoP.052.p1` |
|
||||
|
||||
Note on cascade vs proceeding-tree drift on R.220.3 anchoring is in
|
||||
`docs/audit-upc-rop-deadlines-2026-05-08.md` §5.4b — unchanged here.
|
||||
|
||||
### 4.2 UPC RoP citation drift on Rule 49.1 format (1 still live)
|
||||
|
||||
| Rule | Live `rule_code` | Should be |
|
||||
|---|---|---|
|
||||
| `upc.rev.cfi.defence` | `RoP.49.1` | `RoP.049.1` (canonical zero-padded form used by all other UPC rules) |
|
||||
|
||||
### 4.3 DPMA — 3 mis-attributed citations
|
||||
|
||||
| Rule | Live citation | Problem | Verified |
|
||||
|---|---|---|---|
|
||||
| `dpma.opp.dpma.erwiderung` | `§ 59 PatG` / `DE.PatG.59.3` | §59(3) PatG addresses *Anhörung*, not a 4-month response period. No statutory Erwiderungsfrist exists in §59. The 4-month figure is DPMA-internal practice. | WebFetch [gesetze-im-internet.de/patg/__59.html](https://www.gesetze-im-internet.de/patg/__59.html) 2026-05-25 |
|
||||
| `dpma.appeal.bpatg.begruendung` | `§ 75 PatG` / `DE.PatG.75.1` | §75 PatG is exclusively about *aufschiebende Wirkung* (suspensive effect). It does not establish any Begründungsfrist. No fixed Begründungsfrist for BPatG-Beschwerde exists in PatG §§73-80 — it is set by the BPatG in the individual case. | WebFetch [gesetze-im-internet.de/patg/__75.html](https://www.gesetze-im-internet.de/patg/__75.html) + [§73](https://www.gesetze-im-internet.de/patg/__73.html) 2026-05-25 |
|
||||
| `dpma.appeal.bpatg.beschwerde` | `§ 73 PatG` / `DE.PatG.73.2` | §73 contains the 1-month deadline correctly; the `.2` subscript however refers to §73(2) which is about Beschwerdebefugnis — the *Frist* is in §73(2) S.4 ("Die Beschwerdefrist beträgt einen Monat …"). Citation should be `DE.PatG.73.2.s4` or simply `DE.PatG.73.2`. **Borderline — flag, not a hard bug.** | gesetze-im-internet.de |
|
||||
|
||||
### 4.4 DE patent / civil — 4 mis-attributed citations
|
||||
|
||||
| Rule | Live citation | Problem | Verified |
|
||||
|---|---|---|---|
|
||||
| `de.null.bpatg.erwidg` | `§ 82 PatG` / `DE.PatG.82.1` | §82(1) is the 1-month *Erklärungsfrist* ("sich darüber zu erklären"); the 2-month full *Klageerwiderung* is in §82(3). Citation should be `DE.PatG.82.3`. Duration (2 months) is correct. | WebFetch [§82](https://www.gesetze-im-internet.de/patg/__82.html) 2026-05-25 |
|
||||
| `de.null.bpatg.replik_klaeger` | `§ 83 PatG` / `DE.PatG.83.2` | §83(2) is about the *Hinweisbeschluss* form; the Replik / Schriftsatz windows fall under §83(2) S.3 (Reaktion auf Hinweis). Citation OK at section level but ambiguous. **Borderline — flag, not a hard bug.** | gesetze-im-internet.de |
|
||||
| `de.null.bgh.begruendung` | `§ 111 PatG` / `DE.PatG.111.1` | §111 PatG defines the *Grounds* of Berufung (Verletzung des Bundesrechts), not a Begründungsfrist. The 3-month figure is supplied via §117 PatG → ZPO §520(2). Citation should be `DE.ZPO.520.2` (the actual time-limit source). | WebFetch [§111](https://www.gesetze-im-internet.de/patg/__111.html) 2026-05-25 |
|
||||
| `de.null.bgh.erwiderung` | `§ 111 PatG` / `DE.PatG.111.3` | §111 has no Erwiderungsfrist clause. The actual Erwiderungsfrist for BGH-Nichtigkeitsberufung is set by the court per §117 PatG → ZPO §521(2) (court-discretionary). Duration (2 months) is approximate — typical court-set period is 2 months but it's not fixed. **Should be modelled as court-set.** | WebFetch [§111](https://www.gesetze-im-internet.de/patg/__111.html) + ZPO §521 2026-05-25 |
|
||||
|
||||
### 4.5 EPA — 1 mis-attributed citation
|
||||
|
||||
| Rule | Live citation | Problem |
|
||||
|---|---|---|
|
||||
| `epa.opp.opd.erwidg` | `R. 79(1) EPÜ` / `EU.EPC-R.79.1` | Duration (4 months) is correct as the *typical* EPO-set period under the 2016 streamlined-opposition guidelines, but **R.79(1) does not specify a fixed period** — the Opposition Division sets it. The 4 months is administrative practice (EPO Guidelines D-IV, 5.2). Should be modelled as court-set with 4 months as the default-display value. |
|
||||
|
||||
---
|
||||
|
||||
## §5. Findings — Wrong period (statute says X, paliad says Y)
|
||||
|
||||
| Rule | Live period | Statutory period | Source | Freq |
|
||||
|---|---|---|---|---|
|
||||
| **`upc.rev.cfi.defence`** | 3 months | **2 months** | RoP.049.1: *"The defendant shall lodge a Defence to revocation within two months of service of the Statement for revocation."* — verified verbatim from `UPCRoP.049.1` (youpc DB). Flagged 2026-05-08; still live. | ★★★ |
|
||||
| **`upc.rev.cfi.rejoin`** | 2 months | **1 month** | RoP.052: *"Within one month of the service of the Reply the defendant may lodge a Rejoinder to the Reply to the Defence to revocation"* — verified verbatim from `UPCRoP.052.p1`. Flagged 2026-05-08; still live. | ★★★ |
|
||||
| **`upc.apl.merits.response`** | 2 months | **3 months** | RoP.235.1: *"Within three months of service of the Statement of grounds of appeal pursuant to Rule 224.2(a), any other party … may lodge a Statement of response"* — verified verbatim from `UPCRoP.235.1`. New finding — May 8 audit recorded the duration as 3 months but the live row has always been 2 (migration 012:153 originally seeded 2). | ★★★ |
|
||||
| **`upc.pi.cfi.response`** | 0 / "court-set" (`is_court_set=false`, `duration=0`, `parent_id=NULL`) | court-set, judge-discretion under R.211.2 | RoP.211.2 — judge sets the inter-partes hearing date. Modelling is half-broken: `duration=0` with `parent_id=NULL` makes the calculator treat this as a root anchor rather than a court-set placeholder. Should set `is_court_set=true` and chain `parent_id=app`. | ★★ |
|
||||
|
||||
(All other rules audited have correct durations.)
|
||||
|
||||
---
|
||||
|
||||
## §6. Findings — Wrong party
|
||||
|
||||
No clear party mis-assignments found in the live data. Two notes worth
|
||||
recording, not bugs:
|
||||
|
||||
- `upc.inf.cfi.app_to_amend` carries `primary_party='claimant'`. The
|
||||
defendant in an INF case is the alleged infringer; the patent
|
||||
proprietor (=claimant) is who would file an Application to Amend
|
||||
the patent. **Correct.** Listed here only because R.30 reads "the
|
||||
defendant" in some summaries — those refer to the claimant of the
|
||||
CCR (= defendant of the INF), which loops back to the same person
|
||||
who is the INF-claimant / patent-proprietor.
|
||||
- `dpma.opp.dpma.erwiderung` carries `primary_party='defendant'`. In an
|
||||
EPA-style opposition, the patent proprietor is the "defendant" of the
|
||||
opposition. Consistent with EPA convention. **Correct.**
|
||||
|
||||
---
|
||||
|
||||
## §7. Findings — Wrong sequencing / anchoring
|
||||
|
||||
### 7.1 `de.inf.lg.beruf_begr` chains parent = `berufung`, should anchor on `urteil` directly
|
||||
|
||||
| Live | Per ZPO §520(2) |
|
||||
|---|---|
|
||||
| `de.inf.lg.beruf_begr.parent_id = de.inf.lg.berufung`, `duration = 2 months` → effective end = trigger + 1mo (Berufung) + 2mo = **3 months** after Urteil service | "Die Frist für die Berufungsbegründung beträgt zwei Monate. Sie beginnt mit der Zustellung des in vollständiger Form abgefassten Urteils" → **2 months** after Urteil service |
|
||||
|
||||
Verified verbatim via WebFetch
|
||||
[gesetze-im-internet.de/zpo/__520.html](https://www.gesetze-im-internet.de/zpo/__520.html)
|
||||
2026-05-25.
|
||||
|
||||
The companion `de.inf.olg.begruendung` is **correct** — parent =
|
||||
`urteil_lg`, 2mo, so end = Urteil + 2mo. Same statute, two paliad
|
||||
rules, two different anchorings: this is a real bug in `de.inf.lg`.
|
||||
|
||||
### 7.2 `de.inf.lg.replik` and `de.inf.lg.duplik` have `parent_id = NULL`
|
||||
|
||||
This is the bug head flagged. Live data:
|
||||
|
||||
| submission_code | name | duration | parent_id | sequence_order |
|
||||
|---|---|---|---|---|
|
||||
| `de.inf.lg.klage` | Klageerhebung | 0 mo | NULL | 0 |
|
||||
| `de.inf.lg.anzeige` | Anzeige Verteidigungsbereitschaft | 2 wk | `de.inf.lg.klage` | 10 |
|
||||
| `de.inf.lg.erwidg` | Klageerwiderung | 6 wk | `de.inf.lg.klage` (court-set=true post mig 095) | 20 |
|
||||
| **`de.inf.lg.replik`** | Replik | **4 wk** | **NULL** | 30 |
|
||||
| **`de.inf.lg.duplik`** | Duplik | **4 wk** | **NULL** | 40 |
|
||||
| `de.inf.lg.termin` | Haupttermin | 0 mo | NULL (court-set) | 50 |
|
||||
| `de.inf.lg.urteil` | Urteil | 0 mo | NULL (court-set) | 60 |
|
||||
| `de.inf.lg.berufung` | Berufungsfrist | 1 mo | NULL | 70 |
|
||||
| `de.inf.lg.beruf_begr` | Berufungsbegründung | 2 mo | `de.inf.lg.berufung` | 80 |
|
||||
|
||||
With `parent_id = NULL` the calculator anchors Replik on the
|
||||
triggerDate (= Klageerhebung), and same for Duplik. So both render
|
||||
"4 Wochen ab Klageerhebung" — i.e. before the Klageerwiderung is
|
||||
even due. Correct chain should be:
|
||||
|
||||
- `replik.parent_id = de.inf.lg.erwidg`, with `is_court_set = true` (richterliche Frist § 276(1) S.2 / § 283 ZPO — typ. 4 weeks default)
|
||||
- `duplik.parent_id = de.inf.lg.replik`, same shape
|
||||
|
||||
Both rules lack `legal_source` and `rule_code`, which is consistent
|
||||
with them being court-set Schriftsatzfristen (no statutory clamp).
|
||||
Recommendation in §10.
|
||||
|
||||
### 7.3 `upc.apl.merits.grounds` has `parent_id = NULL`
|
||||
|
||||
This anchors Grounds on the user-supplied trigger date (=Entscheidung
|
||||
service). **Correct** behaviour per RoP.224.2.a: *"within four months
|
||||
of service of a decision referred to in Rule 220.1(a) and (b)"*.
|
||||
|
||||
If `parent_id` were set to `upc.apl.merits.notice` (as the May 8 audit
|
||||
hypothesised), the chain would compound (1-day notice + 4mo grounds =
|
||||
~4mo + 1 day), accidentally landing near the right end-date for the
|
||||
common case but wrong by up to 2 months in the edge case (when notice
|
||||
is filed early). **No fix needed; document the intent.** (This is
|
||||
the change the May 8 audit recommended; it was applied in mig 097 or
|
||||
earlier.)
|
||||
|
||||
### 7.4 DPMA Pathway-A anchors are partially modelled
|
||||
|
||||
- `dpma.appeal.bgh.begruendung` chains parent = `rechtsbeschwerde`
|
||||
(1mo + 1mo = 2mo from BPatG-Entscheidung). Per PatG §102 the
|
||||
Rechtsbeschwerdebegründungsfrist is 1 month from filing of the
|
||||
Rechtsbeschwerde — **correct**.
|
||||
- `dpma.appeal.bpatg.begruendung` chains parent = `beschwerde`
|
||||
(1mo + 1mo = 2mo from DPMA-Entscheidung). **No statutory basis for
|
||||
the 1-month figure** (see §4.3). Should be court-set.
|
||||
|
||||
### 7.5 EPA grant timeline — `epa.grant.exa.r71_3` and `.approval` have `parent_id = NULL`
|
||||
|
||||
Live:
|
||||
|
||||
| Rule | Duration | parent_id | Issue |
|
||||
|---|---|---|---|
|
||||
| `epa.grant.exa.r71_3` | 0 mo | NULL | Should chain on `exam_req` (after examination request is granted, EPO issues R.71(3) communication). NULL parent + 0 duration = root anchor at trigger date — works only if user enters the R.71(3) date as trigger; doesn't compose with the rest of the tree. |
|
||||
| `epa.grant.exa.approval` | 4 mo | NULL | Per R.71(3) approval period: 4 months from notification. **Anchor should be `r71_3`**, not NULL. As-is, "Zustimmung + Übersetzung" appears as a free-standing 4-mo-from-trigger row that has nothing to do with the rest of the timeline. |
|
||||
|
||||
### 7.6 Summary
|
||||
|
||||
| # | Rule | Bug |
|
||||
|---|---|---|
|
||||
| 1 | `de.inf.lg.beruf_begr` | parent should be NULL (anchored on Urteil-trigger) not `berufung` — off by 1 month, ★★★ |
|
||||
| 2 | `de.inf.lg.replik` | parent should be `erwidg` not NULL, ★★★ |
|
||||
| 3 | `de.inf.lg.duplik` | parent should be `replik` not NULL, ★★★ |
|
||||
| 4 | `dpma.appeal.bpatg.begruendung` | should be court-set; current 1-month period has no statutory basis, ★★ |
|
||||
| 5 | `dpma.appeal.bpatg.beschwerde` parent is `entscheidung` — OK, just a citation issue (§4.3) | (citation only) |
|
||||
| 6 | `epa.grant.exa.r71_3` parent | should chain on `exam_req`, ★ |
|
||||
| 7 | `epa.grant.exa.approval` parent | should chain on `r71_3`, ★ |
|
||||
| 8 | `upc.pi.cfi.response` | court-set placeholder with `parent_id=NULL` and `is_court_set=false` — should chain on `app` with `is_court_set=true`, ★★ |
|
||||
|
||||
---
|
||||
|
||||
## §8. Findings — Duplicates
|
||||
|
||||
No genuine duplicates. The closest cases:
|
||||
|
||||
- `upc.inf.cfi.reply` + `upc.inf.cfi.def_to_ccr` both fire at 2mo after
|
||||
`sod` under `with_ccr`. They cover different actions (Reply to SoD
|
||||
vs. Defence to CCR + Reply to SoD combined) per RoP.029.a vs .b.
|
||||
**Not a duplicate** — distinct rule codes.
|
||||
- `upc.rev.cfi.reply` (2mo, no rule_code) and the older `REV.rev_reply`
|
||||
on the archived litigation type — the archived type is hidden
|
||||
(`pt.is_active = false`) so this isn't a duplicate the user sees.
|
||||
Recommendation in §10 to drop the archived corpus once mig 093's
|
||||
audit window closes.
|
||||
- `epa.opp.boa.r106` (Art. 112a review) appears only on
|
||||
`epa.opp.boa`, not on `epa.opp.opd` — correct, since Art. 112a
|
||||
review is only available against a Boards-of-Appeal decision.
|
||||
|
||||
---
|
||||
|
||||
## §9. Ambiguities — decisions m needs to make
|
||||
|
||||
These are not bugs the coder can fix. They are judgement calls about
|
||||
how to model the law.
|
||||
|
||||
### 9.1 Court-set vs fixed-period for richterliche Fristen
|
||||
|
||||
The cleanest source-of-truth for these is "no statutory duration —
|
||||
court sets the period in the individual case." Modelling them as a
|
||||
fixed period with a wrong citation is the bug pattern we keep finding:
|
||||
|
||||
- `dpma.opp.dpma.erwiderung` (4 mo) — DPMA practice, not §59 PatG.
|
||||
- `dpma.appeal.bpatg.begruendung` (1 mo) — no statutory basis.
|
||||
- `de.inf.olg.erwiderung` (1 mo, §521(2)) — §521(2) is explicitly
|
||||
discretionary ("Der Vorsitzende oder das Berufungsgericht **kann**
|
||||
der Gegenpartei eine Frist … bestimmen"). Verified WebFetch
|
||||
[gesetze-im-internet.de/zpo/__521.html](https://www.gesetze-im-internet.de/zpo/__521.html)
|
||||
2026-05-25.
|
||||
- `de.null.bgh.erwiderung` (2 mo, "§111(3) PatG") — court-set per §117
|
||||
PatG → ZPO §521(2).
|
||||
- `de.null.bpatg.duplik` (1 mo, §83 PatG) — court-set; the 1-month
|
||||
default is BPatG practice.
|
||||
- `de.inf.lg.replik`, `.duplik` (4 wk each) — court-set per
|
||||
§283 / §296a ZPO + §276(1) S.2.
|
||||
- `epa.opp.opd.erwidg` (4 mo, "R.79(1)") — EPO-set per Guidelines.
|
||||
|
||||
**Question (Q1):** Should paliad continue to display these with a
|
||||
default duration but flag them as "richterliche Frist — vom Gericht
|
||||
festgesetzt", OR should they all flip to `is_court_set=true,
|
||||
duration=0` and force the user to enter the actual court-set date?
|
||||
|
||||
Head's 2026-05-25 13:13 signal confirms: m's preference is that "Frist
|
||||
vom Gericht bestimmt" be flagged as needing case-by-case anchoring,
|
||||
not displayed as a fixed period. So default answer = flip to
|
||||
`is_court_set=true` and keep the typical period as the *Default*
|
||||
display value (the calculator already supports this since the
|
||||
mig 095 / `de.inf.lg.erwidg` patch). But the trade-off is a UX
|
||||
regression: most users will not enter the actual court-set date
|
||||
and the timeline will then show "vom Gericht bestimmt" everywhere.
|
||||
|
||||
### 9.2 R.198 / R.213 "31 days OR 20 working days, whichever is longer"
|
||||
|
||||
Two RoP rules need a primitive paliad doesn't have:
|
||||
- A `working_days` duration unit (counts business-day arithmetic via
|
||||
the holiday service).
|
||||
- A `combine = 'max'` operator that compares two durations and picks
|
||||
the later end-date.
|
||||
|
||||
**Question (Q2):** Implement the primitive (~120 LoC migration + ~80 LoC
|
||||
Go), or document both rules as "manual calculation required, see RoP"
|
||||
in the UI? Real R.198 / R.213 cases are rare (saisie + PI). The May 8
|
||||
audit suggested deferring; pauli's 2026-05-13 audit §7.1 made the
|
||||
case for adding `combine_op` as part of a broader Pipeline A/C merge.
|
||||
|
||||
### 9.3 R.245.2 rehearing "whichever is later" trigger
|
||||
|
||||
R.245.2.a/b: deadline 2 months from final decision OR from defect
|
||||
discovery, whichever is *later*. Plus outer cap 12 months. Needs:
|
||||
|
||||
- Multi-anchor trigger event (user supplies 2 dates).
|
||||
- `combine = 'max'` between anchors.
|
||||
- Outer-cap arithmetic (separate concept from duration).
|
||||
|
||||
**Question (Q3):** Defer (specialist, vanishingly rare) or build the
|
||||
primitives?
|
||||
|
||||
### 9.4 EPC Art. 112a review — outer cap
|
||||
|
||||
Same shape as R.245.2: 2 months from defect discovery, outer cap 12
|
||||
months from decision. `epa.opp.boa.r106` models the 2-month period
|
||||
but not the cap.
|
||||
|
||||
### 9.5 PatG §123 Wiedereinsetzung calendar arithmetic
|
||||
|
||||
Cascade card (slug `wiedereinsetzung`) exists. The 2mo / 1-year
|
||||
arithmetic anchors on the *missed* deadline, not on a forward-looking
|
||||
event. paliad's `paliad.deadline_rules` schema has no natural shape
|
||||
for this — it would need either a special-case Go helper, or a
|
||||
"backward-from-missed-deadline" mode that no rule today uses.
|
||||
|
||||
**Question (Q4):** Worth modelling? The cascade card already routes
|
||||
the user to the concept; computing the calendar deadline is an
|
||||
incremental win.
|
||||
|
||||
### 9.6 ZPO §339 Versäumnisurteil-Einspruch
|
||||
|
||||
Cascade card orphan. 2 weeks from service of the default judgment.
|
||||
Trivial to add as a `de.inf.lg.einspruch_vu` rule (court-decision
|
||||
anchor + 2wk fixed). **Question (Q5):** Add as a child of
|
||||
`de.inf.lg.urteil` (with `condition_expr={"flag":"with_vu"}`), or
|
||||
as a separate proceeding `de.inf.lg.vu`?
|
||||
|
||||
### 9.7 Litigation-vs-fristenrechner archived corpus
|
||||
|
||||
The 40 rules on `_archived_litigation` (mig 093 retirement holding pen)
|
||||
still occupy the rule table. They're invisible to all UIs.
|
||||
|
||||
**Question (Q6):** Drop them now (data clean-up), or keep until the
|
||||
mig 093 audit window closes formally?
|
||||
|
||||
### 9.8 R.79(2) further-party observations period
|
||||
|
||||
EPC R.79(2) creates a separate notification window for additional
|
||||
opponents. paliad's `epa.opp.opd.r79_further` is modelled as
|
||||
`duration=0, is_bilateral=true`. **Question (Q7):** Is this even worth
|
||||
keeping? Real workflow: EPO sets a separate period in each
|
||||
intervention case. Hard to template.
|
||||
|
||||
### 9.9 R.116(1) EPC oral-proceedings cut-off
|
||||
|
||||
paliad has it as `duration=0, parent_id=entsch` (`epa.opp.opd.r116`) /
|
||||
`parent_id=oral` (`epa.opp.boa.r116`). R.116(1) actually says the
|
||||
EPO sets a "final date for making written submissions" when issuing
|
||||
the summons. So it's a court-set period, not zero-duration.
|
||||
**Question (Q8):** flip to `is_court_set=true` like the §276(1) ZPO
|
||||
fix in mig 095?
|
||||
|
||||
### 9.10 R.131.2 indication of damages period
|
||||
|
||||
paliad models `upc.dmgs.cfi.app` as a 0-duration root anchor (court
|
||||
sets when the damages-determination phase opens, per R.131.2). This
|
||||
is correct shape but means the entire damages tree is unanchored
|
||||
until the user provides the trigger date manually.
|
||||
|
||||
**Question (Q9):** Wire `is_spawn` from `upc.inf.cfi.decision` to
|
||||
`upc.dmgs.cfi.app` (parallel to the mig-095 appeal-spawn)?
|
||||
|
||||
### 9.11 PatG §17 GebrMG / §18 GebrMG
|
||||
|
||||
No GebrMG-rooted proceeding_type exists in paliad. Head confirmed
|
||||
out-of-scope for this audit. **Question (Q10):** Add a `de.gm.lg`
|
||||
proceeding for GebrMG-Löschungsverfahren if HLC sees them?
|
||||
|
||||
### 9.12 Proceeding-tree vs cascade parity
|
||||
|
||||
paliad has 9 cascade-only concepts with `rule_count = 0` (the orphans
|
||||
listed in `audit-fristen-logic-2026-05-13.md` §3.4). The audit-fristen
|
||||
audit covers this; restating here only to note that the parity gap
|
||||
is the largest single source of "the cascade card promises a
|
||||
calculation but doesn't deliver one."
|
||||
|
||||
**Question (Q11):** Same as the audit-fristen Q8 — priority order
|
||||
for the 9 orphan concepts? My ranking: wiedereinsetzung >
|
||||
schriftsatznachreichung > versäumnisurteil-einspruch >
|
||||
weiterbehandlung > rest.
|
||||
|
||||
### 9.13 R.220.3 anchor
|
||||
|
||||
See `audit-upc-rop-deadlines-2026-05-08.md` §5.4b. paliad anchors
|
||||
`upc.apl.order.discretion` on the original order (`order`), but
|
||||
the 15-day clock per RoP.220.3 runs from the refusal-of-leave
|
||||
date (or day-15 fall-back). Off by up to 15 days in the edge case.
|
||||
**Question (Q12):** add an explicit `app_ord.refusal` court-set
|
||||
intermediate node?
|
||||
|
||||
### 9.14 EP_GRANT publish date — priority vs filing
|
||||
|
||||
`epa.grant.exa.publish` correctly has `anchor_alt='priority_date'`.
|
||||
This was open in the May 8 audit and is now closed. **No question —
|
||||
listed to confirm.**
|
||||
|
||||
### 9.15 Cross-proceeding spawn execution
|
||||
|
||||
mig 095 added two `is_spawn=true` rules (`inf.appeal_spawn`,
|
||||
`rev.appeal_spawn` → `upc.apl.merits`). The May 13 audit §1.6 +
|
||||
§6.8 noted spawn execution is half-wired in `projection_service.go`.
|
||||
**Question (Q13):** wire end-to-end now (so the spawned appeal
|
||||
timeline appears in SmartTimeline), or accept the half-wired state?
|
||||
|
||||
---
|
||||
|
||||
## §10. Recommended fixes (prioritised)
|
||||
|
||||
### Tier 0 — hard duration / sequencing / anchor bugs (ship first)
|
||||
|
||||
| # | Rule | Fix | Reason / source | Freq |
|
||||
|---|---|---|---|---|
|
||||
| T0.1 | `upc.rev.cfi.defence` | `duration_value = 2` (was 3), `rule_code = 'RoP.049.1'`, `legal_source = 'UPC.RoP.49.1'` | §5 — every UPC_REV tracked in paliad today computes Defence at wrong month for the last ~3 months | ★★★ |
|
||||
| T0.2 | `upc.rev.cfi.rejoin` | `duration_value = 1` (was 2), `rule_code = 'RoP.052'`, `legal_source = 'UPC.RoP.52.p1'` | §5 — same as T0.1 | ★★★ |
|
||||
| T0.3 | `upc.apl.merits.response` | `duration_value = 3` (was 2), `rule_code = 'RoP.235.1'`, `legal_source = 'UPC.RoP.235.1'` | §5 — every main-track appellate respondent | ★★★ |
|
||||
| T0.4 | `de.inf.lg.beruf_begr` | `parent_id = NULL` (was `de.inf.lg.berufung`) — runs 2 months from triggerDate (Urteil-service) per ZPO §520(2) | §7.1 — every DE-LG-Verletzung appeal | ★★★ |
|
||||
| T0.5 | `de.inf.lg.replik` | `parent_id = de.inf.lg.erwidg`, `is_court_set = true` (richterliche Frist § 276(1) S.2 / § 283 ZPO), keep 4-week default | §7.2 — bug head flagged | ★★★ |
|
||||
| T0.6 | `de.inf.lg.duplik` | `parent_id = de.inf.lg.replik`, `is_court_set = true` | §7.2 | ★★★ |
|
||||
| T0.7 | `upc.rev.cfi.reply` | `rule_code = 'RoP.051'`, `legal_source = 'UPC.RoP.51.p1'` (duration 2mo unchanged) | §4.1 | ★★★ |
|
||||
| T0.8 | `upc.rev.cfi.rejoin` (citation only) | covered in T0.2 | — | — |
|
||||
| T0.9 | `upc.apl.merits.notice` | `rule_code = 'RoP.224.1.a'`, `legal_source = 'UPC.RoP.224.1.a'` (duration unchanged) | §4.1 | ★★ |
|
||||
| T0.10 | `upc.apl.merits.grounds` | `rule_code = 'RoP.224.2.a'`, `legal_source = 'UPC.RoP.224.2.a'` (duration unchanged) | §4.1 | ★★ |
|
||||
| T0.11 | `upc.rev.cfi.defence` rule_code zero-pad | covered in T0.1 | — | — |
|
||||
| T0.12 | `dpma.opp.dpma.erwiderung` | flip to `is_court_set = true`, keep 4-month default-display value, drop the misleading `DE.PatG.59.3` citation (or replace with "DPMA-Richtlinien D-IV 5.2") | §4.3 + §9.1 | ★★ |
|
||||
| T0.13 | `dpma.appeal.bpatg.begruendung` | flip to `is_court_set = true`, drop the `DE.PatG.75.1` citation, keep 1-month default | §4.3 + §9.1 | ★★ |
|
||||
| T0.14 | `de.null.bpatg.erwidg` | citation `DE.PatG.82.3` (was 82.1); duration (2mo) correct | §4.4 | ★★ |
|
||||
| T0.15 | `de.null.bgh.begruendung` | citation `DE.ZPO.520.2` via PatG §117 (was DE.PatG.111.1); duration (3mo) correct | §4.4 | ★★ |
|
||||
| T0.16 | `de.null.bgh.erwiderung` | flip to `is_court_set = true`; citation `DE.ZPO.521.2 via PatG §117` (was DE.PatG.111.3); duration (2mo) becomes default-display | §4.4 + §9.1 | ★★ |
|
||||
| T0.17 | `epa.opp.opd.erwidg` | flip to `is_court_set = true`, keep 4-month default | §4.5 + §9.1 | ★★ |
|
||||
|
||||
**16 hard fixes.** All within the existing schema (no new columns).
|
||||
Each is a single-row UPDATE plus an audit-log entry.
|
||||
|
||||
### Tier 1 — high-value missing rules (★★ / ★★★)
|
||||
|
||||
| # | Rule | Add | Freq |
|
||||
|---|---|---|---|
|
||||
| T1.1 | `upc.inf.cfi.cmo_review` | 15 days from CMO service (R.333.2) | ★★ |
|
||||
| T1.2 | `upc.inf.cfi.confidentiality_response` | 14 days from opp. confidentiality app (R.262.2) | ★★ |
|
||||
| T1.3 | `upc.apl.order.grounds_orders` | 15 days from order service (R.224.2(b)) | ★★ |
|
||||
| T1.4 | `upc.apl.order.response_orders` | 15 days from grounds service (R.235.2) | ★★ |
|
||||
| T1.5 | `upc.inf.cfi.cons_orders` | 2 months from validity decision (R.118.4) | ★★ |
|
||||
| T1.6 | `upc.inf.cfi.rectification` | 1 month from decision (R.353) | ★ |
|
||||
| T1.7 | `upc.pi.cfi.deficiency` | 14 days from PI deficiency notification (R.207.6.a) | ★★ |
|
||||
| T1.8 | `upc.pi.cfi.merits_start` | 31d OR 20wd from PI grant (R.213) — **blocked on Q2** | ★★ |
|
||||
| T1.9 | `upc.inf.cfi.translation_request` | 1 month **before** oral hearing (R.109.1) | ★★ |
|
||||
| T1.10 | `upc.inf.cfi.interpreter_cost` | 2 weeks **before** oral hearing (R.109.4) | ★★ |
|
||||
| T1.11 | `upc.inf.cfi.translations_lodge` | 2 weeks after summons (R.109.5) | ★★ |
|
||||
| T1.12 | `upc.pi.cfi.response` re-anchor | court-set, parent=`app` (currently a broken root) | ★★ |
|
||||
|
||||
**12 rule-adds.** T1.9/.10 are the only `timing='before'` rules in the
|
||||
entire UPC corpus; schema already supports `before` but no rule
|
||||
populates it. Verify the backward-snap-to-working-day logic in
|
||||
`internal/services/deadline_calculator.go` before merging
|
||||
(2026-04-30 audit §5.4 raised the concern).
|
||||
|
||||
### Tier 2 — broader coverage (★ specialist + Wiedereinsetzung family)
|
||||
|
||||
| # | Rule | Add | Notes |
|
||||
|---|---|---|---|
|
||||
| T2.1 | `de.inf.lg.einspruch_vu` | 2 weeks from service of Versäumnisurteil (ZPO §339) | Q5 — proceeding shape decision |
|
||||
| T2.2 | `upc.inf.cfi.wiedereinsetzung` | 2 mo / 1-year-cap from Wegfall des Hindernisses (R.320) | Q4 — needs special arithmetic |
|
||||
| T2.3 | `de.inf.lg.wiedereinsetzung` | 2 mo / 1-year-cap (PatG §123 / ZPO §233 ff.) | Q4 |
|
||||
| T2.4 | `epa.grant.exa.weiterbehandlung` | 2 mo from loss-of-rights notification (EPC R.135) | — |
|
||||
| T2.5 | `upc.inf.cfi.prelim_reply` | 14 days from PO service (R.20.2) | Companion to R.19 (mig 095 added it) |
|
||||
| T2.6 | `upc.apl.order.discretion_anchor` | add explicit `refusal` intermediate node so R.220.3 anchors correctly (Q12) | |
|
||||
| T2.7 | `upc.dmgs.cfi.app` spawn | `is_spawn=true` from `upc.inf.cfi.decision` (Q9) | |
|
||||
| T2.8 | `upc.disc.cfi.app` spawn | same shape as T2.7 | |
|
||||
| T2.9 | `epa.grant.exa.r71_3` re-anchor | parent = `exam_req` (§7.5) | |
|
||||
| T2.10 | `epa.grant.exa.approval` re-anchor | parent = `r71_3` (§7.5) | |
|
||||
| T2.11 | `upc.inf.cfi.appeal_spawn` cross-proc wiring | finish the half-wired spawn execution (Q13) | |
|
||||
|
||||
### Tier 3 — tooling primitives (block multiple rules)
|
||||
|
||||
| # | Primitive | Blocks | Notes |
|
||||
|---|---|---|---|
|
||||
| T3.1 | `duration_unit = 'working_days'` | R.198, R.213 | Schema already accepts the string; add to calculator + UI |
|
||||
| T3.2 | `combine_op = 'max'` | R.198, R.213, R.245.2 | Column already exists per pauli's 2026-05-13 audit |
|
||||
| T3.3 | Multi-anchor "whichever later" trigger | R.245.2.a/b | UI + service work |
|
||||
| T3.4 | Outer-cap modelling (`outer_cap_value` + `outer_cap_unit`) | R.245.2 (12mo), R.320 (12mo), EPC Art.112a(4) (12mo) | Schema add |
|
||||
| T3.5 | "Before"-mode backward snap to working day | R.109.1, R.109.4 | Calculator change (audit-fristenrechner-completeness-2026-04-30.md §5.4) |
|
||||
| T3.6 | Cross-proceeding spawn end-to-end (`is_spawn`) | T2.7, T2.8, T2.11 | Pauli's §6.8 |
|
||||
|
||||
### Tier 4 — out-of-scope until separate prioritisation
|
||||
|
||||
- DNI family (R.63 / R.67.1 / R.69.1 / R.69.2). Zero published filings 2026-Q1.
|
||||
- Registry-correction family (R.16.3.a, R.27.2, R.89.2, R.253.2). Most natural in cascade, not proceeding-tree.
|
||||
- GebrMG (no proceeding_type today).
|
||||
- R.245 rehearing family (specialist).
|
||||
- R.155 cost-decision opposition chain (specialist).
|
||||
- R.144 UPC_DAMAGES tree-end row (cosmetic).
|
||||
- R.79(2) EPC further-parties period (modelling unclear — Q7).
|
||||
|
||||
---
|
||||
|
||||
## §11. Next-step proposals (suggested fix-task slicing)
|
||||
|
||||
The audit identifies **41 distinct actionable items.** Below is a
|
||||
suggested decomposition into fix-tasks that can be assigned
|
||||
independently. Sequence reflects "Wave 0 must precede Wave 1" only
|
||||
where there's a real dependency (most slices are independent).
|
||||
|
||||
### Wave 0 — Tier 0 duration / sequencing / anchor fixes (single fix-task)
|
||||
|
||||
**Proposed task:** `t-paliad-264 — Tier 0 deadline-rule corrections
|
||||
(duration, anchor, citation) from t-paliad-263 audit`
|
||||
|
||||
- 16 row UPDATEs (T0.1–T0.17, deduplicated to 16 distinct rows since
|
||||
T0.8 is covered by T0.2 and T0.11 by T0.1).
|
||||
- One migration file (~120 LoC SQL).
|
||||
- All within existing schema. No new columns.
|
||||
- Idempotent guards on every UPDATE (only fire when the row still has
|
||||
the old value, per the mig 095 convention).
|
||||
- Adds 16 entries to `paliad.deadline_rule_audit` (per the mig 079
|
||||
trigger).
|
||||
- Verification block: `DO $$ … RAISE EXCEPTION …` per mig 095.
|
||||
- **Branch:** `mai/<coder>/t-paliad-264-tier0-deadline-fixes`.
|
||||
- **Owner:** coder.
|
||||
- **Why first:** all 16 affect either calendar correctness (5 hard
|
||||
duration/anchor bugs) or citation correctness (the 11 metadata
|
||||
fixes are what a lawyer would cite-check against). T0.1–T0.6 are
|
||||
user-visible silent wrongs; ship them.
|
||||
|
||||
### Wave 1 — Tier 1 rule additions (single fix-task)
|
||||
|
||||
**Proposed task:** `t-paliad-265 — Tier 1 deadline-rule additions
|
||||
(12 high-frequency rules)`
|
||||
|
||||
- 11 INSERTs + 1 UPDATE re-anchor (T1.12 `upc.pi.cfi.response`).
|
||||
- T1.8 (`upc.pi.cfi.merits_start`) **excluded** — blocked on T3.1/T3.2.
|
||||
- One migration file (~250 LoC SQL).
|
||||
- Add cascade leaves + concepts where needed (each rule should be
|
||||
reachable from Pathway B too).
|
||||
- **Branch:** `mai/<coder>/t-paliad-265-tier1-rule-additions`.
|
||||
- **Owner:** coder. **Legal review:** m must verify each rule before
|
||||
merge (single round of grilling).
|
||||
|
||||
### Wave 2 — Q1 court-set audit decision (separate spike)
|
||||
|
||||
**Proposed task:** `t-paliad-266 — Decide court-set vs fixed-period
|
||||
modelling for richterliche Fristen (Q1 in t-paliad-263 audit)`
|
||||
|
||||
- Inventor / pauli reviews §9.1 with m.
|
||||
- Decision artefact: list of rules to flip vs keep, plus UX guideline
|
||||
for what the timeline displays for `is_court_set=true` rules.
|
||||
- **Owner:** pauli. **m signs off.**
|
||||
|
||||
### Wave 3 — Tier 3 tooling primitives (multi-task)
|
||||
|
||||
Each Tier 3 row is its own task because each touches schema + service +
|
||||
calculator + UI:
|
||||
|
||||
- `t-paliad-267 — working_days unit + combine_op='max' (R.198, R.213)`
|
||||
- `t-paliad-268 — Outer-cap modelling (R.245.2, R.320, Art.112a)`
|
||||
- `t-paliad-269 — Multi-anchor "whichever later" triggers (R.245.2)`
|
||||
- `t-paliad-270 — Backward-snap for `before`-mode rules (R.109.1/.4)`
|
||||
- `t-paliad-271 — Cross-proceeding spawn end-to-end execution`
|
||||
|
||||
Each is foundational for multiple Tier 2 rules; can ship independently.
|
||||
|
||||
### Wave 4 — Tier 2 specialist rules (multi-task, after their primitives land)
|
||||
|
||||
Each Tier 2 row is its own task or batched into 2-3 tasks by topical
|
||||
area:
|
||||
|
||||
- `t-paliad-272 — Wiedereinsetzung / Weiterbehandlung family (T2.2, T2.3, T2.4)` — depends on T3.4 (outer cap).
|
||||
- `t-paliad-273 — UPC follow-on spawns (T2.7, T2.8, T2.11)` — depends on T3.6.
|
||||
- `t-paliad-274 — UPC tail rules (T2.5, T2.6, R.353, etc.)`
|
||||
- `t-paliad-275 — EPA grant timeline re-anchoring (T2.9, T2.10)`.
|
||||
|
||||
### Wave 5 — Concept-layer parity (separate audit)
|
||||
|
||||
The 9 orphan concepts (`audit-fristen-logic-2026-05-13.md` §3.4 + Q11
|
||||
here) need a parallel audit pass to map cascade → rule. Recommend
|
||||
spinning a `t-paliad-276 — Cascade-rule parity audit` task once the
|
||||
above land.
|
||||
|
||||
### Wave 6 — Documentation + retire
|
||||
|
||||
- `t-paliad-277 — Drop `_archived_litigation` proceeding_type` once
|
||||
mig 093's audit window closes (Q6).
|
||||
- `t-paliad-278 — Document Tier 4 deferrals in
|
||||
`docs/feature-roadmap.md`` so the gap-list isn't lost.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — file references
|
||||
|
||||
**Live state queried via Supabase MCP, 2026-05-25 14:00–15:00 UTC:**
|
||||
|
||||
- `paliad.proceeding_types` — 21 active rows (20 fristenrechner + 1
|
||||
archived).
|
||||
- `paliad.deadline_rules` — 132 active + 40 archived rows
|
||||
(`lifecycle_state='published'`).
|
||||
- `paliad.deadline_rule_audit` — diff history.
|
||||
- `data.laws_contents` (youpc) — UPC RoP + EPC verbatim text
|
||||
(`law_type IN ('UPCRoP','EPC')`).
|
||||
|
||||
**paliad migrations consulted:**
|
||||
|
||||
- `internal/db/migrations/012_fristenrechner_rules.up.sql` — original
|
||||
seed.
|
||||
- `internal/db/migrations/043_de_instance_split_proceedings.up.sql`
|
||||
— DE_INF_OLG / DE_INF_BGH split.
|
||||
- `internal/db/migrations/052_event_categories_rop_audit.up.sql`
|
||||
— first RoP audit fix-pass.
|
||||
- `internal/db/migrations/079_*` — `paliad.deadline_rule_audit`
|
||||
trigger.
|
||||
- `internal/db/migrations/091_drop_legacy_rule_columns.up.sql` —
|
||||
cleanup.
|
||||
- `internal/db/migrations/093_retire_litigation_category.up.sql` —
|
||||
archived 40 rules.
|
||||
- `internal/db/migrations/095_fristen_gap_fill.up.sql` — t-paliad-205
|
||||
R.19 + R.220.1(a) gap fill.
|
||||
- `internal/db/migrations/096_proceeding_code_rename.up.sql` — code
|
||||
rename to `<jurisdiction>.<proceeding>.<instance>` form.
|
||||
- `internal/db/migrations/097_legal_citation_backfill.up.sql` —
|
||||
legal_source / rule_code backfill.
|
||||
- `internal/db/migrations/100_ccr_visible_rule.up.sql` —
|
||||
`upc.ccr.cfi` alias.
|
||||
- `internal/db/migrations/104_einspruch_name_and_ccr_priority.up.sql`
|
||||
— Einspruch rename.
|
||||
|
||||
**Companion audits:**
|
||||
|
||||
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — curie /
|
||||
t-paliad-084.
|
||||
- `docs/audit-upc-rop-deadlines-2026-05-08.md` — curie / t-paliad-159.
|
||||
- `docs/audit-fristen-logic-2026-05-13.md` — pauli / t-paliad-157
|
||||
(schema audit, ground-truth on column semantics).
|
||||
- `docs/proposals/fristen-gap-fill-2026-05-18.md` — m's 0.3 decisions
|
||||
that shipped as mig 095.
|
||||
|
||||
**Authoritative source URLs (all verified 2026-05-25):**
|
||||
|
||||
- UPC RoP consolidated 18.05.2023: https://www.unifiedpatentcourt.org/sites/default/files/upc_documents/rop_application_-_consolidated_18_05_2023.pdf
|
||||
- EPC 17th ed.: https://www.epo.org/en/legal/epc/2020/index.html
|
||||
- EPC R.71 (and other Implementing Reg Rules): https://www.epo.org/en/legal/epc/2020/r71.html
|
||||
- PatG: https://www.gesetze-im-internet.de/patg/
|
||||
- §59 https://www.gesetze-im-internet.de/patg/__59.html
|
||||
- §73 https://www.gesetze-im-internet.de/patg/__73.html
|
||||
- §75 https://www.gesetze-im-internet.de/patg/__75.html
|
||||
- §82 https://www.gesetze-im-internet.de/patg/__82.html
|
||||
- §110 https://www.gesetze-im-internet.de/patg/__110.html
|
||||
- §111 https://www.gesetze-im-internet.de/patg/__111.html
|
||||
- ZPO: https://www.gesetze-im-internet.de/zpo/
|
||||
- §520 https://www.gesetze-im-internet.de/zpo/__520.html
|
||||
- §521 https://www.gesetze-im-internet.de/zpo/__521.html
|
||||
- GebrMG: https://www.gesetze-im-internet.de/gebrmg/
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — coverage tally
|
||||
|
||||
| Status | Count | Share |
|
||||
|---|---:|---:|
|
||||
| present-correct | 78 | 59 % |
|
||||
| present-wrong (DURATION) | 3 | 2 % |
|
||||
| present-wrong (anchor/sequence) | 5 | 4 % |
|
||||
| present-wrong (citation only) | 11 | 8 % |
|
||||
| court-set-mismodelled-as-fixed | 6 | 5 % |
|
||||
| **subtotal: still actionable** | **25** | **19 %** |
|
||||
| missing (statute defines, paliad doesn't) | 30 | (gap, vs 132 baseline) |
|
||||
| n/a (RoP / EPC / PatG section creates no time-limit) | 8 | 6 % |
|
||||
| present-correct, no fix needed | (78 above) | |
|
||||
|
||||
**Headline figures for m:**
|
||||
|
||||
- Of the 132 statutory deadlines paliad currently models, **25 carry
|
||||
an actionable bug** (19%). Of those, **5 are user-visible
|
||||
calendar-correctness bugs** (the 3 duration bugs + the 2
|
||||
sequencing/anchor bugs head flagged + me). The other 20 are
|
||||
citation drift or court-set mismodelling — fix-them-quietly
|
||||
category.
|
||||
- An additional **30 statutory deadlines are not modelled at all**
|
||||
(the missing list in §3). Of those, **~12 are ★★★ / ★★ frequency**
|
||||
(Tier 1 in §10); the remaining ~18 are ★ specialist.
|
||||
- The 5 duration / sequencing bugs alone are **the most important
|
||||
takeaway**: every UPC_REV proceeding, every UPC main-track appeal
|
||||
respondent, and every DE-LG-Verletzung timeline tracked in paliad
|
||||
today computes wrong dates.
|
||||
|
||||
End of audit. Awaiting m's review of §9 Q1–Q13 + Tier 0 sign-off
|
||||
before fix-tasks (Wave 0) get cut.
|
||||
@@ -18,6 +18,9 @@ import { renderProjects } from "./src/projects";
|
||||
import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderSubmissionDraft } from "./src/submission-draft";
|
||||
import { renderSubmissionsIndex } from "./src/submissions-index";
|
||||
import { renderSubmissionsNew } from "./src/submissions-new";
|
||||
import { renderEvents } from "./src/events";
|
||||
import { renderDeadlinesNew } from "./src/deadlines-new";
|
||||
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
||||
@@ -46,6 +49,7 @@ import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderAdminBackups } from "./src/admin-backups";
|
||||
import { renderNotFound } from "./src/notfound";
|
||||
|
||||
const DIST = join(import.meta.dir, "dist");
|
||||
@@ -252,6 +256,9 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects-new.ts"),
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/submission-draft.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-index.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-new.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
||||
@@ -285,6 +292,7 @@ async function build() {
|
||||
// skip the re-fetch.
|
||||
join(import.meta.dir, "src/client/paliadin-widget.ts"),
|
||||
join(import.meta.dir, "src/client/admin-paliadin.ts"),
|
||||
join(import.meta.dir, "src/client/admin-backups.ts"),
|
||||
join(import.meta.dir, "src/client/notfound.ts"),
|
||||
],
|
||||
outdir: join(DIST, "assets"),
|
||||
@@ -376,6 +384,9 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
|
||||
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
|
||||
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
|
||||
// Termine entries point at /events?type=… and events.ts re-highlights
|
||||
@@ -408,6 +419,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
|
||||
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||
|
||||
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
|
||||
|
||||
96
frontend/src/admin-backups.tsx
Normal file
96
frontend/src/admin-backups.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Backup Mode admin page (t-paliad-246 / m/paliad#77 Slice A).
|
||||
//
|
||||
// global_admin only — gated by adminGate(...) in handlers.go. Shows the
|
||||
// chronological list of backup runs (one row per kind in
|
||||
// {scheduled, on_demand}) plus a button to kick off an on-demand backup.
|
||||
// Catalog rows + the "run now" action are fetched client-side via
|
||||
// /api/admin/backups.
|
||||
export function renderAdminBackups(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.backups.title">Backups — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/backups" />
|
||||
<BottomNav currentPath="/admin/backups" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.backups.heading">Backups</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.backups.subtitle">
|
||||
Vollständige Snapshots aller Daten — manuell oder zeitgesteuert.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
id="admin-backups-run-btn"
|
||||
type="button"
|
||||
data-i18n="admin.backups.run_now"
|
||||
>
|
||||
Backup jetzt erstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-backups-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="entity-table-wrap">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.backups.col.started">Erstellt</th>
|
||||
<th data-i18n="admin.backups.col.kind">Auslöser</th>
|
||||
<th data-i18n="admin.backups.col.status">Status</th>
|
||||
<th data-i18n="admin.backups.col.requested_by">Angefordert von</th>
|
||||
<th data-i18n="admin.backups.col.size">Größe</th>
|
||||
<th data-i18n="admin.backups.col.rows">Zeilen</th>
|
||||
<th data-i18n="admin.backups.col.actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-backups-tbody">
|
||||
<tr>
|
||||
<td colspan={7} data-i18n="admin.backups.loading">Lade …</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="admin-backups-empty" style="display:none">
|
||||
<p data-i18n="admin.backups.empty">Noch keine Backups vorhanden.</p>
|
||||
</div>
|
||||
|
||||
<p className="tool-footer-note" id="admin-backups-footer">
|
||||
<span data-i18n="admin.backups.footer.note">
|
||||
Geplante Backups werden in einer späteren Slice aktiviert. Manuelle Backups stehen jetzt zur Verfügung.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-backups.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
192
frontend/src/client/admin-backups.ts
Normal file
192
frontend/src/client/admin-backups.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// Backup Mode admin client (t-paliad-246 / m/paliad#77 Slice A).
|
||||
//
|
||||
// Reads /api/admin/backups (chronological list) and wires the
|
||||
// "Backup jetzt erstellen" button to POST /api/admin/backups/run.
|
||||
// Synchronous: the server holds the connection for the duration of
|
||||
// the backup (sub-second at firm-scale today), then returns the new
|
||||
// catalog row inline. No polling needed at v1's data shape; if the
|
||||
// run takes > 5 minutes the handler returns 500 and the UI surfaces
|
||||
// the error.
|
||||
|
||||
interface BackupRow {
|
||||
id: string;
|
||||
kind: "scheduled" | "on_demand";
|
||||
status: "running" | "done" | "failed";
|
||||
requested_by?: string;
|
||||
requested_by_email: string;
|
||||
audit_id?: string;
|
||||
storage_uri?: string;
|
||||
size_bytes?: number;
|
||||
row_counts?: unknown; // jsonb passes through as raw bytes; we don't read it
|
||||
sheet_count?: number;
|
||||
warnings?: unknown;
|
||||
error?: string;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
deleted_at?: string;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
await refreshList();
|
||||
wireRunButton();
|
||||
});
|
||||
|
||||
function wireRunButton(): void {
|
||||
const btn = document.getElementById("admin-backups-run-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = t("admin.backups.running") || "Läuft …";
|
||||
clearFeedback();
|
||||
try {
|
||||
const r = await fetch("/api/admin/backups/run", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({ error: "request failed" }));
|
||||
showFeedback("error", body.error || `HTTP ${r.status}`);
|
||||
return;
|
||||
}
|
||||
// The created row is in the response; refresh the list to land it.
|
||||
await refreshList();
|
||||
showFeedback("success", t("admin.backups.success") || "Backup erfolgreich erstellt.");
|
||||
} catch (e) {
|
||||
showFeedback("error", (e as Error).message || "network error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshList(): Promise<void> {
|
||||
const rows = await fetchJSON<BackupRow[]>("/api/admin/backups?limit=200");
|
||||
const tbody = document.getElementById("admin-backups-tbody") as HTMLTableSectionElement | null;
|
||||
const empty = document.getElementById("admin-backups-empty") as HTMLElement | null;
|
||||
if (!tbody) return;
|
||||
if (!rows || rows.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = "none";
|
||||
tbody.innerHTML = rows.map(renderRow).join("");
|
||||
}
|
||||
|
||||
function renderRow(b: BackupRow): string {
|
||||
const started = formatTimestamp(b.started_at);
|
||||
const kind =
|
||||
b.kind === "scheduled"
|
||||
? t("admin.backups.kind.scheduled") || "Geplant"
|
||||
: t("admin.backups.kind.on_demand") || "Manuell";
|
||||
const status = renderStatus(b);
|
||||
const requestedBy =
|
||||
b.kind === "scheduled" ? "—" : escapeHTML(b.requested_by_email);
|
||||
const size = b.size_bytes != null ? formatBytes(b.size_bytes) : "—";
|
||||
const rows = b.sheet_count != null ? String(b.sheet_count) : "—";
|
||||
const action = renderAction(b);
|
||||
return `<tr>
|
||||
<td>${started}</td>
|
||||
<td>${kind}</td>
|
||||
<td>${status}</td>
|
||||
<td>${requestedBy}</td>
|
||||
<td>${size}</td>
|
||||
<td>${rows}</td>
|
||||
<td>${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderStatus(b: BackupRow): string {
|
||||
switch (b.status) {
|
||||
case "done":
|
||||
return `<span class="status-done">${escapeHTML(t("admin.backups.status.done") || "✓ Fertig")}</span>`;
|
||||
case "running":
|
||||
return `<span class="status-running">${escapeHTML(t("admin.backups.status.running") || "Läuft …")}</span>`;
|
||||
case "failed":
|
||||
const label = t("admin.backups.status.failed") || "✗ Fehlgeschlagen";
|
||||
const tip = b.error ? ` title="${escapeAttr(b.error)}"` : "";
|
||||
return `<span class="status-failed"${tip}>${escapeHTML(label)}</span>`;
|
||||
default:
|
||||
return escapeHTML(b.status);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAction(b: BackupRow): string {
|
||||
if (b.status !== "done" || !b.storage_uri || b.deleted_at) {
|
||||
return "—";
|
||||
}
|
||||
const label = t("admin.backups.download") || "Download";
|
||||
return `<a class="btn-link" href="/api/admin/backups/${encodeURIComponent(b.id)}/file">${escapeHTML(label)}</a>`;
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
async function fetchJSON<T>(url: string): Promise<T | null> {
|
||||
try {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return escapeHTML(iso);
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const mi = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`;
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => {
|
||||
switch (c) {
|
||||
case "&": return "&";
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
case '"': return """;
|
||||
case "'": return "'";
|
||||
default: return c;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return escapeHTML(s);
|
||||
}
|
||||
|
||||
function showFeedback(kind: "success" | "error", text: string): void {
|
||||
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.remove("form-msg-success", "form-msg-error");
|
||||
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
|
||||
el.style.display = "";
|
||||
}
|
||||
|
||||
function clearFeedback(): void {
|
||||
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.style.display = "none";
|
||||
el.textContent = "";
|
||||
el.classList.remove("form-msg-success", "form-msg-error");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initNotes } from "./notes";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
@@ -25,6 +26,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -43,6 +47,10 @@ let project: Project | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
let me: Me | null = null;
|
||||
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
|
||||
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
|
||||
// "Termin bearbeiten" in the withdraw warning modal.
|
||||
let pendingEditMode = false;
|
||||
|
||||
function parseAppointmentID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -207,10 +215,14 @@ function renderHeader() {
|
||||
}
|
||||
|
||||
// Freeze the edit form + delete button while a request is in flight.
|
||||
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
|
||||
// withdraw modal, pendingEditMode unfreezes the form so Save can route
|
||||
// to /edit-entity (which keeps the request pending + merges payload).
|
||||
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
const freeze = isPending && !pendingEditMode;
|
||||
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
|
||||
.forEach((el) => { el.disabled = isPending; });
|
||||
.forEach((el) => { el.disabled = freeze; });
|
||||
}
|
||||
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
|
||||
if (deleteBtn) deleteBtn.disabled = isPending;
|
||||
@@ -263,6 +275,39 @@ async function saveEdit(ev: Event) {
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
// t-paliad-252 — pending-edit mode routes through /edit-entity which
|
||||
// keeps the request pending + merges fields into payload. clear_project
|
||||
// and project_id are NOT in the counter-allowlist (yet) — the requester
|
||||
// can't move projects on a pending request from this surface.
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const editFields = { ...payload };
|
||||
delete editFields.clear_project;
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: editFields }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/appointments/${appointment.id}`);
|
||||
if (fresh.ok) appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
// Exit pending-edit mode so the form re-freezes (still pending).
|
||||
pendingEditMode = false;
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
msg.textContent = t("appointments.detail.saved");
|
||||
msg.className = "form-msg form-msg-ok";
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
|
||||
msg.textContent = data.message || data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/appointments/${appointment.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -312,12 +357,37 @@ async function deleteAppointment() {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-252 — withdraw warning modal replaces the old confirm().
|
||||
// Returns:
|
||||
// "edit" → unfreeze the edit form (pending-edit mode); Save will
|
||||
// route through /api/approval-requests/{id}/edit-entity
|
||||
// "withdraw" → destructive: the existing /revoke endpoint
|
||||
// null → user cancelled
|
||||
async function withdrawAppointmentRequest() {
|
||||
if (!appointment || !pendingRequest) return;
|
||||
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "appointment",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
pendingEditMode = true;
|
||||
if (btn) btn.disabled = false;
|
||||
// renderHeader re-evaluates the freeze and unfreezes the form now
|
||||
// that pendingEditMode is set. Focus the first editable field so the
|
||||
// user can type immediately.
|
||||
renderHeader();
|
||||
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
|
||||
titleEl?.focus();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -328,9 +398,12 @@ async function withdrawAppointmentRequest() {
|
||||
if (fresh.ok) {
|
||||
appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
// CREATE lifecycle: entity gone → back to the list.
|
||||
window.location.href = "/events?type=appointment";
|
||||
}
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
|
||||
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
|
||||
//
|
||||
// Before t-paliad-252 the deadline + appointment detail pages did a
|
||||
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
|
||||
// For pending CREATE lifecycles that endpoint silently DELETES the
|
||||
// underlying entity row — m's "withdrawing the approval deletes the event"
|
||||
// surprise.
|
||||
//
|
||||
// This modal replaces the confirm() with three explicit paths:
|
||||
//
|
||||
// 1. Cancel — does nothing
|
||||
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
|
||||
// through POST /approval-requests/{id}/
|
||||
// edit-entity which keeps the request
|
||||
// pending and merges the new fields
|
||||
// into approval_request.payload
|
||||
// 3. Endgültig zurückziehen + — destructive; current /revoke
|
||||
// löschen behaviour (delete for CREATE, revert
|
||||
// for UPDATE/COMPLETE, cancel for
|
||||
// DELETE-lifecycle requests)
|
||||
//
|
||||
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
|
||||
// three-button row sits cleanly inside the body — the primitive only
|
||||
// supports one secondary action, but we paint the destructive button as a
|
||||
// separate row above the footer.
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { openModal } from "./modal";
|
||||
|
||||
export type WithdrawAction = "edit" | "withdraw";
|
||||
|
||||
export interface WithdrawWarningArgs {
|
||||
// entityType drives the copy ("event" vs "appointment" labels).
|
||||
entityType: "deadline" | "appointment";
|
||||
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
|
||||
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
|
||||
// cancelling the deletion request).
|
||||
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
|
||||
}
|
||||
|
||||
// openWithdrawWarningModal resolves with the chosen action, or null if the
|
||||
// user dismissed via Cancel / Esc / backdrop / browser back-button.
|
||||
export async function openWithdrawWarningModal(
|
||||
args: WithdrawWarningArgs,
|
||||
): Promise<WithdrawAction | null> {
|
||||
const body = document.createElement("div");
|
||||
body.className = "withdraw-warning-body";
|
||||
|
||||
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
|
||||
// knows what the destructive button will actually do. The /revoke
|
||||
// backend behaviour:
|
||||
// - create → DELETE the entity (the "surprise" m flagged)
|
||||
// - update → revert to pre_image
|
||||
// - complete → revert to pre-complete state
|
||||
// - delete → cancel the delete request (entity stays alive)
|
||||
const intro = document.createElement("p");
|
||||
intro.className = "withdraw-warning-intro";
|
||||
intro.textContent = leadCopyFor(args);
|
||||
body.appendChild(intro);
|
||||
|
||||
const sub = document.createElement("p");
|
||||
sub.className = "withdraw-warning-sub muted";
|
||||
sub.textContent = subCopyFor(args);
|
||||
body.appendChild(sub);
|
||||
|
||||
// The destructive button lives inside the body — the openModal primitive
|
||||
// only exposes one secondary button slot, and we want the safe "Edit"
|
||||
// path to be the primary CTA. Painting it in red here, separated from
|
||||
// the footer, signals "this is the dangerous option" without competing
|
||||
// visually with the primary CTA.
|
||||
const destructiveRow = document.createElement("div");
|
||||
destructiveRow.className = "withdraw-warning-destructive-row";
|
||||
const destructiveBtn = document.createElement("button");
|
||||
destructiveBtn.type = "button";
|
||||
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
|
||||
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
|
||||
destructiveRow.appendChild(destructiveBtn);
|
||||
body.appendChild(destructiveRow);
|
||||
|
||||
return new Promise<WithdrawAction | null>((resolve) => {
|
||||
let chosen: WithdrawAction | null = null;
|
||||
|
||||
// The destructive button has to close the modal and return "withdraw".
|
||||
// We need access to the modal's internal close() — fortunately openModal
|
||||
// exposes it via the primary handler's first arg. We pass through the
|
||||
// outer resolve and let the primary handler (Edit) own the close-fn
|
||||
// route. For the destructive button we resolve the outer promise
|
||||
// directly and then synthesise an ESC keypress so the modal dismisses
|
||||
// — or, simpler, set chosen and use the secondary "Cancel" path that
|
||||
// the modal already supports. (openModal's onClose fires on every
|
||||
// dismiss path including the primary handler resolution.)
|
||||
destructiveBtn.addEventListener("click", () => {
|
||||
chosen = "withdraw";
|
||||
// The unified openModal primitive (modal.ts) wires its dismiss path
|
||||
// through the native <dialog>'s `cancel` event. Dispatching it on
|
||||
// the parent <dialog> runs the same finish() → onClose → resolve
|
||||
// sequence as ESC / backdrop. We then map the resolved `null` back
|
||||
// to "withdraw" via the captured `chosen` in onClose below.
|
||||
const dialogEl = body.closest("dialog");
|
||||
dialogEl?.dispatchEvent(new Event("cancel"));
|
||||
});
|
||||
|
||||
void openModal<WithdrawAction>({
|
||||
title: t("approvals.withdraw.modal.title"),
|
||||
body,
|
||||
size: "md",
|
||||
classNames: "withdraw-warning-modal",
|
||||
primary: {
|
||||
label: t("approvals.withdraw.primary.label"),
|
||||
handler: (close) => {
|
||||
chosen = "edit";
|
||||
close("edit");
|
||||
},
|
||||
},
|
||||
secondary: { label: t("approvals.withdraw.cancel") },
|
||||
onClose: () => {
|
||||
// Resolves whatever was chosen via the destructive button OR the
|
||||
// primary handler. ESC / backdrop / secondary clear `chosen` to
|
||||
// null which is the right "cancel" semantics.
|
||||
resolve(chosen);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function leadCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return args.entityType === "appointment"
|
||||
? t("approvals.withdraw.lead.create.appointment")
|
||||
: t("approvals.withdraw.lead.create.deadline");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.lead.delete");
|
||||
default:
|
||||
// update / complete / unknown → revert semantics
|
||||
return t("approvals.withdraw.lead.update");
|
||||
}
|
||||
}
|
||||
|
||||
function subCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return t("approvals.withdraw.sub.create");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.sub.delete");
|
||||
default:
|
||||
return t("approvals.withdraw.sub.update");
|
||||
}
|
||||
}
|
||||
289
frontend/src/client/date-range-picker-pure.test.ts
Normal file
289
frontend/src/client/date-range-picker-pure.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
// Unit tests for the date-range picker's pure helpers (t-paliad-248).
|
||||
// Run with `bun test`.
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import {
|
||||
horizonBounds,
|
||||
isValidHorizon,
|
||||
isValidISODate,
|
||||
validateCustomRange,
|
||||
parseURL,
|
||||
serializeURL,
|
||||
isDefault,
|
||||
ALL_HORIZONS,
|
||||
PAST_HORIZONS,
|
||||
NEXT_HORIZONS,
|
||||
type TimeHorizon,
|
||||
type TimeSpec,
|
||||
} from "./date-range-picker-pure";
|
||||
|
||||
// Anchor the clock so day-arithmetic assertions don't drift with the
|
||||
// wall clock. 2026-05-25 00:00 UTC matches the Go-side bounds test.
|
||||
const NOW = new Date(Date.UTC(2026, 4, 25));
|
||||
const DAY = (offsetDays: number): Date =>
|
||||
new Date(NOW.getTime() + offsetDays * 86_400_000);
|
||||
|
||||
describe("ALL_HORIZONS / PAST / NEXT registries", () => {
|
||||
test("registries sum to a known total without overlap", () => {
|
||||
// 6 past + 6 next + any + custom = 14 fan chips (custom is the
|
||||
// trailing entry in ALL_HORIZONS; `all` is intentionally absent —
|
||||
// surfaces don't render the legacy bidirectional-unbounded chip).
|
||||
expect(ALL_HORIZONS.length).toBe(14);
|
||||
expect(PAST_HORIZONS.length).toBe(6);
|
||||
expect(NEXT_HORIZONS.length).toBe(6);
|
||||
expect(new Set(ALL_HORIZONS).size).toBe(ALL_HORIZONS.length);
|
||||
});
|
||||
|
||||
test("PAST_HORIZONS are all past_*", () => {
|
||||
for (const h of PAST_HORIZONS) {
|
||||
expect(h.startsWith("past_")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("NEXT_HORIZONS are all next_*", () => {
|
||||
for (const h of NEXT_HORIZONS) {
|
||||
expect(h.startsWith("next_")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("ALL_HORIZONS ends with custom and contains any in the middle", () => {
|
||||
expect(ALL_HORIZONS.at(-1)).toBe("custom");
|
||||
expect(ALL_HORIZONS).toContain("any");
|
||||
});
|
||||
});
|
||||
|
||||
describe("horizonBounds", () => {
|
||||
test("future fan: bounds anchor at today, extend forward", () => {
|
||||
expect(horizonBounds("next_1d", NOW)).toEqual({ from: DAY(0), to: DAY(1) });
|
||||
expect(horizonBounds("next_7d", NOW)).toEqual({ from: DAY(0), to: DAY(7) });
|
||||
expect(horizonBounds("next_14d", NOW)).toEqual({ from: DAY(0), to: DAY(14) });
|
||||
expect(horizonBounds("next_30d", NOW)).toEqual({ from: DAY(0), to: DAY(30) });
|
||||
expect(horizonBounds("next_90d", NOW)).toEqual({ from: DAY(0), to: DAY(90) });
|
||||
});
|
||||
|
||||
test("past fan: bounds extend back, upper bound is tomorrow (exclusive end-of-today)", () => {
|
||||
expect(horizonBounds("past_1d", NOW)).toEqual({ from: DAY(-1), to: DAY(1) });
|
||||
expect(horizonBounds("past_7d", NOW)).toEqual({ from: DAY(-7), to: DAY(1) });
|
||||
expect(horizonBounds("past_14d", NOW)).toEqual({ from: DAY(-14), to: DAY(1) });
|
||||
expect(horizonBounds("past_30d", NOW)).toEqual({ from: DAY(-30), to: DAY(1) });
|
||||
expect(horizonBounds("past_90d", NOW)).toEqual({ from: DAY(-90), to: DAY(1) });
|
||||
});
|
||||
|
||||
test("next_all is one-sided: from=today, to undefined", () => {
|
||||
const b = horizonBounds("next_all", NOW);
|
||||
expect(b.from).toEqual(DAY(0));
|
||||
expect(b.to).toBeUndefined();
|
||||
});
|
||||
|
||||
test("past_all is one-sided: from undefined, to=tomorrow", () => {
|
||||
const b = horizonBounds("past_all", NOW);
|
||||
expect(b.from).toBeUndefined();
|
||||
expect(b.to).toEqual(DAY(1));
|
||||
});
|
||||
|
||||
test("any / all / custom: both bounds undefined", () => {
|
||||
expect(horizonBounds("any", NOW)).toEqual({});
|
||||
expect(horizonBounds("all", NOW)).toEqual({});
|
||||
expect(horizonBounds("custom", NOW)).toEqual({});
|
||||
});
|
||||
|
||||
test("bounds anchor on UTC start-of-day regardless of input clock time", () => {
|
||||
const nowAfternoon = new Date(Date.UTC(2026, 4, 25, 14, 37, 0));
|
||||
const nowMidnight = new Date(Date.UTC(2026, 4, 25, 0, 0, 0));
|
||||
expect(horizonBounds("past_7d", nowAfternoon)).toEqual(horizonBounds("past_7d", nowMidnight));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidHorizon", () => {
|
||||
test("accepts every entry in ALL_HORIZONS plus 'all' (legacy)", () => {
|
||||
for (const h of ALL_HORIZONS) {
|
||||
expect(isValidHorizon(h)).toBe(true);
|
||||
}
|
||||
expect(isValidHorizon("all")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects unknown strings, numbers, undefined, null", () => {
|
||||
expect(isValidHorizon("next_5d")).toBe(false);
|
||||
expect(isValidHorizon("past_100d")).toBe(false);
|
||||
expect(isValidHorizon("")).toBe(false);
|
||||
expect(isValidHorizon(7)).toBe(false);
|
||||
expect(isValidHorizon(undefined)).toBe(false);
|
||||
expect(isValidHorizon(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidISODate", () => {
|
||||
test("accepts valid YYYY-MM-DD", () => {
|
||||
expect(isValidISODate("2026-05-25")).toBe(true);
|
||||
expect(isValidISODate("2026-12-31")).toBe(true);
|
||||
expect(isValidISODate("2024-02-29")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects shape mismatches", () => {
|
||||
expect(isValidISODate("2026/05/25")).toBe(false);
|
||||
expect(isValidISODate("25.05.2026")).toBe(false);
|
||||
expect(isValidISODate("2026-5-25")).toBe(false);
|
||||
expect(isValidISODate("")).toBe(false);
|
||||
expect(isValidISODate(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects calendar-impossible dates (Date.parse silently rolls over)", () => {
|
||||
expect(isValidISODate("2026-02-30")).toBe(false);
|
||||
expect(isValidISODate("2026-13-01")).toBe(false);
|
||||
expect(isValidISODate("2026-04-31")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects 2025-02-29 (non-leap February)", () => {
|
||||
expect(isValidISODate("2025-02-29")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCustomRange", () => {
|
||||
test("requires both bounds present and valid", () => {
|
||||
expect(validateCustomRange(undefined, undefined)).toBe("date_range.custom.invalid_missing");
|
||||
expect(validateCustomRange("2026-05-25", undefined)).toBe("date_range.custom.invalid_missing");
|
||||
expect(validateCustomRange(undefined, "2026-05-25")).toBe("date_range.custom.invalid_missing");
|
||||
});
|
||||
|
||||
test("rejects malformed dates with format error", () => {
|
||||
expect(validateCustomRange("bogus", "2026-05-25")).toBe("date_range.custom.invalid_format");
|
||||
expect(validateCustomRange("2026-13-01", "2026-12-31")).toBe("date_range.custom.invalid_format");
|
||||
});
|
||||
|
||||
test("rejects to <= from with invalid error", () => {
|
||||
expect(validateCustomRange("2026-05-25", "2026-05-25")).toBe("date_range.custom.invalid");
|
||||
expect(validateCustomRange("2026-05-25", "2026-05-24")).toBe("date_range.custom.invalid");
|
||||
});
|
||||
|
||||
test("accepts strictly-ordered valid pair", () => {
|
||||
expect(validateCustomRange("2026-05-25", "2026-05-26")).toBeNull();
|
||||
expect(validateCustomRange("2026-01-01", "2026-12-31")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseURL", () => {
|
||||
test("missing horizon yields contract default", () => {
|
||||
expect(parseURL(new URLSearchParams(""))).toEqual({ horizon: "any" });
|
||||
expect(parseURL(new URLSearchParams(""), { default: "next_30d" })).toEqual({ horizon: "next_30d" });
|
||||
});
|
||||
|
||||
test("unknown horizon falls back to default, doesn't throw", () => {
|
||||
expect(parseURL(new URLSearchParams("horizon=mystery"), { default: "next_7d" }))
|
||||
.toEqual({ horizon: "next_7d" });
|
||||
});
|
||||
|
||||
test("every fan horizon round-trips on a fresh URLSearchParams", () => {
|
||||
for (const h of ALL_HORIZONS) {
|
||||
if (h === "custom") continue;
|
||||
const params = new URLSearchParams(`horizon=${h}`);
|
||||
expect(parseURL(params)).toEqual({ horizon: h });
|
||||
}
|
||||
});
|
||||
|
||||
test("custom horizon reads from+to", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30");
|
||||
expect(parseURL(params)).toEqual({
|
||||
horizon: "custom",
|
||||
from: "2026-03-15",
|
||||
to: "2026-04-30",
|
||||
});
|
||||
});
|
||||
|
||||
test("custom with malformed dates falls back to default rather than half-state", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-99-99&horizon_to=2026-04-30");
|
||||
expect(parseURL(params, { default: "next_30d" })).toEqual({ horizon: "next_30d" });
|
||||
});
|
||||
|
||||
test("custom with from>=to falls back", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-05-25&horizon_to=2026-05-25");
|
||||
expect(parseURL(params)).toEqual({ horizon: "any" });
|
||||
});
|
||||
|
||||
test("custom URL key override", () => {
|
||||
const params = new URLSearchParams("range=past_30d");
|
||||
expect(parseURL(params, { key: "range" })).toEqual({ horizon: "past_30d" });
|
||||
expect(parseURL(params)).toEqual({ horizon: "any" }); // default `horizon` key absent
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeURL", () => {
|
||||
test("default horizon is omitted (canonical URL stays short)", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "any" }, params);
|
||||
expect(params.toString()).toBe("");
|
||||
});
|
||||
|
||||
test("explicit default param removed when value matches default", () => {
|
||||
const params = new URLSearchParams("horizon=past_30d&other=keep");
|
||||
serializeURL({ horizon: "past_30d" }, params, { default: "past_30d" });
|
||||
expect(params.toString()).toBe("other=keep");
|
||||
});
|
||||
|
||||
test("non-default horizon is written", () => {
|
||||
const params = new URLSearchParams("other=keep");
|
||||
serializeURL({ horizon: "next_7d" }, params);
|
||||
expect(params.toString()).toBe("other=keep&horizon=next_7d");
|
||||
});
|
||||
|
||||
test("custom writes horizon+from+to", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "custom", from: "2026-03-15", to: "2026-04-30" }, params);
|
||||
expect(params.toString()).toBe("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30");
|
||||
});
|
||||
|
||||
test("custom partial bounds: from/to are written individually", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "custom", from: "2026-03-15" }, params);
|
||||
expect(params.toString()).toBe("horizon=custom&horizon_from=2026-03-15");
|
||||
});
|
||||
|
||||
test("stale params cleared on re-serialize", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30&other=keep");
|
||||
serializeURL({ horizon: "past_30d" }, params);
|
||||
expect(params.toString()).toBe("other=keep&horizon=past_30d");
|
||||
// Stale from/to must be gone.
|
||||
expect(params.has("horizon_from")).toBe(false);
|
||||
expect(params.has("horizon_to")).toBe(false);
|
||||
});
|
||||
|
||||
test("key override propagates to from/to", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "custom", from: "2026-03-15", to: "2026-04-30" }, params, { key: "range" });
|
||||
expect(params.toString()).toBe("range=custom&range_from=2026-03-15&range_to=2026-04-30");
|
||||
});
|
||||
|
||||
test("URL round-trips through parse → serialize → parse", () => {
|
||||
const specs: TimeSpec[] = [
|
||||
{ horizon: "any" },
|
||||
{ horizon: "next_7d" },
|
||||
{ horizon: "past_all" },
|
||||
{ horizon: "next_all" },
|
||||
{ horizon: "custom", from: "2026-03-15", to: "2026-04-30" },
|
||||
];
|
||||
for (const spec of specs) {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL(spec, params);
|
||||
expect(parseURL(params)).toEqual(spec);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDefault", () => {
|
||||
test("true when horizon matches default exactly", () => {
|
||||
expect(isDefault({ horizon: "any" }, "any")).toBe(true);
|
||||
expect(isDefault({ horizon: "next_30d" }, "next_30d")).toBe(true);
|
||||
});
|
||||
|
||||
test("false when horizon differs", () => {
|
||||
expect(isDefault({ horizon: "past_7d" }, "any")).toBe(false);
|
||||
expect(isDefault({ horizon: "next_30d" }, "next_7d")).toBe(false);
|
||||
});
|
||||
|
||||
test("custom is never default — even when bounds match", () => {
|
||||
// No surface treats "custom" as the natural default, so any custom
|
||||
// selection IS user-driven and the closed button must surface
|
||||
// the non-default indicator.
|
||||
expect(isDefault({ horizon: "custom", from: "2026-01-01", to: "2026-12-31" }, "custom" as TimeHorizon))
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
292
frontend/src/client/date-range-picker-pure.ts
Normal file
292
frontend/src/client/date-range-picker-pure.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
// date-range-picker-pure.ts — pure helpers for the symmetric date-range
|
||||
// picker (t-paliad-248). No DOM access; runnable under `bun test`. The
|
||||
// picker's boot client (date-range-picker.ts) drives the popover, but
|
||||
// every interesting decision — what does "Letzte 7 Tage" mean today,
|
||||
// what URL params should land, when is a custom range valid — lives
|
||||
// here so it can be tested without a browser.
|
||||
//
|
||||
// The Go side (internal/services/view_service.go:computeViewSpecBounds)
|
||||
// is the canonical materializer; horizonBounds() below MUST stay in
|
||||
// step with it. The bounds test in pure-tests pins the shape so a
|
||||
// divergent change to one side breaks the assertions on the other.
|
||||
|
||||
import type { I18nKey } from "../i18n-keys";
|
||||
|
||||
/**
|
||||
* TimeHorizon — the full 14-value union the symmetric picker can emit.
|
||||
* Mirrors `internal/services/filter_spec.go` TimeHorizon.
|
||||
*
|
||||
* The fan chips: 6 past + 6 next + the ALLES centre (`any`) + custom.
|
||||
* `all` is the legacy bidirectional-unbounded value, gated to
|
||||
* scope=explicit by the validator (Q26); the picker doesn't surface it
|
||||
* but parseURL accepts it for back-compat with saved Custom Views.
|
||||
*/
|
||||
export type TimeHorizon =
|
||||
| "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "all" | "custom";
|
||||
|
||||
/**
|
||||
* TimeSpec — the wire shape mirrored from the Go FilterSpec.TimeSpec.
|
||||
* `from`/`to` are ISO YYYY-MM-DD strings — UTC dates, not timestamps.
|
||||
* Times-of-day intentionally absent from the picker's contract.
|
||||
*/
|
||||
export interface TimeSpec {
|
||||
horizon: TimeHorizon;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full list of horizon values the picker is willing to render
|
||||
* as chips. Order is the picker's reading order — past edge → past
|
||||
* → ALLES → next → next edge, with `custom` last because it lives
|
||||
* below the chip rows in the popover, not in the row itself.
|
||||
*/
|
||||
export const ALL_HORIZONS: readonly TimeHorizon[] = [
|
||||
"past_all",
|
||||
"past_90d",
|
||||
"past_30d",
|
||||
"past_14d",
|
||||
"past_7d",
|
||||
"past_1d",
|
||||
"any",
|
||||
"next_1d",
|
||||
"next_7d",
|
||||
"next_14d",
|
||||
"next_30d",
|
||||
"next_90d",
|
||||
"next_all",
|
||||
"custom",
|
||||
];
|
||||
|
||||
// Strict-validity set. Includes the legacy bidirectional-unbounded `all`
|
||||
// horizon so a saved Custom View JSON ({"horizon":"all", …}) deserializes
|
||||
// without falling back to the surface default. The picker UI itself
|
||||
// doesn't surface a chip for `all` — it's read in, kept as state, but
|
||||
// the chip the user sees light up is `any` (the centre ALLES button).
|
||||
const ALL_HORIZONS_SET: ReadonlySet<string> = new Set([...ALL_HORIZONS, "all"]);
|
||||
|
||||
/**
|
||||
* Past chips, in reading order (outermost → innermost). The picker
|
||||
* renders this left-to-right in the popover's past fan.
|
||||
*/
|
||||
export const PAST_HORIZONS: readonly TimeHorizon[] = [
|
||||
"past_all",
|
||||
"past_90d",
|
||||
"past_30d",
|
||||
"past_14d",
|
||||
"past_7d",
|
||||
"past_1d",
|
||||
];
|
||||
|
||||
/**
|
||||
* Future chips, in reading order (innermost → outermost). The picker
|
||||
* renders this left-to-right in the popover's future fan.
|
||||
*/
|
||||
export const NEXT_HORIZONS: readonly TimeHorizon[] = [
|
||||
"next_1d",
|
||||
"next_7d",
|
||||
"next_14d",
|
||||
"next_30d",
|
||||
"next_90d",
|
||||
"next_all",
|
||||
];
|
||||
|
||||
/**
|
||||
* The i18n key for the closed-button label and chip text of every
|
||||
* horizon. Lives here (not in the TSX) so a single dictionary lookup
|
||||
* sites can hand back a translated string at any point.
|
||||
*/
|
||||
export const HORIZON_LABEL_KEY: Record<TimeHorizon, I18nKey> = {
|
||||
past_all: "date_range.horizon.past_all",
|
||||
past_90d: "date_range.horizon.past_90d",
|
||||
past_30d: "date_range.horizon.past_30d",
|
||||
past_14d: "date_range.horizon.past_14d",
|
||||
past_7d: "date_range.horizon.past_7d",
|
||||
past_1d: "date_range.horizon.past_1d",
|
||||
any: "date_range.horizon.any",
|
||||
next_1d: "date_range.horizon.next_1d",
|
||||
next_7d: "date_range.horizon.next_7d",
|
||||
next_14d: "date_range.horizon.next_14d",
|
||||
next_30d: "date_range.horizon.next_30d",
|
||||
next_90d: "date_range.horizon.next_90d",
|
||||
next_all: "date_range.horizon.next_all",
|
||||
all: "date_range.horizon.any", // legacy alias — surfaces "Alles" in the closed label
|
||||
custom: "date_range.horizon.custom",
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounds for a given horizon, anchored at `now`. Pure function: the
|
||||
* caller passes the clock so tests can pin a specific day without
|
||||
* mocking Date. Bounds are UTC dates; the `to` bound is exclusive
|
||||
* (start-of-day-after) so "past 7d" includes today.
|
||||
*
|
||||
* Returns `{}` for `any` / `all` / `custom` — the picker's surface
|
||||
* lifts the from/to out of TimeSpec directly when horizon === custom,
|
||||
* and treats unbounded values as "no narrowing in that direction".
|
||||
*/
|
||||
export function horizonBounds(
|
||||
horizon: TimeHorizon,
|
||||
now: Date,
|
||||
): { from?: Date; to?: Date } {
|
||||
const day = new Date(Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
));
|
||||
const offset = (days: number): Date =>
|
||||
new Date(day.getTime() + days * 86_400_000);
|
||||
|
||||
switch (horizon) {
|
||||
case "past_1d": return { from: offset(-1), to: offset(1) };
|
||||
case "past_7d": return { from: offset(-7), to: offset(1) };
|
||||
case "past_14d": return { from: offset(-14), to: offset(1) };
|
||||
case "past_30d": return { from: offset(-30), to: offset(1) };
|
||||
case "past_90d": return { from: offset(-90), to: offset(1) };
|
||||
case "past_all": return { to: offset(1) };
|
||||
case "next_1d": return { from: day, to: offset(1) };
|
||||
case "next_7d": return { from: day, to: offset(7) };
|
||||
case "next_14d": return { from: day, to: offset(14) };
|
||||
case "next_30d": return { from: day, to: offset(30) };
|
||||
case "next_90d": return { from: day, to: offset(90) };
|
||||
case "next_all": return { from: day };
|
||||
case "any":
|
||||
case "all":
|
||||
case "custom":
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* isValidHorizon — narrows an unknown string to a TimeHorizon, used
|
||||
* by parseURL and by surface-side URL alias adapters.
|
||||
*/
|
||||
export function isValidHorizon(s: unknown): s is TimeHorizon {
|
||||
return typeof s === "string" && ALL_HORIZONS_SET.has(s);
|
||||
}
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* isValidISODate — `YYYY-MM-DD` shape check plus a real-date validity
|
||||
* check (rejects 2026-02-30). Doesn't enforce timezone or floor at any
|
||||
* particular date.
|
||||
*/
|
||||
export function isValidISODate(s: unknown): s is string {
|
||||
if (typeof s !== "string" || !ISO_DATE_RE.test(s)) return false;
|
||||
const ms = Date.parse(`${s}T00:00:00Z`);
|
||||
if (Number.isNaN(ms)) return false;
|
||||
// Reject 2026-02-30 etc. — Date.parse accepts those by rolling over.
|
||||
return new Date(ms).toISOString().slice(0, 10) === s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a custom range. Returns null on success, an i18n key
|
||||
* pointing at the error message on failure.
|
||||
*
|
||||
* Rules:
|
||||
* - Both `from` and `to` must be valid ISO YYYY-MM-DD.
|
||||
* - `to` must be strictly after `from` (single-day ranges use
|
||||
* `from=2026-05-25&to=2026-05-26`, NOT `from=to=2026-05-25`).
|
||||
*/
|
||||
export function validateCustomRange(
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
): I18nKey | null {
|
||||
if (!from || !to) return "date_range.custom.invalid_missing";
|
||||
if (!isValidISODate(from) || !isValidISODate(to)) return "date_range.custom.invalid_format";
|
||||
if (Date.parse(`${from}T00:00:00Z`) >= Date.parse(`${to}T00:00:00Z`)) {
|
||||
return "date_range.custom.invalid";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* URLContract — the picker's stable URL serialization. Surfaces can
|
||||
* override the param name via `key` so two pickers on the same page
|
||||
* (rare) don't collide.
|
||||
*/
|
||||
export interface URLContract {
|
||||
/** Base param name, defaults to "horizon". */
|
||||
key?: string;
|
||||
/** Default value omitted from URL (matches surface's natural default). */
|
||||
default?: TimeHorizon;
|
||||
}
|
||||
|
||||
/**
|
||||
* parseURL — reads a URL search-params object into a TimeSpec.
|
||||
*
|
||||
* ?horizon=past_30d → {horizon:"past_30d"}
|
||||
* ?horizon=custom&from=2026-03-15&to=… → {horizon:"custom",from,to}
|
||||
* (no params) → {horizon: contract.default ?? "any"}
|
||||
*
|
||||
* Unknown / malformed values fall back to the default. Out-of-shape
|
||||
* custom dates clamp to {horizon: default} — the picker never lands
|
||||
* in a half-custom state from a URL.
|
||||
*/
|
||||
export function parseURL(
|
||||
params: URLSearchParams,
|
||||
contract: URLContract = {},
|
||||
): TimeSpec {
|
||||
const key = contract.key ?? "horizon";
|
||||
const fallback: TimeHorizon = contract.default ?? "any";
|
||||
|
||||
const raw = params.get(key);
|
||||
if (raw === null) return { horizon: fallback };
|
||||
if (!isValidHorizon(raw)) return { horizon: fallback };
|
||||
if (raw !== "custom") return { horizon: raw };
|
||||
|
||||
const from = params.get(`${key}_from`) ?? undefined;
|
||||
const to = params.get(`${key}_to`) ?? undefined;
|
||||
if (validateCustomRange(from, to) !== null) {
|
||||
return { horizon: fallback };
|
||||
}
|
||||
return { horizon: "custom", from, to };
|
||||
}
|
||||
|
||||
/**
|
||||
* serializeURL — writes a TimeSpec into the URL search-params object,
|
||||
* mutating the passed-in instance. Values equal to the surface
|
||||
* default are OMITTED — the canonical URL stays short.
|
||||
*
|
||||
* Always deletes `horizon`, `<key>_from`, `<key>_to` first so a
|
||||
* re-serialise after the picker reverts to default cleans up rather
|
||||
* than accumulating stale entries.
|
||||
*/
|
||||
export function serializeURL(
|
||||
spec: TimeSpec,
|
||||
params: URLSearchParams,
|
||||
contract: URLContract = {},
|
||||
): void {
|
||||
const key = contract.key ?? "horizon";
|
||||
const fromKey = `${key}_from`;
|
||||
const toKey = `${key}_to`;
|
||||
|
||||
params.delete(key);
|
||||
params.delete(fromKey);
|
||||
params.delete(toKey);
|
||||
|
||||
if (spec.horizon === (contract.default ?? "any") && spec.horizon !== "custom") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (spec.horizon === "custom") {
|
||||
params.set(key, "custom");
|
||||
if (spec.from) params.set(fromKey, spec.from);
|
||||
if (spec.to) params.set(toKey, spec.to);
|
||||
return;
|
||||
}
|
||||
|
||||
params.set(key, spec.horizon);
|
||||
}
|
||||
|
||||
/**
|
||||
* isDefault — used by surfaces to decide whether to render the
|
||||
* "value is non-default" dot on the closed button.
|
||||
*/
|
||||
export function isDefault(spec: TimeSpec, defaultHorizon: TimeHorizon): boolean {
|
||||
if (spec.horizon !== defaultHorizon) return false;
|
||||
if (spec.horizon === "custom") return false;
|
||||
return true;
|
||||
}
|
||||
470
frontend/src/client/date-range-picker.ts
Normal file
470
frontend/src/client/date-range-picker.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
// date-range-picker.ts — boot client + DOM mount for the symmetric
|
||||
// date-range picker (t-paliad-248). The picker is a controlled
|
||||
// component: callers pass `value` + `onChange`, the component renders
|
||||
// the trigger button + popover scaffold, the popover materialises a
|
||||
// chip row and (when "Anpassen" is picked) an inline date-pair editor.
|
||||
//
|
||||
// The picker reuses the existing `.agenda-chip` styling for chips and
|
||||
// the `.multi-panel` popover pattern (auto-positioned under a
|
||||
// `.multi-anchor` wrapper). Both patterns are battle-tested by the
|
||||
// filter-bar + multi-select widgets — no new design tokens, no new
|
||||
// dark-mode contrast risk.
|
||||
|
||||
import { t } from "./i18n";
|
||||
import {
|
||||
ALL_HORIZONS,
|
||||
HORIZON_LABEL_KEY,
|
||||
NEXT_HORIZONS,
|
||||
PAST_HORIZONS,
|
||||
isDefault,
|
||||
isValidISODate,
|
||||
validateCustomRange,
|
||||
type TimeHorizon,
|
||||
type TimeSpec,
|
||||
} from "./date-range-picker-pure";
|
||||
|
||||
export interface MountOpts {
|
||||
/** Current value. The picker is fully controlled. */
|
||||
value: TimeSpec;
|
||||
/** Fired on every committed change (chip click or Anwenden). */
|
||||
onChange(next: TimeSpec): void;
|
||||
/**
|
||||
* Which horizon constitutes the "default" for this surface. Used
|
||||
* for the non-default indicator dot. Defaults to `"any"`.
|
||||
*/
|
||||
defaultHorizon?: TimeHorizon;
|
||||
/**
|
||||
* Which chips to render. Order is preserved. Defaults to the full
|
||||
* 14-chip fan from ALL_HORIZONS.
|
||||
*/
|
||||
presets?: readonly TimeHorizon[];
|
||||
/**
|
||||
* Stable surface tag — feeds into the `data-testid` on every DOM
|
||||
* node the picker creates so tests can scope. Example: "agenda",
|
||||
* "filter-bar.time", "audit-log".
|
||||
*/
|
||||
surface: string;
|
||||
/**
|
||||
* Optional prefix for the closed-button label. The label always
|
||||
* starts with the resolved horizon name (e.g. "Letzte 30 Tage").
|
||||
* Surfaces that want a heading prefix ("Zeitraum: Letzte 30 Tage")
|
||||
* pass it here.
|
||||
*/
|
||||
labelPrefix?: string;
|
||||
}
|
||||
|
||||
export interface PickerHandle {
|
||||
/** Root element — append to the host container. */
|
||||
element: HTMLElement;
|
||||
/** Read the current value (may have been edited via Anpassen). */
|
||||
getValue(): TimeSpec;
|
||||
/** Update the value from the host (e.g. after URL change). */
|
||||
setValue(next: TimeSpec): void;
|
||||
/** Force-close the popover. Safe to call when already closed. */
|
||||
close(): void;
|
||||
/** Detach event listeners + remove from DOM. */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a date-range picker. The returned `element` is a single
|
||||
* inline node containing both the trigger button and the popover
|
||||
* (absolutely positioned via `.multi-anchor` + `.multi-panel`).
|
||||
*
|
||||
* The popover stays in the DOM permanently; opening/closing toggles
|
||||
* the `[hidden]` attribute. This keeps the chip's tab-order stable
|
||||
* and matches the multi-select widget's behaviour.
|
||||
*/
|
||||
export function mountDateRangePicker(opts: MountOpts): PickerHandle {
|
||||
const presets = opts.presets ?? ALL_HORIZONS;
|
||||
const defaultHorizon = opts.defaultHorizon ?? "any";
|
||||
let value: TimeSpec = normalize(opts.value);
|
||||
|
||||
// Cached drafts for the "Anpassen" editor — preserved across
|
||||
// open/close so the user doesn't lose their typing if they peek
|
||||
// away. Seeded from the live value when the editor opens.
|
||||
let customFromDraft = value.horizon === "custom" ? (value.from ?? "") : "";
|
||||
let customToDraft = value.horizon === "custom" ? (value.to ?? "") : "";
|
||||
let customEditorOpen = value.horizon === "custom";
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "date-range-anchor multi-anchor";
|
||||
root.dataset.testid = `${opts.surface}.date-range-picker`;
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "date-range-trigger";
|
||||
trigger.setAttribute("aria-haspopup", "dialog");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.dataset.testid = `${opts.surface}.date-range-trigger`;
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "date-range-panel multi-panel";
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-label", t("date_range.dialog.label"));
|
||||
panel.hidden = true;
|
||||
panel.dataset.testid = `${opts.surface}.date-range-panel`;
|
||||
|
||||
root.appendChild(trigger);
|
||||
root.appendChild(panel);
|
||||
|
||||
renderTrigger();
|
||||
renderPanel();
|
||||
|
||||
// Open/close wiring. Click outside the root collapses the popover;
|
||||
// Esc inside it bubbles up to the same handler via keydown delegate.
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (panel.hidden) return;
|
||||
if (e.target instanceof Node && root.contains(e.target)) return;
|
||||
closePopover();
|
||||
};
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (panel.hidden) return;
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
closePopover();
|
||||
trigger.focus();
|
||||
}
|
||||
};
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
if (panel.hidden) openPopover();
|
||||
else closePopover();
|
||||
});
|
||||
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
|
||||
function openPopover(): void {
|
||||
panel.hidden = false;
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
// Re-render to reflect the very latest value (host may have
|
||||
// patched via setValue between open/close).
|
||||
renderPanel();
|
||||
// Move keyboard focus into the panel so Esc works without a
|
||||
// prior click. The first chip is the natural landing spot.
|
||||
const firstChip = panel.querySelector<HTMLButtonElement>(".date-range-chip");
|
||||
firstChip?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function closePopover(): void {
|
||||
panel.hidden = true;
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function commit(next: TimeSpec, closeAfter: boolean): void {
|
||||
value = normalize(next);
|
||||
customEditorOpen = value.horizon === "custom";
|
||||
if (value.horizon === "custom") {
|
||||
customFromDraft = value.from ?? "";
|
||||
customToDraft = value.to ?? "";
|
||||
}
|
||||
renderTrigger();
|
||||
renderPanel();
|
||||
opts.onChange(value);
|
||||
if (closeAfter) {
|
||||
closePopover();
|
||||
trigger.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrigger(): void {
|
||||
trigger.replaceChildren();
|
||||
if (!isDefault(value, defaultHorizon)) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "date-range-trigger-dot";
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
trigger.appendChild(dot);
|
||||
}
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.className = "date-range-trigger-label";
|
||||
labelSpan.textContent = labelFor(value, opts.labelPrefix);
|
||||
trigger.appendChild(labelSpan);
|
||||
|
||||
const chev = document.createElement("span");
|
||||
chev.className = "date-range-trigger-chev";
|
||||
chev.setAttribute("aria-hidden", "true");
|
||||
chev.textContent = "▾";
|
||||
trigger.appendChild(chev);
|
||||
}
|
||||
|
||||
function renderPanel(): void {
|
||||
panel.replaceChildren();
|
||||
|
||||
// Three groups in a single row: past fan / ALLES centre / next fan.
|
||||
const row = document.createElement("div");
|
||||
row.className = "date-range-row";
|
||||
|
||||
const pastGroup = renderFan(
|
||||
PAST_HORIZONS.filter((h) => presets.includes(h)),
|
||||
"past",
|
||||
);
|
||||
const centerGroup = renderCenter();
|
||||
const nextGroup = renderFan(
|
||||
NEXT_HORIZONS.filter((h) => presets.includes(h)),
|
||||
"next",
|
||||
);
|
||||
|
||||
if (pastGroup) row.appendChild(pastGroup);
|
||||
if (centerGroup) row.appendChild(centerGroup);
|
||||
if (nextGroup) row.appendChild(nextGroup);
|
||||
|
||||
panel.appendChild(row);
|
||||
|
||||
// Custom-range section ("Anpassen"). Toggle button + collapsible
|
||||
// date-pair editor below.
|
||||
if (presets.includes("custom")) {
|
||||
panel.appendChild(renderCustomSection());
|
||||
}
|
||||
}
|
||||
|
||||
function renderFan(horizons: readonly TimeHorizon[], side: "past" | "next"): HTMLElement | null {
|
||||
if (horizons.length === 0) return null;
|
||||
const group = document.createElement("div");
|
||||
group.className = `date-range-fan date-range-fan--${side}`;
|
||||
group.setAttribute("role", "group");
|
||||
group.setAttribute("aria-label", side === "past"
|
||||
? t("date_range.fan.past.label")
|
||||
: t("date_range.fan.future.label"));
|
||||
for (const h of horizons) {
|
||||
group.appendChild(makeChip(h));
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function renderCenter(): HTMLElement | null {
|
||||
if (!presets.includes("any")) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "date-range-center";
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "date-range-center-btn";
|
||||
if (value.horizon === "any" || value.horizon === "all") {
|
||||
btn.classList.add("date-range-center-btn--active");
|
||||
}
|
||||
btn.setAttribute("aria-pressed", String(value.horizon === "any" || value.horizon === "all"));
|
||||
btn.dataset.testid = `${opts.surface}.date-range-chip.any`;
|
||||
|
||||
const glyph = document.createElement("span");
|
||||
glyph.className = "date-range-center-glyph";
|
||||
glyph.setAttribute("aria-hidden", "true");
|
||||
glyph.textContent = "⌖"; // ⌖ POSITION INDICATOR
|
||||
const label = document.createElement("span");
|
||||
label.className = "date-range-center-label";
|
||||
label.textContent = t("date_range.center.label");
|
||||
btn.appendChild(glyph);
|
||||
btn.appendChild(label);
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
commit({ horizon: "any" }, /*closeAfter*/ true);
|
||||
});
|
||||
|
||||
wrap.appendChild(btn);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function makeChip(h: TimeHorizon): HTMLButtonElement {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "agenda-chip date-range-chip";
|
||||
if (value.horizon === h) chip.classList.add("agenda-chip-active");
|
||||
chip.setAttribute("aria-pressed", String(value.horizon === h));
|
||||
chip.textContent = t(HORIZON_LABEL_KEY[h]);
|
||||
chip.dataset.testid = `${opts.surface}.date-range-chip.${h}`;
|
||||
chip.addEventListener("click", () => {
|
||||
commit({ horizon: h }, /*closeAfter*/ true);
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
|
||||
function renderCustomSection(): HTMLElement {
|
||||
const section = document.createElement("div");
|
||||
section.className = "date-range-custom";
|
||||
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.type = "button";
|
||||
toggleBtn.className = "agenda-chip date-range-chip date-range-chip--custom";
|
||||
if (value.horizon === "custom") toggleBtn.classList.add("agenda-chip-active");
|
||||
toggleBtn.setAttribute("aria-expanded", String(customEditorOpen));
|
||||
toggleBtn.dataset.testid = `${opts.surface}.date-range-chip.custom`;
|
||||
toggleBtn.textContent = t("date_range.horizon.custom");
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
customEditorOpen = !customEditorOpen;
|
||||
renderPanel();
|
||||
if (customEditorOpen) {
|
||||
// Focus the first input on expand.
|
||||
panel.querySelector<HTMLInputElement>(".date-range-custom-from")?.focus();
|
||||
}
|
||||
});
|
||||
section.appendChild(toggleBtn);
|
||||
|
||||
if (!customEditorOpen) return section;
|
||||
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "date-range-custom-editor";
|
||||
|
||||
const fromWrap = document.createElement("label");
|
||||
fromWrap.className = "date-range-custom-field";
|
||||
const fromLbl = document.createElement("span");
|
||||
fromLbl.className = "date-range-custom-label";
|
||||
fromLbl.textContent = t("date_range.custom.from");
|
||||
const fromInput = document.createElement("input");
|
||||
fromInput.type = "date";
|
||||
fromInput.lang = "de";
|
||||
fromInput.className = "date-range-custom-from";
|
||||
fromInput.value = customFromDraft;
|
||||
fromInput.dataset.testid = `${opts.surface}.date-range-custom-from`;
|
||||
fromInput.addEventListener("input", () => {
|
||||
customFromDraft = fromInput.value;
|
||||
refreshValidity();
|
||||
});
|
||||
fromWrap.appendChild(fromLbl);
|
||||
fromWrap.appendChild(fromInput);
|
||||
|
||||
const toWrap = document.createElement("label");
|
||||
toWrap.className = "date-range-custom-field";
|
||||
const toLbl = document.createElement("span");
|
||||
toLbl.className = "date-range-custom-label";
|
||||
toLbl.textContent = t("date_range.custom.to");
|
||||
const toInput = document.createElement("input");
|
||||
toInput.type = "date";
|
||||
toInput.lang = "de";
|
||||
toInput.className = "date-range-custom-to";
|
||||
toInput.value = customToDraft;
|
||||
toInput.dataset.testid = `${opts.surface}.date-range-custom-to`;
|
||||
toInput.addEventListener("input", () => {
|
||||
customToDraft = toInput.value;
|
||||
refreshValidity();
|
||||
});
|
||||
toWrap.appendChild(toLbl);
|
||||
toWrap.appendChild(toInput);
|
||||
|
||||
const applyBtn = document.createElement("button");
|
||||
applyBtn.type = "button";
|
||||
applyBtn.className = "date-range-custom-apply";
|
||||
applyBtn.textContent = t("date_range.custom.apply");
|
||||
applyBtn.dataset.testid = `${opts.surface}.date-range-custom-apply`;
|
||||
applyBtn.addEventListener("click", () => {
|
||||
const err = validateCustomRange(customFromDraft, customToDraft);
|
||||
if (err !== null) {
|
||||
showError(err);
|
||||
return;
|
||||
}
|
||||
commit(
|
||||
{ horizon: "custom", from: customFromDraft, to: customToDraft },
|
||||
/*closeAfter*/ true,
|
||||
);
|
||||
});
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "date-range-custom-cancel";
|
||||
cancelBtn.textContent = t("date_range.custom.cancel");
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
customEditorOpen = false;
|
||||
// Restore drafts from live value so a re-open shows the
|
||||
// committed state rather than the abandoned typing.
|
||||
customFromDraft = value.horizon === "custom" ? (value.from ?? "") : "";
|
||||
customToDraft = value.horizon === "custom" ? (value.to ?? "") : "";
|
||||
renderPanel();
|
||||
});
|
||||
|
||||
const errEl = document.createElement("div");
|
||||
errEl.className = "date-range-custom-error";
|
||||
errEl.hidden = true;
|
||||
errEl.dataset.testid = `${opts.surface}.date-range-custom-error`;
|
||||
|
||||
editor.appendChild(fromWrap);
|
||||
editor.appendChild(toWrap);
|
||||
editor.appendChild(applyBtn);
|
||||
editor.appendChild(cancelBtn);
|
||||
editor.appendChild(errEl);
|
||||
section.appendChild(editor);
|
||||
|
||||
refreshValidity();
|
||||
|
||||
function refreshValidity(): void {
|
||||
const err = validateCustomRange(customFromDraft, customToDraft);
|
||||
if (err === null) {
|
||||
applyBtn.disabled = false;
|
||||
errEl.hidden = true;
|
||||
errEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
applyBtn.disabled = true;
|
||||
// Only surface the *content* error (`invalid` = inverted range)
|
||||
// while the user is typing. Empty / format errors are visible
|
||||
// through the disabled-Anwenden state alone — surfacing them on
|
||||
// every keystroke would be noisy.
|
||||
if (err === "date_range.custom.invalid") {
|
||||
showError(err);
|
||||
} else {
|
||||
errEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(key: Parameters<typeof t>[0]): void {
|
||||
errEl.textContent = t(key);
|
||||
errEl.hidden = false;
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
return {
|
||||
element: root,
|
||||
getValue: () => normalize(value),
|
||||
setValue(next: TimeSpec) {
|
||||
value = normalize(next);
|
||||
customEditorOpen = value.horizon === "custom";
|
||||
if (value.horizon === "custom") {
|
||||
customFromDraft = value.from ?? "";
|
||||
customToDraft = value.to ?? "";
|
||||
}
|
||||
renderTrigger();
|
||||
renderPanel();
|
||||
},
|
||||
close: closePopover,
|
||||
destroy() {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(spec: TimeSpec): TimeSpec {
|
||||
if (spec.horizon === "custom") {
|
||||
return {
|
||||
horizon: "custom",
|
||||
from: spec.from && isValidISODate(spec.from) ? spec.from : undefined,
|
||||
to: spec.to && isValidISODate(spec.to) ? spec.to : undefined,
|
||||
};
|
||||
}
|
||||
return { horizon: spec.horizon };
|
||||
}
|
||||
|
||||
function labelFor(spec: TimeSpec, prefix?: string): string {
|
||||
let body: string;
|
||||
if (spec.horizon === "custom") {
|
||||
if (spec.from && spec.to) {
|
||||
body = t("date_range.button.label.custom_range")
|
||||
.replace("{from}", formatISO(spec.from))
|
||||
.replace("{to}", formatISO(spec.to));
|
||||
} else {
|
||||
body = t("date_range.horizon.custom");
|
||||
}
|
||||
} else {
|
||||
body = t(HORIZON_LABEL_KEY[spec.horizon]);
|
||||
}
|
||||
return prefix ? `${prefix}: ${body}` : body;
|
||||
}
|
||||
|
||||
function formatISO(iso: string): string {
|
||||
if (!isValidISODate(iso)) return iso;
|
||||
// DE locale: DD.MM.YYYY. The picker is German-first; surfaces in EN
|
||||
// can override via labelPrefix or by formatting before commit if
|
||||
// they want a different shape.
|
||||
const [y, m, d] = iso.split("-");
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type EventType,
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
interface Deadline {
|
||||
id: string;
|
||||
@@ -20,6 +22,9 @@ interface Deadline {
|
||||
source: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-258 — lawyer's free-text rule label when the deadline was
|
||||
// saved in Custom mode. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
@@ -38,6 +43,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
@@ -54,7 +62,21 @@ interface DeadlineRule {
|
||||
id: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
rule_code?: string;
|
||||
legal_source?: string | null;
|
||||
// t-paliad-258 — canonical event_type for Auto-mode rule resolution
|
||||
// when the user flips to Auto on the edit form.
|
||||
concept_default_event_type_id?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
jurisdiction: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -70,6 +92,30 @@ let me: Me | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
|
||||
// t-paliad-258 — Auto/Custom rule editor state. Mirrors the create form.
|
||||
// On enterEdit we initialise the mode from the persisted deadline:
|
||||
// rule_id set → "auto"
|
||||
// custom_rule_text set, no rule_id → "custom"
|
||||
// neither set → "auto" (so the Type-driven
|
||||
// resolver fills in immediately).
|
||||
type RuleMode = "auto" | "custom";
|
||||
let ruleMode: RuleMode = "auto";
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
|
||||
// modal, the entity is still in approval_status='pending'. Save must POST
|
||||
// to /api/approval-requests/{id}/edit-entity (which keeps the request
|
||||
// pending + merges the new fields into payload) instead of the regular
|
||||
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
|
||||
// from edit mode + after a successful save.
|
||||
let pendingEditMode = false;
|
||||
|
||||
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
|
||||
// modal handler (initWithdraw) can route into pending-edit mode without
|
||||
// duplicating the edit-mode toggle logic.
|
||||
let pendingEnterEdit: (() => void) | null = null;
|
||||
|
||||
function parseDeadlineID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "deadlines" || !parts[1]) return null;
|
||||
@@ -165,17 +211,66 @@ function populateProjectPicker() {
|
||||
sel.value = deadline.project_id;
|
||||
}
|
||||
|
||||
async function loadRule(ruleID: string) {
|
||||
async function loadAllRules() {
|
||||
try {
|
||||
const resp = await fetch(`/api/deadline-rules`);
|
||||
if (!resp.ok) return;
|
||||
const all: DeadlineRule[] = await resp.json();
|
||||
rule = all.find((r) => r.id === ruleID) || null;
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProceedingTypes() {
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db");
|
||||
if (!resp.ok) return;
|
||||
const types: ProceedingType[] = await resp.json();
|
||||
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function lookupRule(ruleID: string): DeadlineRule | null {
|
||||
return rulesByID.get(ruleID) || null;
|
||||
}
|
||||
|
||||
// resolveAutoRuleForType mirrors the create-form resolver: pick the
|
||||
// canonical rule for the chosen event_type, prioritising the project's
|
||||
// proceeding then jurisdiction match.
|
||||
function resolveAutoRuleForType(eventTypeID: string): DeadlineRule | null {
|
||||
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
const projID = deadline?.project_id;
|
||||
const proj = projID ? allProjects.find((p) => p.id === projID) as (Project & { proceeding_type_id?: number | null }) | undefined : undefined;
|
||||
if (proj && proj.proceeding_type_id) {
|
||||
const exact = candidates.find((r) => r.proceeding_type_id === proj.proceeding_type_id);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
const et = eventTypeByID.get(eventTypeID);
|
||||
if (et?.jurisdiction && et.jurisdiction !== "any") {
|
||||
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
|
||||
const jurMatch = candidates.find((r) => {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
return pt?.jurisdiction === want;
|
||||
});
|
||||
if (jurMatch) return jurMatch;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function currentAutoRule(): DeadlineRule | null {
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) return null;
|
||||
return resolveAutoRuleForType(picked[0]);
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
@@ -227,9 +322,15 @@ function render() {
|
||||
}
|
||||
|
||||
const ruleEl = document.getElementById("deadline-rule-display")!;
|
||||
// t-paliad-258 — display priority:
|
||||
// 1. catalog rule (canonical Name · Citation pattern)
|
||||
// 2. custom_rule_text + Custom badge
|
||||
// 3. legacy rule_code-only (Fristenrechner saves)
|
||||
// 4. "—"
|
||||
if (rule) {
|
||||
const code = rule.rule_code || rule.code || "";
|
||||
ruleEl.textContent = code ? `${code} — ${rule.name}` : rule.name;
|
||||
ruleEl.innerHTML = formatRuleLabelHTML(rule, esc);
|
||||
} else if (deadline.custom_rule_text && deadline.custom_rule_text.trim()) {
|
||||
ruleEl.innerHTML = formatCustomRuleLabelHTML(deadline.custom_rule_text, esc);
|
||||
} else if (deadline.rule_code) {
|
||||
// Fristenrechner-saved deadlines carry rule_code directly without
|
||||
// a rule_id (no rule UUID round-trips through the public API).
|
||||
@@ -353,6 +454,48 @@ function render() {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshRuleAutoDisplay(): void {
|
||||
const panel = document.getElementById("deadline-rule-auto-display");
|
||||
const text = document.getElementById("deadline-rule-auto-text");
|
||||
if (!panel || !text) return;
|
||||
if (ruleMode !== "auto") {
|
||||
panel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
panel.style.display = "";
|
||||
const r = currentAutoRule();
|
||||
if (r) {
|
||||
text.textContent = formatRuleLabel(r);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const fallback = picked.length === 1
|
||||
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
|
||||
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
|
||||
text.textContent = fallback;
|
||||
text.classList.add("rule-auto-text--empty");
|
||||
}
|
||||
|
||||
function applyRuleModeUI(): void {
|
||||
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
const autoPanel = document.getElementById("deadline-rule-auto-display");
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
if (!toggleBtn || !autoPanel || !customInput) return;
|
||||
if (ruleMode === "auto") {
|
||||
autoPanel.style.display = "";
|
||||
customInput.style.display = "none";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
|
||||
} else {
|
||||
autoPanel.style.display = "none";
|
||||
customInput.style.display = "";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
|
||||
}
|
||||
refreshRuleAutoDisplay();
|
||||
}
|
||||
|
||||
function initEdit() {
|
||||
const titleDisplay = document.getElementById("deadline-title-display")!;
|
||||
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
|
||||
@@ -366,6 +509,11 @@ function initEdit() {
|
||||
const etEdit = document.getElementById("deadline-event-types-edit");
|
||||
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
|
||||
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
|
||||
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
|
||||
const ruleDisplay = document.getElementById("deadline-rule-display");
|
||||
const ruleEdit = document.getElementById("deadline-rule-edit");
|
||||
const ruleCustomInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const ruleToggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
|
||||
function enterEdit() {
|
||||
titleDisplay.style.display = "none";
|
||||
@@ -381,6 +529,20 @@ function initEdit() {
|
||||
projectEdit.style.display = "";
|
||||
projectEdit.value = deadline.project_id;
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
|
||||
// t-paliad-258 — show the Auto/Custom rule editor + initialise mode
|
||||
// from the persisted deadline. Display element stays visible so the
|
||||
// user keeps "before / after" context while editing.
|
||||
if (ruleEdit) ruleEdit.style.display = "";
|
||||
if (ruleDisplay) ruleDisplay.style.display = "none";
|
||||
if (deadline?.custom_rule_text && !deadline.rule_id) {
|
||||
ruleMode = "custom";
|
||||
if (ruleCustomInput) ruleCustomInput.value = deadline.custom_rule_text;
|
||||
} else {
|
||||
ruleMode = "auto";
|
||||
if (ruleCustomInput) ruleCustomInput.value = "";
|
||||
}
|
||||
applyRuleModeUI();
|
||||
saveBtn.style.display = "";
|
||||
editBtn.style.display = "none";
|
||||
titleEdit.focus();
|
||||
@@ -399,12 +561,71 @@ function initEdit() {
|
||||
projectEdit.style.display = "none";
|
||||
projectLink.style.display = "";
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
|
||||
if (ruleEdit) ruleEdit.style.display = "none";
|
||||
if (ruleDisplay) ruleDisplay.style.display = "";
|
||||
saveBtn.style.display = "none";
|
||||
editBtn.style.display = "";
|
||||
pendingEditMode = false;
|
||||
}
|
||||
|
||||
// Rule mode toggle (Auto ↔ Custom). The Auto resolver re-runs every
|
||||
// time the Type picker changes, so just-toggling-to-Auto immediately
|
||||
// surfaces a fresh resolution.
|
||||
ruleToggleBtn?.addEventListener("click", () => {
|
||||
ruleMode = ruleMode === "auto" ? "custom" : "auto";
|
||||
applyRuleModeUI();
|
||||
if (ruleMode === "custom") ruleCustomInput?.focus();
|
||||
});
|
||||
|
||||
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
|
||||
// route into pending-edit mode without re-running the edit-button
|
||||
// visibility gate (which hides the button during pending).
|
||||
pendingEnterEdit = () => {
|
||||
pendingEditMode = true;
|
||||
enterEdit();
|
||||
};
|
||||
|
||||
editBtn.addEventListener("click", enterEdit);
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
|
||||
// head = event_type label (if exactly one Typ chip in edit)
|
||||
// || Auto-resolved rule's canonical label (Name · Citation)
|
||||
// || saved rule's canonical label
|
||||
// || custom_rule_text (when in Custom mode + non-empty)
|
||||
// || rule_code-only legacy fallback
|
||||
// || "Neue Frist" fallback
|
||||
// suffix = " — <project.reference>" when not already in head
|
||||
titleDefaultBtn?.addEventListener("click", () => {
|
||||
if (!deadline) return;
|
||||
let head = "";
|
||||
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
|
||||
if (ids.length === 1) {
|
||||
const et = eventTypeByID.get(ids[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
if (!head) {
|
||||
const r = ruleMode === "auto" ? (currentAutoRule() ?? rule) : null;
|
||||
if (r) head = formatRuleLabel(r);
|
||||
}
|
||||
if (!head && ruleMode === "custom") {
|
||||
const txt = ruleCustomInput?.value.trim() || "";
|
||||
if (txt) head = txt;
|
||||
}
|
||||
if (!head && rule) {
|
||||
head = formatRuleLabel(rule);
|
||||
}
|
||||
if (!head && deadline.rule_code) {
|
||||
head = deadline.rule_code;
|
||||
}
|
||||
if (!head) head = t("deadlines.field.title.default_fallback");
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) head = `${head} — ${ref}`;
|
||||
titleEdit.value = head;
|
||||
titleEdit.focus();
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!deadline) return;
|
||||
const newTitle = titleEdit.value.trim();
|
||||
@@ -424,6 +645,48 @@ function initEdit() {
|
||||
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
|
||||
payload.project_id = projectEdit.value;
|
||||
}
|
||||
// t-paliad-258 — rule_set discriminator tells the service this
|
||||
// PATCH carries an Auto/Custom rule change. Both columns are
|
||||
// mutually exclusive at the persistence boundary.
|
||||
payload.rule_set = true;
|
||||
if (ruleMode === "auto") {
|
||||
const r = currentAutoRule();
|
||||
payload.rule_id = r ? r.id : null;
|
||||
payload.custom_rule_text = null;
|
||||
} else {
|
||||
const txt = ruleCustomInput?.value.trim() || "";
|
||||
payload.rule_id = null;
|
||||
payload.custom_rule_text = txt || null;
|
||||
}
|
||||
|
||||
// t-paliad-252 — pending-edit mode routes through the new endpoint
|
||||
// that updates the entity + merges payload into the still-pending
|
||||
// approval_request. Outside pending-edit mode the regular PATCH
|
||||
// path remains the authoritative one (with its existing 409-on-
|
||||
// pending guard).
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: payload }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && (body.message || body.error))
|
||||
|| (t("approvals.withdraw.error") || "Fehler");
|
||||
window.alert(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -501,19 +764,39 @@ function initReopen() {
|
||||
});
|
||||
}
|
||||
|
||||
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
|
||||
// /api/approval-requests/{id}/revoke endpoint (no new server route
|
||||
// needed). After the revoke lands, the entity goes back to
|
||||
// approval_status='approved' and the page reloads to refresh the
|
||||
// in-memory state cleanly.
|
||||
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
|
||||
//
|
||||
// Click flow: open the withdraw warning modal (replaces the old
|
||||
// confirm()). The modal returns one of:
|
||||
//
|
||||
// "edit" — open the edit form in pending-edit mode; Save calls
|
||||
// /api/approval-requests/{id}/edit-entity which keeps the
|
||||
// request pending + merges the new fields into payload
|
||||
// "withdraw" — destructive: call the existing /revoke endpoint
|
||||
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
|
||||
// cancel-delete for DELETE lifecycle)
|
||||
// null — user cancelled; nothing happens
|
||||
function initWithdraw() {
|
||||
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!deadline || !pendingRequest) return;
|
||||
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "deadline",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
btn.disabled = false;
|
||||
pendingEnterEdit?.();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → existing destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -521,14 +804,16 @@ function initWithdraw() {
|
||||
});
|
||||
if (resp.ok) {
|
||||
// Re-fetch the entity so approval_status flips back to 'approved'
|
||||
// and the badge / buttons rerender accordingly.
|
||||
// and the badge / buttons rerender accordingly. For CREATE
|
||||
// lifecycle the entity is gone, so the 404 surfaces as a reload.
|
||||
const r = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (r.ok) {
|
||||
deadline = await r.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
window.location.reload();
|
||||
// CREATE lifecycle deleted the entity — bounce to the list.
|
||||
window.location.href = "/events?type=deadline";
|
||||
}
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
@@ -592,8 +877,14 @@ async function main() {
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
|
||||
if (deadline.rule_id) await loadRule(deadline.rule_id);
|
||||
await Promise.all([
|
||||
loadProject(deadline.project_id),
|
||||
loadAllProjects(),
|
||||
loadPendingRequest(),
|
||||
loadAllRules(),
|
||||
loadProceedingTypes(),
|
||||
]);
|
||||
if (deadline.rule_id) rule = lookupRule(deadline.rule_id);
|
||||
|
||||
// Load event types in parallel; render once ready (the picker re-renders
|
||||
// chips off the cached map, and the display element re-renders on the
|
||||
@@ -614,6 +905,11 @@ async function main() {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
initialIDs: deadline.event_type_ids ?? [],
|
||||
currentUserAdmin: me?.global_role === "global_admin",
|
||||
onChange: () => {
|
||||
// Type change shifts the Auto-resolved rule. Refresh the
|
||||
// read-only display panel (no-op outside edit mode / Custom).
|
||||
refreshRuleAutoDisplay();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
@@ -8,22 +8,21 @@ import {
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { formatRuleLabel } from "./rule-label";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
let eventTypesByID = new Map<string, EventType>();
|
||||
// expandedOverride flips to true when the user clicks "Anderen Typ
|
||||
// wählen" on the collapsed inline summary. Sticky for the rest of the
|
||||
// form session — cleared only when the user reverts the rule to "Keine
|
||||
// Regel". When true, the picker stays visible regardless of whether
|
||||
// the chip matches the rule's canonical default.
|
||||
let expandedOverride = false;
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
// Used by the Type→Rule resolver to narrow rule candidates to the
|
||||
// project's own proceeding when one applies. Optional because clients
|
||||
// and matter-level projects don't carry a proceeding type.
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
@@ -32,23 +31,37 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-165 — canonical event_type for this rule's concept,
|
||||
// hydrated server-side from paliad.deadline_concept_event_types.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule.
|
||||
legal_source?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
sequence_order?: number;
|
||||
// t-paliad-165 — canonical event_type for the rule's concept. The
|
||||
// catalog is indexed by it so we can resolve Type → canonical Rule.
|
||||
concept_default_event_type_id?: string | null;
|
||||
}
|
||||
|
||||
// Rules indexed by id so the Regel-change handler can look up the
|
||||
// concept's canonical event_type without re-fetching.
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
jurisdiction: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
||||
// the picker still reflects the rule's suggestion (replace silently on
|
||||
// new rule pick) or whether the user has manually edited (leave alone,
|
||||
// surface the mismatch warning instead).
|
||||
let lastAutoFilledEventTypeID: string | null = null;
|
||||
// Rule mode (t-paliad-258 / m/paliad#89). The form has two states:
|
||||
// auto — rule_id resolved from the chosen event_type, rendered
|
||||
// read-only as "Auto: Name · Citation".
|
||||
// custom — free-text input; submits as custom_rule_text on the API.
|
||||
type RuleMode = "auto" | "custom";
|
||||
let ruleMode: RuleMode = "auto";
|
||||
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
let projectsByID = new Map<string, Project>();
|
||||
|
||||
let preselectedProjectID = "";
|
||||
let preselectedProjectIDLocal = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
@@ -62,6 +75,13 @@ function showError(msg: string) {
|
||||
el.className = "form-msg form-msg-error";
|
||||
}
|
||||
|
||||
function proceedingLabel(pt: ProceedingType | undefined): string {
|
||||
if (!pt) return "";
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && pt.name_en) ? pt.name_en : pt.name;
|
||||
return `${pt.jurisdiction} — ${name}`;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
|
||||
const hint = document.getElementById("deadline-project-empty-hint")!;
|
||||
@@ -69,6 +89,7 @@ async function loadProjects() {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (!resp.ok) return;
|
||||
const projects: Project[] = await resp.json();
|
||||
projectsByID = new Map(projects.map((p) => [p.id, p]));
|
||||
if (projects.length === 0) {
|
||||
hint.style.display = "";
|
||||
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
|
||||
@@ -82,7 +103,7 @@ async function loadProjects() {
|
||||
const ref = p.reference || "";
|
||||
const indent = projectIndent(p.path);
|
||||
options.push(
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} — ${esc(p.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
@@ -91,122 +112,166 @@ async function loadProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProceedingTypes() {
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db");
|
||||
if (!resp.ok) return;
|
||||
const types: ProceedingType[] = await resp.json();
|
||||
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
// Optional: load rules so user can attach. We pull all rules; small set.
|
||||
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
|
||||
try {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
const rules: DeadlineRule[] = await resp.json();
|
||||
rulesByID = new Map(rules.map((r) => [r.id, r]));
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
for (const r of rules) {
|
||||
const code = r.rule_code || r.code || "";
|
||||
const label = code ? `${code} \u2014 ${r.name}` : r.name;
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
} catch {
|
||||
/* non-fatal — rule select stays at "no rule" */
|
||||
/* non-fatal — rule display falls back to "—" */
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
// resolveAutoRuleForType picks the best-match catalog rule for the
|
||||
// chosen event type, scoring by:
|
||||
// 1. project's proceeding_type_id (if known) — exact match wins,
|
||||
// 2. otherwise event_type.jurisdiction matches the rule's proceeding's
|
||||
// jurisdiction (EPA→EPO canonicalised),
|
||||
// 3. otherwise the first candidate in canonical sequence_order.
|
||||
//
|
||||
// collapsed: rule selected + canonical event_type known + picker
|
||||
// contains exactly [default] + user hasn't clicked "Anderen Typ
|
||||
// wählen". Hides the chip cluster, surfaces a single inline
|
||||
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
|
||||
// override link.
|
||||
//
|
||||
// expanded: every other case — no rule, no default for the rule,
|
||||
// picker has been edited, or expandedOverride is sticky after the
|
||||
// user clicked the override link. Picker visible; mismatch warning
|
||||
// surfaces yellow when the rule expected a different event_type.
|
||||
function refreshRuleView(): void {
|
||||
const collapsed = document.getElementById("deadline-event-type-collapsed");
|
||||
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
const warn = document.getElementById("deadline-event-type-rule-mismatch");
|
||||
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
|
||||
// Returns null when no rule maps. Callers render that as "no Auto rule
|
||||
// available" so the user can flip to Custom or pick a different Type.
|
||||
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
|
||||
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
if (project?.proceeding_type_id) {
|
||||
const exact = candidates.find((r) => r.proceeding_type_id === project.proceeding_type_id);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
const et = eventTypesByID.get(eventTypeID);
|
||||
if (et?.jurisdiction && et.jurisdiction !== "any") {
|
||||
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
|
||||
const jurMatch = candidates.find((r) => {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
return pt?.jurisdiction === want;
|
||||
});
|
||||
if (jurMatch) return jurMatch;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
// currentAutoRule returns the catalog rule the Auto mode would resolve
|
||||
// to for the current form state, or null when no Type is picked or no
|
||||
// rule maps. Centralised so the Auto display, submitForm, and the
|
||||
// Standardtitel button all agree on the same resolution.
|
||||
function currentAutoRule(): DeadlineRule | null {
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) return null;
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
return resolveAutoRuleForType(picked[0], projectID);
|
||||
}
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
||||
|
||||
if (wantsCollapsed) {
|
||||
const et = eventTypesByID.get(expected!);
|
||||
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
|
||||
collapsed.style.display = "";
|
||||
pickerHost.style.display = "none";
|
||||
warn.style.display = "none";
|
||||
// refreshRuleAutoDisplay updates the read-only Auto display panel to
|
||||
// reflect the rule that would be saved in Auto mode. Hides itself when
|
||||
// the user is in Custom mode (the input takes its place).
|
||||
function refreshRuleAutoDisplay(): void {
|
||||
const panel = document.getElementById("deadline-rule-auto-display");
|
||||
const text = document.getElementById("deadline-rule-auto-text");
|
||||
if (!panel || !text) return;
|
||||
if (ruleMode !== "auto") {
|
||||
panel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
panel.style.display = "";
|
||||
const rule = currentAutoRule();
|
||||
if (rule) {
|
||||
text.textContent = formatRuleLabel(rule);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const fallback = picked.length === 1
|
||||
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
|
||||
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
|
||||
text.textContent = fallback;
|
||||
text.classList.add("rule-auto-text--empty");
|
||||
}
|
||||
|
||||
collapsed.style.display = "none";
|
||||
pickerHost.style.display = "";
|
||||
// Mismatch warning: rule expected an event_type AND the picker
|
||||
// doesn't contain it. (When the picker is empty + no override, no
|
||||
// warning — user is free to leave it blank.)
|
||||
if (expected && picked.length > 0 && !picked.includes(expected)) {
|
||||
warn.style.display = "";
|
||||
function applyRuleModeUI(): void {
|
||||
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
const autoPanel = document.getElementById("deadline-rule-auto-display");
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
if (!toggleBtn || !autoPanel || !customInput) return;
|
||||
if (ruleMode === "auto") {
|
||||
autoPanel.style.display = "";
|
||||
customInput.style.display = "none";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
|
||||
} else {
|
||||
warn.style.display = "none";
|
||||
autoPanel.style.display = "none";
|
||||
customInput.style.display = "";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
|
||||
}
|
||||
refreshRuleAutoDisplay();
|
||||
}
|
||||
|
||||
function setRuleMode(mode: RuleMode): void {
|
||||
ruleMode = mode;
|
||||
applyRuleModeUI();
|
||||
if (mode === "custom") {
|
||||
const input = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
input?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// applyRuleAutoFill replaces the picker silently when it still reflects
|
||||
// the previous rule's suggestion (or is empty); leaves a manually-edited
|
||||
// picker alone. Called whenever the Regel select changes.
|
||||
function applyRuleAutoFill(): void {
|
||||
if (!eventTypePicker) return;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const current = eventTypePicker.getIDs();
|
||||
// computeDefaultTitle — t-paliad-251 Part 4. Priority order picks the head:
|
||||
// 1. event_type label (when exactly one Typ chip is set)
|
||||
// 2. canonical rule name (when Auto resolves to a rule)
|
||||
// 3. custom rule text (when in Custom mode)
|
||||
// 4. proceeding type name (when project carries one)
|
||||
// 5. fallback i18n key
|
||||
// Suffix: " — <project-reference>" when not already in head.
|
||||
function computeDefaultTitle(): string {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
// Reset the override on transition to "Keine Regel" — fresh form
|
||||
// session. Otherwise expandedOverride stays sticky.
|
||||
if (ruleID === "") {
|
||||
expandedOverride = false;
|
||||
let head = "";
|
||||
if (picked.length === 1) {
|
||||
const et = eventTypesByID.get(picked[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
|
||||
const pickerStillReflectsLastSuggestion =
|
||||
lastAutoFilledEventTypeID !== null &&
|
||||
current.length === 1 &&
|
||||
current[0] === lastAutoFilledEventTypeID;
|
||||
const pickerIsEmpty = current.length === 0;
|
||||
|
||||
if (expected) {
|
||||
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
|
||||
eventTypePicker.setIDs([expected]);
|
||||
lastAutoFilledEventTypeID = expected;
|
||||
if (!head) {
|
||||
if (ruleMode === "auto") {
|
||||
const rule = currentAutoRule();
|
||||
if (rule) head = formatRuleLabel(rule);
|
||||
} else {
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const txt = customInput?.value.trim() || "";
|
||||
if (txt) head = txt;
|
||||
}
|
||||
} else if (pickerStillReflectsLastSuggestion) {
|
||||
// New rule has no canonical event_type — clear the stale auto-fill
|
||||
// so the picker doesn't carry a chip from the old rule.
|
||||
eventTypePicker.setIDs([]);
|
||||
lastAutoFilledEventTypeID = null;
|
||||
}
|
||||
refreshRuleView();
|
||||
}
|
||||
if (!head && project?.proceeding_type_id) {
|
||||
const pt = proceedingTypesByID.get(project.proceeding_type_id);
|
||||
if (pt) head = proceedingLabel(pt);
|
||||
}
|
||||
if (!head) {
|
||||
head = t("deadlines.field.title.default_fallback");
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
||||
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) {
|
||||
return `${head} — ${ref}`;
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
async function submitForm(e: Event) {
|
||||
@@ -217,7 +282,6 @@ async function submitForm(e: Event) {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
|
||||
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
|
||||
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
|
||||
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
|
||||
|
||||
if (!projectID || !title || !due) {
|
||||
@@ -234,7 +298,15 @@ async function submitForm(e: Event) {
|
||||
due_date: due,
|
||||
source: "manual",
|
||||
};
|
||||
if (ruleID) payload.rule_id = ruleID;
|
||||
// Rule field: Auto resolves to rule_id, Custom sends the free text.
|
||||
if (ruleMode === "auto") {
|
||||
const rule = currentAutoRule();
|
||||
if (rule) payload.rule_id = rule.id;
|
||||
} else {
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const txt = customInput?.value.trim() || "";
|
||||
if (txt) payload.custom_rule_text = txt;
|
||||
}
|
||||
if (notes) payload.notes = notes;
|
||||
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
|
||||
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
|
||||
@@ -252,8 +324,8 @@ async function submitForm(e: Event) {
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
if (preselectedProjectID) {
|
||||
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
if (preselectedProjectIDLocal) {
|
||||
window.location.href = `/projects/${preselectedProjectIDLocal}/deadlines`;
|
||||
} else {
|
||||
window.location.href = `/deadlines/${created.id}`;
|
||||
}
|
||||
@@ -275,6 +347,16 @@ function detectPreselect() {
|
||||
if (fromQuery) preselectedProjectID = fromQuery;
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
||||
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
}
|
||||
preselectedProjectIDLocal = preselectedProjectID;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
@@ -288,8 +370,6 @@ async function loadMe() {
|
||||
|
||||
// t-paliad-154 — fetch the effective approval policy for (project,
|
||||
// deadline, create) and reveal the form-time hint when it applies.
|
||||
// Hidden when no policy applies. Re-runs on project change so the hint
|
||||
// updates if the user picks a different project mid-form.
|
||||
async function refreshApprovalHint(): Promise<void> {
|
||||
const hint = document.getElementById("deadline-approval-hint");
|
||||
const text = document.getElementById("deadline-approval-hint-text");
|
||||
@@ -308,7 +388,6 @@ async function refreshApprovalHint(): Promise<void> {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
// t-paliad-160 split-grammar (with M1 legacy fallback).
|
||||
const eff = await resp.json() as {
|
||||
requires_approval?: boolean;
|
||||
min_role?: string | null;
|
||||
@@ -343,44 +422,51 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Default due to today
|
||||
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
|
||||
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
|
||||
await Promise.all([loadProjects(), loadRules(), loadMe()]);
|
||||
|
||||
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
|
||||
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => refreshRuleView(),
|
||||
onChange: () => {
|
||||
// Type change shifts which Auto rule resolves; re-render the
|
||||
// read-only Auto display panel.
|
||||
refreshRuleAutoDisplay();
|
||||
},
|
||||
});
|
||||
}
|
||||
// t-paliad-165 follow-up — preload event_types so the collapsed
|
||||
// summary can render the type's label inline without an extra round
|
||||
// trip when the user picks a Regel.
|
||||
|
||||
// Preload event_types for the Auto display + Standardtitel resolver.
|
||||
fetchEventTypes()
|
||||
.then((types) => {
|
||||
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
||||
refreshRuleView();
|
||||
refreshRuleAutoDisplay();
|
||||
})
|
||||
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
|
||||
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
|
||||
// concept's canonical event_type, when the picker hasn't been
|
||||
// manually edited away from the previous rule's suggestion.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
applyRuleAutoFill();
|
||||
.catch(() => {/* non-fatal */});
|
||||
|
||||
// Rule mode toggle.
|
||||
document.getElementById("deadline-rule-mode-toggle")?.addEventListener("click", () => {
|
||||
setRuleMode(ruleMode === "auto" ? "custom" : "auto");
|
||||
});
|
||||
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
||||
// visible even when the chip still matches the rule's default.
|
||||
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
|
||||
expandedOverride = true;
|
||||
refreshRuleView();
|
||||
// Move focus into the picker's search box so the user can type
|
||||
// immediately without an extra click.
|
||||
const search = document.querySelector<HTMLInputElement>(
|
||||
"#deadline-event-types .event-type-search",
|
||||
);
|
||||
search?.focus();
|
||||
});
|
||||
// Wire approval-hint refresh: on first render + on project change.
|
||||
|
||||
applyRuleModeUI();
|
||||
|
||||
// Approval-hint refresh: on first render + on project change.
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
||||
void refreshApprovalHint();
|
||||
// Project change can shift which Auto rule resolves (via the
|
||||
// project's proceeding_type_id).
|
||||
refreshRuleAutoDisplay();
|
||||
});
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
|
||||
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
|
||||
if (!titleInput) return;
|
||||
const derived = computeDefaultTitle();
|
||||
if (derived) titleInput.value = derived;
|
||||
titleInput.focus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -686,6 +686,33 @@ export function openBrowseEventTypesModal(
|
||||
return new Promise<string[] | null>((resolve) => {
|
||||
let selected = new Set<string>(opts.initialIDs);
|
||||
let searchQuery = "";
|
||||
// t-paliad-251 — court-type filter chips. `null` = "Alle" (any
|
||||
// jurisdiction). Any non-null value matches event_types.jurisdiction;
|
||||
// "any" is mapped to NULL/missing rows via jurisdictionMatches().
|
||||
let activeJurisdiction: string | null = null;
|
||||
|
||||
// Surface every jurisdiction present in the data — "any" stays bucketed
|
||||
// separately so users still have a "show generic-only" chip. EPA is
|
||||
// canonicalised to EPO in event_types (see mig 074); the chip label
|
||||
// shows EPA to match the legal vocabulary the lawyers use.
|
||||
const jurisdictionsPresent = new Set<string>();
|
||||
for (const et of opts.types) {
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
if (j) jurisdictionsPresent.add(j);
|
||||
}
|
||||
const JURISDICTION_ORDER = ["UPC", "EPO", "DPMA", "DE", "any"];
|
||||
const chipJurisdictions = JURISDICTION_ORDER.filter((j) => jurisdictionsPresent.has(j));
|
||||
// Any jurisdiction in the data that isn't in our ordered list lands at
|
||||
// the end so the chip row never silently drops a court flavour.
|
||||
for (const j of jurisdictionsPresent) {
|
||||
if (!chipJurisdictions.includes(j)) chipJurisdictions.push(j);
|
||||
}
|
||||
|
||||
function chipLabel(j: string): string {
|
||||
if (j === "EPO") return "EPA";
|
||||
if (j === "any") return t("event_types.browse.jurisdiction.none");
|
||||
return j;
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay event-type-browse-overlay";
|
||||
@@ -694,6 +721,15 @@ export function openBrowseEventTypesModal(
|
||||
<div class="event-type-browse-header">
|
||||
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
|
||||
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
|
||||
<div class="event-type-browse-chips" data-role="chips" role="group" aria-label="${esc(t("event_types.browse.jurisdiction.filter_label"))}">
|
||||
<button type="button" class="event-type-browse-chip event-type-browse-chip--active" data-jurisdiction="" data-role="chip-all">${esc(t("event_types.browse.jurisdiction.all"))}</button>
|
||||
${chipJurisdictions
|
||||
.map(
|
||||
(j) =>
|
||||
`<button type="button" class="event-type-browse-chip" data-jurisdiction="${esc(j)}">${esc(chipLabel(j))}</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
|
||||
<div class="event-type-browse-actions">
|
||||
@@ -711,6 +747,7 @@ export function openBrowseEventTypesModal(
|
||||
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
|
||||
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
|
||||
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
|
||||
const chipButtons = overlay.querySelectorAll<HTMLButtonElement>(".event-type-browse-chip");
|
||||
|
||||
const groups = groupByCategory(opts.types);
|
||||
|
||||
@@ -721,6 +758,12 @@ export function openBrowseEventTypesModal(
|
||||
return j;
|
||||
}
|
||||
|
||||
function jurisdictionMatches(et: EventType): boolean {
|
||||
if (activeJurisdiction === null) return true;
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
return j === activeJurisdiction;
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
countEl.textContent = t("event_types.browse.selected_count").replace(
|
||||
"{n}",
|
||||
@@ -731,6 +774,7 @@ export function openBrowseEventTypesModal(
|
||||
function renderList() {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const matches = (et: EventType) => {
|
||||
if (!jurisdictionMatches(et)) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
et.label_de.toLowerCase().includes(q) ||
|
||||
@@ -783,6 +827,16 @@ export function openBrowseEventTypesModal(
|
||||
renderList();
|
||||
});
|
||||
|
||||
chipButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const raw = btn.dataset.jurisdiction ?? "";
|
||||
activeJurisdiction = raw === "" ? null : raw;
|
||||
chipButtons.forEach((b) => b.classList.remove("event-type-browse-chip--active"));
|
||||
btn.classList.add("event-type-browse-chip--active");
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
|
||||
function close(value: string[] | null) {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
overlay.remove();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
|
||||
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
|
||||
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
|
||||
@@ -66,6 +67,9 @@ interface EventListItem {
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
// t-paliad-258 — free-text rule label when the deadline was created
|
||||
// via the Custom rule path. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
event_type_ids?: string[];
|
||||
|
||||
// appointment-only
|
||||
@@ -264,13 +268,26 @@ function urgencyClass(item: EventListItem): string {
|
||||
|
||||
function ruleDisplay(item: EventListItem): string {
|
||||
if (item.type !== "deadline") return "";
|
||||
// Prefer the saved citation (RoP.023, R.151) over the rule name —
|
||||
// REGEL is meant for the legal reference, not the rule's display
|
||||
// name (which is the title column's job).
|
||||
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
|
||||
const lang = getLang();
|
||||
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
|
||||
if (localized && localized.trim()) return esc(localized);
|
||||
// t-paliad-258 addendum — canonical display contract: Name primary,
|
||||
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
|
||||
// Custom rules render the lawyer's free text + a "Custom" badge.
|
||||
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
|
||||
// show the bare citation as last-resort fallback.
|
||||
const hasName = (item.rule_name && item.rule_name.trim()) ||
|
||||
(item.rule_name_en && item.rule_name_en.trim());
|
||||
if (hasName || (item.rule_code && item.rule_code.trim())) {
|
||||
return formatRuleLabelHTML(
|
||||
{
|
||||
name: item.rule_name || "",
|
||||
name_en: item.rule_name_en,
|
||||
rule_code: item.rule_code,
|
||||
},
|
||||
esc,
|
||||
);
|
||||
}
|
||||
if (item.custom_rule_text && item.custom_rule_text.trim()) {
|
||||
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@
|
||||
// New classes are scoped under .filter-bar-* so they don't bleed.
|
||||
|
||||
import { t, tDyn, type I18nKey } from "../i18n";
|
||||
import type { BarState, AxisKey } from "./types";
|
||||
import { mountDateRangePicker } from "../date-range-picker";
|
||||
import {
|
||||
ALL_HORIZONS as DRP_ALL_HORIZONS,
|
||||
type TimeHorizon as DRPTimeHorizon,
|
||||
type TimeSpec as DRPTimeSpec,
|
||||
} from "../date-range-picker-pure";
|
||||
import type { BarState, AxisKey, InboxFocus } from "./types";
|
||||
|
||||
export interface AxisCtx {
|
||||
// Read the current value for this axis.
|
||||
@@ -47,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
|
||||
@@ -57,60 +65,63 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// time — chip cluster (presets + Anpassen)
|
||||
// time — symmetric date-range picker (t-paliad-248, replaces the t-163
|
||||
// chip-cluster + disabled Anpassen stub). The picker emits a TimeSpec
|
||||
// (horizon + optional custom from/to); the bar patches that onto
|
||||
// BarState.time directly.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
|
||||
|
||||
const TIME_PRESET_LABELS: Record<TimeHorizonValue, I18nKey> = {
|
||||
next_7d: "views.bar.time.next_7d",
|
||||
next_30d: "views.bar.time.next_30d",
|
||||
next_90d: "views.bar.time.next_90d",
|
||||
past_7d: "views.bar.time.past_7d",
|
||||
past_30d: "views.bar.time.past_30d",
|
||||
past_90d: "views.bar.time.past_90d",
|
||||
any: "views.bar.time.any",
|
||||
all: "views.bar.time.all",
|
||||
custom: "views.bar.time.custom",
|
||||
};
|
||||
|
||||
// Default chip set when the surface doesn't override. Matches the
|
||||
// forward-leaning bias of the legacy filter-bar default (the universal
|
||||
// substrate is more often used for "what's coming up" than "what just
|
||||
// happened") but now covers the full symmetric fan plus past_30d for
|
||||
// quick recent-history lookups.
|
||||
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
|
||||
"next_7d", "next_30d", "next_90d", "past_30d", "any",
|
||||
"past_30d", "past_7d", "any", "next_7d", "next_30d", "next_90d", "custom",
|
||||
];
|
||||
|
||||
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {
|
||||
const wrap = group("views.bar.label.time");
|
||||
const row = chipRow();
|
||||
const presets = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
|
||||
// "any" / "all" are both unbounded — clearing state is the cleanest
|
||||
// representation, so each maps to "no overlay" rather than a stored
|
||||
// horizon. The chip's active state then keys off "no time set".
|
||||
const current = ctx.get("time")?.horizon ?? "any";
|
||||
for (const preset of presets) {
|
||||
if (preset === "custom") continue; // custom rendered separately below
|
||||
const isUnbounded = preset === "any" || preset === "all";
|
||||
const isActive = isUnbounded
|
||||
? !ctx.get("time")
|
||||
: preset === current;
|
||||
const chip = chipBtn(t(TIME_PRESET_LABELS[preset]), isActive);
|
||||
chip.addEventListener("click", () => {
|
||||
if (isUnbounded) {
|
||||
const presetSource = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
|
||||
// The picker's pure module owns the complete chip set; we narrow it
|
||||
// here to whatever this surface declares (preserving the surface's
|
||||
// chip order so timePresets remains the override knob it always was).
|
||||
const presets: DRPTimeHorizon[] = presetSource.flatMap((p) =>
|
||||
DRP_ALL_HORIZONS.includes(p as DRPTimeHorizon) ? [p as DRPTimeHorizon] : [],
|
||||
);
|
||||
|
||||
const current = ctx.get("time");
|
||||
const initialValue: DRPTimeSpec = current
|
||||
? { horizon: current.horizon as DRPTimeHorizon, from: current.from, to: current.to }
|
||||
: { horizon: "any" };
|
||||
|
||||
const picker = mountDateRangePicker({
|
||||
value: initialValue,
|
||||
onChange(next) {
|
||||
// The bar treats `any` as "no time overlay" (matches the legacy
|
||||
// chip-cluster's behaviour) so the BarState stays minimal when
|
||||
// the user lands on the centre ALLES button.
|
||||
if (next.horizon === "any") {
|
||||
ctx.patch({ time: undefined });
|
||||
} else {
|
||||
ctx.patch({ time: { horizon: preset } });
|
||||
return;
|
||||
}
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
// Custom range — placeholder chip; opens a small popover with two
|
||||
// <input type="date"> in Phase 2. For Phase 1 we render the chip
|
||||
// disabled with a tooltip so the affordance is discoverable.
|
||||
const customChip = chipBtn(t("views.bar.time.custom"), current === "custom");
|
||||
customChip.classList.add("filter-bar-chip-pending");
|
||||
customChip.title = t("views.bar.time.custom.coming_soon");
|
||||
customChip.disabled = true;
|
||||
row.appendChild(customChip);
|
||||
wrap.appendChild(row);
|
||||
ctx.patch({
|
||||
time: {
|
||||
horizon: next.horizon as TimeHorizonValue,
|
||||
from: next.horizon === "custom" ? next.from : undefined,
|
||||
to: next.horizon === "custom" ? next.to : undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
defaultHorizon: "any",
|
||||
presets,
|
||||
surface: "filter-bar.time",
|
||||
labelPrefix: t("views.bar.label.time"),
|
||||
});
|
||||
|
||||
wrap.appendChild(picker.element);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -484,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,10 +72,20 @@ 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 {
|
||||
horizon: "next_7d" | "next_30d" | "next_90d" | "past_7d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
|
||||
// Mirrors internal/services/filter_spec.go TimeHorizon. t-paliad-248
|
||||
// added the symmetric 1d / 14d / all chips on each side; the union
|
||||
// here is the wire-shape the URL codec parses and the picker emits.
|
||||
horizon:
|
||||
| "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "all" | "custom";
|
||||
from?: string; // ISO 8601 — only when horizon === "custom"
|
||||
to?: string;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@ describe("filter-bar/url-codec", () => {
|
||||
});
|
||||
|
||||
test("time horizon round-trips", () => {
|
||||
for (const h of ["next_7d", "next_30d", "next_90d", "past_30d", "past_90d", "any", "all"] as const) {
|
||||
// Includes the t-paliad-248 symmetric additions (1d / 14d / all on each side).
|
||||
for (const h of [
|
||||
"next_1d", "next_7d", "next_14d", "next_30d", "next_90d", "next_all",
|
||||
"past_1d", "past_7d", "past_14d", "past_30d", "past_90d", "past_all",
|
||||
"any", "all",
|
||||
] as const) {
|
||||
expect(roundTrip({ time: { horizon: h } })).toEqual({ time: { horizon: h } });
|
||||
}
|
||||
});
|
||||
@@ -99,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,16 +179,31 @@ 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 {
|
||||
switch (s) {
|
||||
case "next_1d":
|
||||
case "next_7d":
|
||||
case "next_14d":
|
||||
case "next_30d":
|
||||
case "next_90d":
|
||||
case "next_all":
|
||||
case "past_1d":
|
||||
case "past_7d":
|
||||
case "past_14d":
|
||||
case "past_30d":
|
||||
case "past_90d":
|
||||
case "past_all":
|
||||
case "any":
|
||||
case "all":
|
||||
case "custom":
|
||||
|
||||
@@ -429,8 +429,13 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
// Pass the chip-strip perspective through as `side` so the column
|
||||
// bucketer keeps the user's own party on the left (Unsere Seite) —
|
||||
// t-paliad-257: the old Proaktiv/Reaktiv labels lied when the user
|
||||
// was on the defendant side, the new labels demand we route the
|
||||
// user's party into the `ours` column.
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
? renderColumnsBody(data, { editable: true, showNotes, side: currentPerspective })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
|
||||
@@ -27,6 +27,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.glossar": "Glossar",
|
||||
"nav.gebuehrentabellen": "Geb\u00fchrentabellen",
|
||||
"nav.checklisten": "Checklisten",
|
||||
"nav.submissions": "Schriftsätze",
|
||||
"nav.gerichte": "Gerichte",
|
||||
"nav.logout": "Abmelden",
|
||||
"nav.akten": "Akten",
|
||||
@@ -301,9 +302,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
"deadlines.view.columns": "Spalten",
|
||||
"deadlines.notes.show": "Hinweise anzeigen",
|
||||
"deadlines.col.proactive": "Proaktiv",
|
||||
"deadlines.col.ours": "Unsere Seite",
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.reactive": "Reaktiv",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
"deadlines.col.both": "Beide Parteien",
|
||||
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||
@@ -416,6 +417,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
|
||||
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
|
||||
"deadlines.side.label": "Seite:",
|
||||
"deadlines.side.claimant": "Klägerseite",
|
||||
"deadlines.side.defendant": "Beklagtenseite",
|
||||
"deadlines.side.both": "Beide",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -873,11 +882,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.title.placeholder": "z.\u202fB. Klageerwiderung einreichen",
|
||||
"deadlines.field.due": "F\u00e4lligkeitsdatum",
|
||||
"deadlines.field.rule": "Regel (optional)",
|
||||
"deadlines.field.rule.none": "Keine Regel",
|
||||
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
|
||||
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
|
||||
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
|
||||
"deadlines.field.rule.override": "Anderen Typ wählen",
|
||||
"deadlines.field.rule.auto_badge": "Auto",
|
||||
"deadlines.field.rule.auto_no_match": "Keine Regel zur gewählten Verfahrenshandlung",
|
||||
"deadlines.field.rule.auto_pick_type": "Wählen Sie zuerst eine Verfahrenshandlung",
|
||||
"deadlines.field.rule.custom_badge": "Eigen",
|
||||
"deadlines.field.rule.custom_placeholder": "z.B. interner Review-Termin, Mandantengespräch",
|
||||
"deadlines.field.rule.mode.toggle_to_auto": "Zurück zu Auto",
|
||||
"deadlines.field.rule.mode.toggle_to_custom": "Eigene Regel eingeben",
|
||||
"deadlines.field.title.default_btn": "Standardtitel",
|
||||
"deadlines.field.title.default_fallback": "Neue Frist",
|
||||
"deadlines.field.notes": "Notizen (optional)",
|
||||
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
|
||||
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
|
||||
@@ -1425,10 +1438,16 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notizen",
|
||||
"projects.detail.tab.checklisten": "Checklisten",
|
||||
"projects.detail.tab.submissions": "Schriftsätze",
|
||||
"projects.detail.tab.settings": "Verwaltung",
|
||||
"projects.detail.export.button": "Daten exportieren",
|
||||
"projects.detail.export.tooltip": "Daten dieses Projekts (mit Unter-Projekten) als Excel + JSON + CSV herunterladen.",
|
||||
"projects.detail.submissions.empty": "Für dieses Verfahren sind keine Schriftsätze hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt. Bitte im Projekt bearbeiten.",
|
||||
"projects.detail.settings.export.heading": "Daten exportieren",
|
||||
"projects.detail.settings.export.description": "Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.",
|
||||
"projects.detail.settings.archive.heading": "Projekt archivieren",
|
||||
"projects.detail.settings.archive.description": "Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).",
|
||||
"projects.detail.settings.archive.cta": "Bearbeiten öffnen",
|
||||
"projects.detail.submissions.empty": "Es sind aktuell keine Schriftsatzvorlagen hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.",
|
||||
"projects.detail.submissions.empty.no_proceeding.cta": "Projekt bearbeiten",
|
||||
"projects.detail.submissions.col.name": "Schriftsatz",
|
||||
"projects.detail.submissions.col.party": "Partei",
|
||||
@@ -1436,7 +1455,50 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generieren",
|
||||
"projects.detail.submissions.action.no_template": "Keine Vorlage",
|
||||
"projects.detail.submissions.action.edit": "Bearbeiten",
|
||||
"projects.detail.submissions.hint": "Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.",
|
||||
// t-paliad-238 — dedicated draft editor page.
|
||||
"submissions.draft.title": "Schriftsatz bearbeiten — Paliad",
|
||||
"submissions.draft.back": "← Zurück zum Projekt",
|
||||
"submissions.draft.loading": "Lädt…",
|
||||
"submissions.draft.notfound": "Schriftsatz nicht gefunden oder keine Berechtigung.",
|
||||
"submissions.draft.action.export": "Als .docx exportieren",
|
||||
"submissions.draft.action.new": "+ Neuer Entwurf",
|
||||
"submissions.draft.action.delete": "Löschen",
|
||||
"submissions.draft.switcher.label": "Entwurf",
|
||||
"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-240 — global Schriftsätze drafts index page.
|
||||
"submissions.index.title": "Schriftsätze — Paliad",
|
||||
"submissions.index.heading": "Schriftsätze",
|
||||
"submissions.index.subtitle": "Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.",
|
||||
"submissions.index.loading": "Lädt…",
|
||||
"submissions.index.empty": "Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.",
|
||||
"submissions.index.empty.cta": "+ Neuer Entwurf",
|
||||
"submissions.index.error": "Schriftsätze konnten nicht geladen werden.",
|
||||
"submissions.index.col.project": "Projekt",
|
||||
"submissions.index.col.submission": "Schriftsatz",
|
||||
"submissions.index.col.draft": "Entwurf",
|
||||
"submissions.index.col.updated": "Zuletzt geändert",
|
||||
"submissions.index.action.new": "+ Neuer Entwurf",
|
||||
// t-paliad-243 — global Schriftsatz picker (/submissions/new).
|
||||
"submissions.new.title": "Neuer Schriftsatz — Paliad",
|
||||
"submissions.new.back": "← Zurück zur Übersicht",
|
||||
"submissions.new.heading": "Neuer Schriftsatz",
|
||||
"submissions.new.subtitle": "Wählen Sie eine Vorlage. Optional verknüpfen Sie den Entwurf mit einem Projekt — sonst füllen Sie alle Variablen manuell.",
|
||||
"submissions.new.search.placeholder": "Suche nach Schriftsatz, Code oder Norm…",
|
||||
"submissions.new.loading": "Lädt…",
|
||||
"submissions.new.error": "Katalog konnte nicht geladen werden.",
|
||||
"submissions.new.col.name": "Schriftsatz",
|
||||
"submissions.new.col.party": "Partei",
|
||||
"submissions.new.col.source": "Rechtsgrundlage",
|
||||
"submissions.new.col.actions": "Entwurf starten",
|
||||
"submissions.new.empty.filtered": "Keine passenden Schriftsätze. Filter zurücksetzen.",
|
||||
"submissions.new.picker.title": "Projekt wählen",
|
||||
"submissions.new.picker.placeholder": "Projekt suchen (Titel oder Aktenzeichen)…",
|
||||
"submissions.new.picker.loading": "Lädt Projekte…",
|
||||
"submissions.new.picker.empty": "Keine sichtbaren Projekte.",
|
||||
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
|
||||
"projects.detail.verlauf.loadMore": "Mehr laden",
|
||||
// SmartTimeline (t-paliad-171, Slice 1).
|
||||
@@ -2177,6 +2239,20 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"inbox.empty.admin_nudge.title": "Noch keine Genehmigungspflichten konfiguriert?",
|
||||
"inbox.empty.admin_nudge.body": "Lege fest, welche Lifecycle-Events 4-Augen-Prüfung erfordern.",
|
||||
"inbox.empty.admin_nudge.cta": "Genehmigungspflichten konfigurieren",
|
||||
"inbox.title.feed": "Inbox — Paliad",
|
||||
"inbox.heading.feed": "Inbox",
|
||||
"inbox.subtitle.feed": "Neuigkeiten zu Ihren Projekten und offene Genehmigungen.",
|
||||
"inbox.action.mark_all_seen": "Alles als gelesen markieren",
|
||||
"inbox.action.open": "Öffnen",
|
||||
"inbox.empty.feed": "Keine Neuigkeiten in den letzten 30 Tagen.",
|
||||
"views.bar.label.unread_only": "Lesestatus",
|
||||
"views.bar.unread_only.on": "Nur ungelesen",
|
||||
"views.bar.unread_only.off": "Alle",
|
||||
"views.bar.label.inbox_focus": "Anzeigen",
|
||||
"views.bar.inbox_focus.alles": "Alles",
|
||||
"views.bar.inbox_focus.genehmigungen": "Nur Genehmigungen",
|
||||
"views.bar.inbox_focus.plus_termine": "+ Termine",
|
||||
"views.bar.inbox_focus.plus_fristen": "+ Fristen",
|
||||
"deadlines.form.approval_hint": "4-Augen-Prüfung erforderlich",
|
||||
"appointments.form.approval_hint": "4-Augen-Prüfung erforderlich",
|
||||
"admin.email_templates.title": "Email-Templates — Paliad",
|
||||
@@ -2288,6 +2364,31 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Admin audit log (t-paliad-071)
|
||||
"nav.admin.audit": "Audit-Log",
|
||||
"nav.admin.partner_units": "Partner Units",
|
||||
|
||||
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
|
||||
"nav.admin.backups": "Backups",
|
||||
"admin.backups.title": "Backups — Paliad",
|
||||
"admin.backups.heading": "Backups",
|
||||
"admin.backups.subtitle": "Vollständige Snapshots aller Daten — manuell oder zeitgesteuert.",
|
||||
"admin.backups.run_now": "Backup jetzt erstellen",
|
||||
"admin.backups.running": "Läuft …",
|
||||
"admin.backups.success": "Backup erfolgreich erstellt.",
|
||||
"admin.backups.empty": "Noch keine Backups vorhanden.",
|
||||
"admin.backups.loading": "Lade …",
|
||||
"admin.backups.col.started": "Erstellt",
|
||||
"admin.backups.col.kind": "Auslöser",
|
||||
"admin.backups.col.status": "Status",
|
||||
"admin.backups.col.requested_by": "Angefordert von",
|
||||
"admin.backups.col.size": "Größe",
|
||||
"admin.backups.col.rows": "Sheets",
|
||||
"admin.backups.col.actions": "Aktion",
|
||||
"admin.backups.kind.scheduled": "Geplant",
|
||||
"admin.backups.kind.on_demand": "Manuell",
|
||||
"admin.backups.status.running": "Läuft …",
|
||||
"admin.backups.status.done": "✓ Fertig",
|
||||
"admin.backups.status.failed": "✗ Fehlgeschlagen",
|
||||
"admin.backups.download": "Download",
|
||||
"admin.backups.footer.note": "Geplante Backups werden in einer späteren Slice aktiviert. Manuelle Backups stehen jetzt zur Verfügung.",
|
||||
"admin.audit.title": "Audit-Log — Paliad",
|
||||
"admin.audit.heading": "Audit-Log",
|
||||
"admin.audit.subtitle": "Globale Zeitleiste über Projekt-, CalDAV-, Reminder- und Partner-Unit-Ereignisse.",
|
||||
@@ -2387,6 +2488,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event_types.browse.cancel": "Abbrechen",
|
||||
"event_types.browse.selected_count": "{n} ausgewählt",
|
||||
"event_types.browse.jurisdiction.none": "Allgemein",
|
||||
"event_types.browse.jurisdiction.all": "Alle Gerichte",
|
||||
"event_types.browse.jurisdiction.filter_label": "Nach Gerichtsart filtern",
|
||||
"event_types.filter.all": "Alle Typen",
|
||||
"event_types.filter.untyped": "— Ohne Typ —",
|
||||
"event_types.filter.search": "Typ suchen…",
|
||||
@@ -2532,6 +2635,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
|
||||
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
|
||||
"approvals.withdraw.error": "Fehler beim Zurückziehen",
|
||||
"approvals.withdraw.cancel": "Abbrechen",
|
||||
"approvals.withdraw.modal.title": "Genehmigungsanfrage zurückziehen?",
|
||||
"approvals.withdraw.primary.label": "Termin bearbeiten",
|
||||
"approvals.withdraw.destructive.label": "Endgültig zurückziehen und löschen",
|
||||
"approvals.withdraw.lead.create.deadline": "Wenn Sie die Anfrage zurückziehen, wird die Frist gelöscht.",
|
||||
"approvals.withdraw.lead.create.appointment": "Wenn Sie die Anfrage zurückziehen, wird der Termin gelöscht.",
|
||||
"approvals.withdraw.lead.update": "Wenn Sie die Anfrage zurückziehen, werden die vorgeschlagenen Änderungen verworfen — der Eintrag kehrt in den Zustand vor Ihrer Bearbeitung zurück.",
|
||||
"approvals.withdraw.lead.delete": "Wenn Sie die Löschanfrage zurückziehen, bleibt der Eintrag bestehen.",
|
||||
"approvals.withdraw.sub.create": "Alternativ können Sie den Eintrag stattdessen bearbeiten. Die Anfrage bleibt offen und der Genehmiger sieht Ihre neuen Werte.",
|
||||
"approvals.withdraw.sub.update": "Alternativ können Sie Ihre Änderungen bearbeiten und neu absenden. Die Anfrage bleibt offen.",
|
||||
"approvals.withdraw.sub.delete": "Sind Sie sicher, dass Sie die Löschanfrage zurückziehen möchten?",
|
||||
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
|
||||
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
|
||||
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
|
||||
@@ -2589,11 +2703,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.scope.my_subtree": "Mein Teilbaum",
|
||||
"views.scope.explicit": "Bestimmte Projekte",
|
||||
"views.scope.personal_only": "Nur persönliche",
|
||||
"views.horizon.next_1d": "Morgen",
|
||||
"views.horizon.next_7d": "Nächste 7 Tage",
|
||||
"views.horizon.next_14d": "Nächste 14 Tage",
|
||||
"views.horizon.next_30d": "Nächste 30 Tage",
|
||||
"views.horizon.next_90d": "Nächste 90 Tage",
|
||||
"views.horizon.next_all": "Ganze Zukunft",
|
||||
"views.horizon.past_1d": "Letzter Tag",
|
||||
"views.horizon.past_7d": "Letzte 7 Tage",
|
||||
"views.horizon.past_14d": "Letzte 14 Tage",
|
||||
"views.horizon.past_30d": "Letzte 30 Tage",
|
||||
"views.horizon.past_90d": "Letzte 90 Tage",
|
||||
"views.horizon.past_all": "Ganze Vergangenheit",
|
||||
"views.horizon.any": "Beliebig",
|
||||
"views.horizon.all": "Komplett (alle Daten)",
|
||||
"views.horizon.custom": "Benutzerdefiniert",
|
||||
@@ -2677,16 +2798,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.label.density": "Dichte",
|
||||
"views.bar.label.sort": "Sortierung",
|
||||
"views.bar.common.all": "Alle",
|
||||
"views.bar.time.next_7d": "7 Tage",
|
||||
"views.bar.time.next_30d": "30 Tage",
|
||||
"views.bar.time.next_90d": "90 Tage",
|
||||
"views.bar.time.past_7d": "Letzte 7 T.",
|
||||
"views.bar.time.past_30d": "Letzte 30 T.",
|
||||
"views.bar.time.past_90d": "Letzte 90 T.",
|
||||
"views.bar.time.any": "Beliebig",
|
||||
"views.bar.time.all": "Alle Zeit",
|
||||
"views.bar.time.custom": "Anpassen",
|
||||
"views.bar.time.custom.coming_soon": "Benutzerdefinierter Zeitraum folgt in einer der nächsten Iterationen.",
|
||||
// views.bar.time.* keys retired in t-paliad-248 — the filter-bar time
|
||||
// axis now mounts the symmetric date-range picker, whose labels live
|
||||
// under date_range.horizon.* (see end of this dict). The picker reuses
|
||||
// views.bar.label.time as the closed-button prefix.
|
||||
"views.bar.personal.on": "Nur eigene",
|
||||
"views.bar.approval_role.approver_eligible": "Zur Genehmigung",
|
||||
"views.bar.approval_role.self_requested": "Eigene Anfragen",
|
||||
@@ -2913,6 +3028,38 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.export.ok": "{n} Audit-Zeilen exportiert.",
|
||||
"admin.rules.export.error": "Export fehlgeschlagen.",
|
||||
"admin.rules.export.no_pending": "Keine offenen Audit-Zeilen zum Export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). Symmetric past/future chip fan
|
||||
// around an ALLES centre. Used by the filter-bar 'time' axis from
|
||||
// Slice A onwards; future slices will migrate /agenda and
|
||||
// /admin/audit-log to the same component.
|
||||
"date_range.button.label": "Zeitraum",
|
||||
"date_range.button.label.custom_range": "Von {from} bis {to}",
|
||||
"date_range.horizon.next_1d": "Morgen",
|
||||
"date_range.horizon.next_7d": "Nächste 7 Tage",
|
||||
"date_range.horizon.next_14d": "Nächste 14 Tage",
|
||||
"date_range.horizon.next_30d": "Nächste 30 Tage",
|
||||
"date_range.horizon.next_90d": "Nächste 90 Tage",
|
||||
"date_range.horizon.next_all": "Ganze Zukunft",
|
||||
"date_range.horizon.past_1d": "Letzter Tag",
|
||||
"date_range.horizon.past_7d": "Letzte 7 Tage",
|
||||
"date_range.horizon.past_14d": "Letzte 14 Tage",
|
||||
"date_range.horizon.past_30d": "Letzte 30 Tage",
|
||||
"date_range.horizon.past_90d": "Letzte 90 Tage",
|
||||
"date_range.horizon.past_all": "Ganze Vergangenheit",
|
||||
"date_range.horizon.any": "Alles",
|
||||
"date_range.horizon.custom": "Anpassen",
|
||||
"date_range.dialog.label": "Zeitraum wählen",
|
||||
"date_range.fan.past.label": "Vergangenheit",
|
||||
"date_range.fan.future.label": "Zukunft",
|
||||
"date_range.center.label": "Alles",
|
||||
"date_range.custom.from": "Von",
|
||||
"date_range.custom.to": "Bis",
|
||||
"date_range.custom.apply": "Anwenden",
|
||||
"date_range.custom.cancel": "Abbrechen",
|
||||
"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.",
|
||||
},
|
||||
|
||||
en: {
|
||||
@@ -2926,6 +3073,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.glossar": "Glossary",
|
||||
"nav.gebuehrentabellen": "Fee Schedules",
|
||||
"nav.checklisten": "Checklists",
|
||||
"nav.submissions": "Submissions",
|
||||
"nav.gerichte": "Courts",
|
||||
"nav.logout": "Sign Out",
|
||||
"nav.akten": "Matters",
|
||||
@@ -3197,9 +3345,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
"deadlines.view.columns": "Columns",
|
||||
"deadlines.notes.show": "Show details",
|
||||
"deadlines.col.proactive": "Proactive",
|
||||
"deadlines.col.ours": "Client Side",
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.reactive": "Reactive",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
"deadlines.col.both": "Both parties",
|
||||
"deadlines.adjusted": "Adjusted",
|
||||
"deadlines.adjusted.reason": "weekend/holiday",
|
||||
@@ -3319,6 +3467,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
|
||||
"deadlines.perspective.predefined_hint": "predefined from project",
|
||||
"deadlines.side.label": "Side:",
|
||||
"deadlines.side.claimant": "Claimant",
|
||||
"deadlines.side.defendant": "Defendant",
|
||||
"deadlines.side.both": "Both",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
@@ -3769,11 +3925,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.title.placeholder": "e.g. File statement of defence",
|
||||
"deadlines.field.due": "Due date",
|
||||
"deadlines.field.rule": "Rule (optional)",
|
||||
"deadlines.field.rule.none": "No rule",
|
||||
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
|
||||
"deadlines.field.rule.autofill_inline": " (set by rule)",
|
||||
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
|
||||
"deadlines.field.rule.override": "Choose another type",
|
||||
"deadlines.field.rule.auto_badge": "Auto",
|
||||
"deadlines.field.rule.auto_no_match": "No rule maps to the chosen Type",
|
||||
"deadlines.field.rule.auto_pick_type": "Pick a Type first",
|
||||
"deadlines.field.rule.custom_badge": "Custom",
|
||||
"deadlines.field.rule.custom_placeholder": "e.g. internal review meeting, client call",
|
||||
"deadlines.field.rule.mode.toggle_to_auto": "Back to Auto",
|
||||
"deadlines.field.rule.mode.toggle_to_custom": "Enter custom rule",
|
||||
"deadlines.field.title.default_btn": "Default title",
|
||||
"deadlines.field.title.default_fallback": "New deadline",
|
||||
"deadlines.field.notes": "Notes (optional)",
|
||||
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
|
||||
"deadlines.error.required": "Matter, title and due date are required.",
|
||||
@@ -4302,10 +4462,16 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notes",
|
||||
"projects.detail.tab.checklisten": "Checklists",
|
||||
"projects.detail.tab.submissions": "Submissions",
|
||||
"projects.detail.tab.settings": "Settings",
|
||||
"projects.detail.export.button": "Export data",
|
||||
"projects.detail.export.tooltip": "Download this project's data (including sub-projects) as Excel + JSON + CSV.",
|
||||
"projects.detail.submissions.empty": "No submissions are configured for this proceeding.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet. Edit the project to choose one.",
|
||||
"projects.detail.settings.export.heading": "Export data",
|
||||
"projects.detail.settings.export.description": "Download all data for this project (including sub-projects) as an Excel + JSON + CSV archive.",
|
||||
"projects.detail.settings.archive.heading": "Archive project",
|
||||
"projects.detail.settings.archive.description": "Archiving happens in the edit dialog (danger zone).",
|
||||
"projects.detail.settings.archive.cta": "Open edit dialog",
|
||||
"projects.detail.submissions.empty": "No submission templates are configured yet.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet — the catalog below still lists every template.",
|
||||
"projects.detail.submissions.empty.no_proceeding.cta": "Edit project",
|
||||
"projects.detail.submissions.col.name": "Submission",
|
||||
"projects.detail.submissions.col.party": "Party",
|
||||
@@ -4313,7 +4479,49 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generate",
|
||||
"projects.detail.submissions.action.no_template": "No template",
|
||||
"projects.detail.submissions.action.edit": "Edit",
|
||||
"projects.detail.submissions.hint": "Submissions are generated as .docx directly from the project. Edit, print, file.",
|
||||
// t-paliad-238 — dedicated draft editor page.
|
||||
"submissions.draft.title": "Edit submission — Paliad",
|
||||
"submissions.draft.back": "← Back to project",
|
||||
"submissions.draft.loading": "Loading…",
|
||||
"submissions.draft.notfound": "Submission not found or insufficient access.",
|
||||
"submissions.draft.action.export": "Export as .docx",
|
||||
"submissions.draft.action.new": "+ New draft",
|
||||
"submissions.draft.action.delete": "Delete",
|
||||
"submissions.draft.switcher.label": "Draft",
|
||||
"submissions.draft.name.placeholder": "Name of this draft",
|
||||
"submissions.draft.preview.title": "Preview",
|
||||
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
"submissions.index.subtitle": "Your submission drafts across every visible project.",
|
||||
"submissions.index.loading": "Loading…",
|
||||
"submissions.index.empty": "No drafts yet. Start a new draft — with or without a project.",
|
||||
"submissions.index.empty.cta": "+ New draft",
|
||||
"submissions.index.error": "Could not load submissions.",
|
||||
"submissions.index.col.project": "Project",
|
||||
"submissions.index.col.submission": "Submission",
|
||||
"submissions.index.col.draft": "Draft",
|
||||
"submissions.index.col.updated": "Last updated",
|
||||
"submissions.index.action.new": "+ New draft",
|
||||
"submissions.new.title": "New submission — Paliad",
|
||||
"submissions.new.back": "← Back to drafts",
|
||||
"submissions.new.heading": "New submission",
|
||||
"submissions.new.subtitle": "Pick a template. Optionally bind it to a project — otherwise all variables are filled manually.",
|
||||
"submissions.new.search.placeholder": "Search by name, code or statute…",
|
||||
"submissions.new.loading": "Loading…",
|
||||
"submissions.new.error": "Could not load catalog.",
|
||||
"submissions.new.col.name": "Submission",
|
||||
"submissions.new.col.party": "Party",
|
||||
"submissions.new.col.source": "Legal source",
|
||||
"submissions.new.col.actions": "Start draft",
|
||||
"submissions.new.empty.filtered": "No submissions match the filters. Reset them to see the full catalog.",
|
||||
"submissions.new.picker.title": "Pick a project",
|
||||
"submissions.new.picker.placeholder": "Search project (title or reference)…",
|
||||
"submissions.new.picker.loading": "Loading projects…",
|
||||
"submissions.new.picker.empty": "No visible projects.",
|
||||
"projects.detail.verlauf.empty": "No events recorded yet.",
|
||||
"projects.detail.verlauf.loadMore": "Load more",
|
||||
"projects.detail.smarttimeline.empty": "No events captured yet.",
|
||||
@@ -5046,6 +5254,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",
|
||||
@@ -5157,6 +5379,31 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Admin audit log (t-paliad-071)
|
||||
"nav.admin.audit": "Audit Log",
|
||||
"nav.admin.partner_units": "Partner Units",
|
||||
|
||||
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
|
||||
"nav.admin.backups": "Backups",
|
||||
"admin.backups.title": "Backups — Paliad",
|
||||
"admin.backups.heading": "Backups",
|
||||
"admin.backups.subtitle": "Full snapshots of all data — manual or scheduled.",
|
||||
"admin.backups.run_now": "Run backup now",
|
||||
"admin.backups.running": "Running …",
|
||||
"admin.backups.success": "Backup created successfully.",
|
||||
"admin.backups.empty": "No backups yet.",
|
||||
"admin.backups.loading": "Loading …",
|
||||
"admin.backups.col.started": "Started",
|
||||
"admin.backups.col.kind": "Trigger",
|
||||
"admin.backups.col.status": "Status",
|
||||
"admin.backups.col.requested_by": "Requested by",
|
||||
"admin.backups.col.size": "Size",
|
||||
"admin.backups.col.rows": "Sheets",
|
||||
"admin.backups.col.actions": "Action",
|
||||
"admin.backups.kind.scheduled": "Scheduled",
|
||||
"admin.backups.kind.on_demand": "Manual",
|
||||
"admin.backups.status.running": "Running …",
|
||||
"admin.backups.status.done": "✓ Done",
|
||||
"admin.backups.status.failed": "✗ Failed",
|
||||
"admin.backups.download": "Download",
|
||||
"admin.backups.footer.note": "Scheduled backups land in a later slice. Manual backups are available now.",
|
||||
"admin.audit.title": "Audit Log — Paliad",
|
||||
"admin.audit.heading": "Audit Log",
|
||||
"admin.audit.subtitle": "Global timeline across project, CalDAV, reminder and partner-unit events.",
|
||||
@@ -5256,6 +5503,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event_types.browse.cancel": "Cancel",
|
||||
"event_types.browse.selected_count": "{n} selected",
|
||||
"event_types.browse.jurisdiction.none": "Any",
|
||||
"event_types.browse.jurisdiction.all": "All courts",
|
||||
"event_types.browse.jurisdiction.filter_label": "Filter by court type",
|
||||
"event_types.filter.all": "All types",
|
||||
"event_types.filter.untyped": "— Untyped —",
|
||||
"event_types.filter.search": "Search type…",
|
||||
@@ -5401,6 +5650,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.withdraw.cta": "Withdraw approval request",
|
||||
"approvals.withdraw.confirm": "Withdraw the approval request?",
|
||||
"approvals.withdraw.error": "Failed to withdraw",
|
||||
"approvals.withdraw.cancel": "Cancel",
|
||||
"approvals.withdraw.modal.title": "Withdraw approval request?",
|
||||
"approvals.withdraw.primary.label": "Edit event",
|
||||
"approvals.withdraw.destructive.label": "Withdraw permanently and delete",
|
||||
"approvals.withdraw.lead.create.deadline": "Withdrawing this request will delete the deadline.",
|
||||
"approvals.withdraw.lead.create.appointment": "Withdrawing this request will delete the appointment.",
|
||||
"approvals.withdraw.lead.update": "Withdrawing this request will discard your proposed changes — the entry will revert to its state before your edit.",
|
||||
"approvals.withdraw.lead.delete": "Withdrawing the delete request will keep the entry alive.",
|
||||
"approvals.withdraw.sub.create": "Alternatively, you can edit the entry instead. The request stays open and the approver will see your new values.",
|
||||
"approvals.withdraw.sub.update": "Alternatively, you can edit your changes and resubmit. The request stays open.",
|
||||
"approvals.withdraw.sub.delete": "Are you sure you want to withdraw the delete request?",
|
||||
"approvals.pending_create.label": "Awaits approval (creation)",
|
||||
"approvals.pending_update.label": "Awaits approval (change)",
|
||||
"approvals.pending_complete.label": "Awaits approval (completion)",
|
||||
@@ -5458,11 +5718,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.scope.my_subtree": "My subtree",
|
||||
"views.scope.explicit": "Specific projects",
|
||||
"views.scope.personal_only": "Personal only",
|
||||
"views.horizon.next_1d": "Tomorrow",
|
||||
"views.horizon.next_7d": "Next 7 days",
|
||||
"views.horizon.next_14d": "Next 14 days",
|
||||
"views.horizon.next_30d": "Next 30 days",
|
||||
"views.horizon.next_90d": "Next 90 days",
|
||||
"views.horizon.next_all": "All future",
|
||||
"views.horizon.past_1d": "Last day",
|
||||
"views.horizon.past_7d": "Last 7 days",
|
||||
"views.horizon.past_14d": "Last 14 days",
|
||||
"views.horizon.past_30d": "Last 30 days",
|
||||
"views.horizon.past_90d": "Last 90 days",
|
||||
"views.horizon.past_all": "All past",
|
||||
"views.horizon.any": "Any",
|
||||
"views.horizon.all": "All-time",
|
||||
"views.horizon.custom": "Custom",
|
||||
@@ -5545,16 +5812,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.label.density": "Density",
|
||||
"views.bar.label.sort": "Sort",
|
||||
"views.bar.common.all": "All",
|
||||
"views.bar.time.next_7d": "7 days",
|
||||
"views.bar.time.next_30d": "30 days",
|
||||
"views.bar.time.next_90d": "90 days",
|
||||
"views.bar.time.past_7d": "Past 7d",
|
||||
"views.bar.time.past_30d": "Past 30 d.",
|
||||
"views.bar.time.past_90d": "Past 90 d.",
|
||||
"views.bar.time.any": "Any",
|
||||
"views.bar.time.all": "All time",
|
||||
"views.bar.time.custom": "Custom",
|
||||
"views.bar.time.custom.coming_soon": "Custom date range arrives in a follow-up iteration.",
|
||||
// views.bar.time.* keys retired in t-paliad-248 — see the DE block
|
||||
// for context. The filter-bar time axis now mounts the symmetric
|
||||
// date-range picker whose labels live under date_range.horizon.*.
|
||||
"views.bar.personal.on": "Mine only",
|
||||
"views.bar.approval_role.approver_eligible": "To approve",
|
||||
"views.bar.approval_role.self_requested": "My requests",
|
||||
@@ -5781,6 +6041,35 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.export.ok": "{n} audit rows exported.",
|
||||
"admin.rules.export.error": "Export failed.",
|
||||
"admin.rules.export.no_pending": "No pending audit rows to export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). See DE block above for details.
|
||||
"date_range.button.label": "Time range",
|
||||
"date_range.button.label.custom_range": "From {from} to {to}",
|
||||
"date_range.horizon.next_1d": "Tomorrow",
|
||||
"date_range.horizon.next_7d": "Next 7 days",
|
||||
"date_range.horizon.next_14d": "Next 14 days",
|
||||
"date_range.horizon.next_30d": "Next 30 days",
|
||||
"date_range.horizon.next_90d": "Next 90 days",
|
||||
"date_range.horizon.next_all": "All future",
|
||||
"date_range.horizon.past_1d": "Last day",
|
||||
"date_range.horizon.past_7d": "Last 7 days",
|
||||
"date_range.horizon.past_14d": "Last 14 days",
|
||||
"date_range.horizon.past_30d": "Last 30 days",
|
||||
"date_range.horizon.past_90d": "Last 90 days",
|
||||
"date_range.horizon.past_all": "All past",
|
||||
"date_range.horizon.any": "All",
|
||||
"date_range.horizon.custom": "Customize",
|
||||
"date_range.dialog.label": "Choose time range",
|
||||
"date_range.fan.past.label": "Past",
|
||||
"date_range.fan.future.label": "Future",
|
||||
"date_range.center.label": "All",
|
||||
"date_range.custom.from": "From",
|
||||
"date_range.custom.to": "To",
|
||||
"date_range.custom.apply": "Apply",
|
||||
"date_range.custom.cancel": "Cancel",
|
||||
"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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { FilterSpec, RenderSpec } from "./views/types";
|
||||
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
|
||||
import { loadAndRenderSubmissions } from "./submissions";
|
||||
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
|
||||
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -142,6 +143,11 @@ interface Deadline {
|
||||
status: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
// t-paliad-258 — free-text rule label when the deadline was saved in
|
||||
// Custom mode. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
// Populated by the union endpoint (/api/events) which is what the project
|
||||
// detail page calls — used for attribution when the row lives on a
|
||||
// descendant project (t-paliad-139).
|
||||
@@ -175,7 +181,8 @@ type TabId =
|
||||
| "appointments"
|
||||
| "notes"
|
||||
| "checklists"
|
||||
| "submissions";
|
||||
| "submissions"
|
||||
| "settings";
|
||||
|
||||
const VALID_TABS: TabId[] = [
|
||||
"history",
|
||||
@@ -187,6 +194,7 @@ const VALID_TABS: TabId[] = [
|
||||
"notes",
|
||||
"checklists",
|
||||
"submissions",
|
||||
"settings",
|
||||
];
|
||||
|
||||
// Legacy German tab slugs that may appear in bookmarked URLs after the
|
||||
@@ -389,18 +397,26 @@ function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] {
|
||||
// horizons that show up on the Verlauf bar. Forward-looking horizons
|
||||
// (next_*) are absent on this surface — the timePresets override hides
|
||||
// them — but the function tolerates them for forward-compatibility with
|
||||
// the SmartTimeline redesign.
|
||||
// the SmartTimeline redesign. Open-ended ranges (next_all / past_all)
|
||||
// leave the matching bound undefined; the upstream filter treats that
|
||||
// as "no narrowing in that direction".
|
||||
function horizonBounds(horizon: string): { from?: Date; to?: Date } {
|
||||
const now = new Date();
|
||||
const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const offset = (days: number) => new Date(day.getTime() + days * 86400000);
|
||||
switch (horizon) {
|
||||
case "past_1d": return { from: offset(-1), to: offset(1) };
|
||||
case "past_7d": return { from: offset(-7), to: offset(1) };
|
||||
case "past_14d": return { from: offset(-14), to: offset(1) };
|
||||
case "past_30d": return { from: offset(-30), to: offset(1) };
|
||||
case "past_90d": return { from: offset(-90), to: offset(1) };
|
||||
case "past_all": return { to: offset(1) };
|
||||
case "next_1d": return { from: day, to: offset(1) };
|
||||
case "next_7d": return { from: day, to: offset(7) };
|
||||
case "next_14d": return { from: day, to: offset(14) };
|
||||
case "next_30d": return { from: day, to: offset(30) };
|
||||
case "next_90d": return { from: day, to: offset(90) };
|
||||
case "next_all": return { from: day };
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
@@ -803,6 +819,9 @@ interface UnionEvent {
|
||||
status?: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
custom_rule_text?: string;
|
||||
start_at?: string;
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
@@ -830,6 +849,9 @@ async function loadDeadlines(id: string) {
|
||||
status: it.status ?? "pending",
|
||||
rule_id: it.rule_id,
|
||||
rule_code: it.rule_code,
|
||||
rule_name: it.rule_name,
|
||||
rule_name_en: it.rule_name_en,
|
||||
custom_rule_text: it.custom_rule_text,
|
||||
project_title: it.project_title,
|
||||
}));
|
||||
} else {
|
||||
@@ -999,6 +1021,27 @@ function fmtDateOnly(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// formatDeadlineRuleCell renders the REGEL column for the project
|
||||
// detail Fristen table using the canonical t-paliad-258 contract:
|
||||
// 1. catalog rule (rule_name / rule_name_en + rule_code) → "Name · Code"
|
||||
// 2. custom_rule_text → text + "Custom" badge
|
||||
// 3. legacy rule_code-only saves → bare citation
|
||||
// 4. otherwise "—"
|
||||
function formatDeadlineRuleCell(f: Deadline): string {
|
||||
const hasName = (f.rule_name && f.rule_name.trim()) ||
|
||||
(f.rule_name_en && f.rule_name_en.trim());
|
||||
if (hasName || (f.rule_code && f.rule_code.trim())) {
|
||||
return formatRuleLabelHTML(
|
||||
{ name: f.rule_name || "", name_en: f.rule_name_en, rule_code: f.rule_code },
|
||||
esc,
|
||||
);
|
||||
}
|
||||
if (f.custom_rule_text && f.custom_rule_text.trim()) {
|
||||
return formatCustomRuleLabelHTML(f.custom_rule_text, esc);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
function urgencyClass(due: string, status: string): string {
|
||||
if (status === "completed") return "frist-urgency-done";
|
||||
const today = new Date();
|
||||
@@ -1037,7 +1080,7 @@ function renderDeadlines() {
|
||||
</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
|
||||
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
|
||||
<td class="frist-col-rule">${formatDeadlineRuleCell(f)}</td>
|
||||
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
@@ -1185,13 +1228,16 @@ function renderHeader() {
|
||||
netdocs.style.display = "none";
|
||||
}
|
||||
|
||||
// Delete visibility: partner/admin only
|
||||
// Delete visibility: partner/admin only. The Verwaltung tab's archive
|
||||
// sub-section mirrors the same gate (t-paliad-245) — it only points at
|
||||
// the Edit-modal danger zone, so it's pointless to show when the danger
|
||||
// zone itself is hidden.
|
||||
const deleteWrap = document.getElementById("project-delete-wrap")!;
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
}
|
||||
const archiveSection = document.getElementById("project-settings-archive");
|
||||
const canArchive = !!me && me.global_role === "global_admin";
|
||||
deleteWrap.style.display = canArchive ? "" : "none";
|
||||
if (archiveSection) archiveSection.style.display = canArchive ? "" : "none";
|
||||
updateSettingsTabVisibility();
|
||||
}
|
||||
|
||||
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
|
||||
@@ -2045,6 +2091,17 @@ function initEditModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// Verwaltung → Projekt archivieren — opens the edit modal scrolled to
|
||||
// the danger-zone archive button (t-paliad-245).
|
||||
const archiveLink = document.getElementById(
|
||||
"project-settings-archive-link",
|
||||
) as HTMLButtonElement | null;
|
||||
if (archiveLink) {
|
||||
archiveLink.addEventListener("click", () => {
|
||||
openEditModal("project-delete-btn");
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!project) return;
|
||||
@@ -2991,17 +3048,21 @@ function canExportProject(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// wireExportButton reveals + hooks up the project-export button on the
|
||||
// tabs nav. Triggers a download via a transient <a download> — same
|
||||
// pattern as the personal export in client/settings.ts.
|
||||
// wireExportButton reveals the Export sub-section of the Verwaltung tab
|
||||
// (t-paliad-245) and hooks up the project-export button. Triggers a
|
||||
// download via a transient <a download> — same pattern as the personal
|
||||
// export in client/settings.ts.
|
||||
function wireExportButton(projectID: string): void {
|
||||
const section = document.getElementById("project-settings-export") as HTMLElement | null;
|
||||
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
if (!section || !btn) return;
|
||||
if (!canExportProject()) {
|
||||
btn.style.display = "none";
|
||||
section.style.display = "none";
|
||||
updateSettingsTabVisibility();
|
||||
return;
|
||||
}
|
||||
btn.style.display = "";
|
||||
section.style.display = "";
|
||||
updateSettingsTabVisibility();
|
||||
btn.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
|
||||
@@ -3012,6 +3073,17 @@ function wireExportButton(projectID: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
// updateSettingsTabVisibility hides the Verwaltung tab when none of its
|
||||
// sub-sections are visible to the current user — an empty tab is worse
|
||||
// UX than no tab. Called whenever a sub-section's visibility flips.
|
||||
function updateSettingsTabVisibility(): void {
|
||||
const tab = document.querySelector<HTMLElement>('.entity-tab[data-tab="settings"]');
|
||||
if (!tab) return;
|
||||
const exportShown = document.getElementById("project-settings-export")?.style.display !== "none";
|
||||
const archiveShown = document.getElementById("project-settings-archive")?.style.display !== "none";
|
||||
tab.style.display = exportShown || archiveShown ? "" : "none";
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
|
||||
87
frontend/src/client/rule-label.ts
Normal file
87
frontend/src/client/rule-label.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// rule-label — canonical display contract for deadline rules.
|
||||
//
|
||||
// t-paliad-258 / m/paliad#89 addendum. Previously each surface (deadline
|
||||
// form, list rows, detail header, Schriftsätze tab, browse-a-proceeding)
|
||||
// invented its own pattern: sometimes citation-only, sometimes name-only,
|
||||
// sometimes "code — name". m flagged this on the first submissions in a
|
||||
// proceeding sequence where the inconsistency was most visible.
|
||||
//
|
||||
// Canonical pattern: **Name primary, Citation muted secondary**.
|
||||
// Text: "Notice of Appeal · UPC.RoP.220.1"
|
||||
// HTML: <span class="rule-label-name">Notice of Appeal</span>
|
||||
// <span class="rule-label-sep"> · </span>
|
||||
// <span class="rule-label-cite">UPC.RoP.220.1</span>
|
||||
//
|
||||
// Custom rules (t-paliad-258 — free-text label entered by the lawyer):
|
||||
// formatCustomRuleLabel produces "<text>" with a "Custom" badge slot
|
||||
// so list/detail surfaces can render both shapes uniformly.
|
||||
|
||||
import { getLang, t } from "./i18n";
|
||||
|
||||
export interface RuleLike {
|
||||
name: string;
|
||||
name_en?: string | null;
|
||||
// The catalog carries multiple citation fields depending on which
|
||||
// surface populated it. Order of preference: legal_source > rule_code
|
||||
// > code. All three are accepted so callers don't have to normalise.
|
||||
rule_code?: string | null;
|
||||
code?: string | null;
|
||||
legal_source?: string | null;
|
||||
}
|
||||
|
||||
// formatRuleLabel returns the canonical plain-text label.
|
||||
// Falls back gracefully when either side is missing.
|
||||
export function formatRuleLabel(r: RuleLike): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const cite = ruleCitation(r);
|
||||
if (name && cite) return `${name} · ${cite}`;
|
||||
return name || cite || "";
|
||||
}
|
||||
|
||||
// formatRuleLabelHTML returns the canonical HTML form with muted-citation
|
||||
// styling. The caller passes the HTML-escape helper so we don't pull a
|
||||
// dependency on a specific esc() module — every surface already has one.
|
||||
export function formatRuleLabelHTML(r: RuleLike, esc: (s: string) => string): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const cite = ruleCitation(r);
|
||||
if (name && cite) {
|
||||
return (
|
||||
`<span class="rule-label-name">${esc(name)}</span>` +
|
||||
`<span class="rule-label-sep"> · </span>` +
|
||||
`<span class="rule-label-cite">${esc(cite)}</span>`
|
||||
);
|
||||
}
|
||||
return esc(name || cite || "");
|
||||
}
|
||||
|
||||
// ruleCitation returns the best-available citation string for a rule.
|
||||
// Exported so callers that need the bare code (e.g. CalDAV exports,
|
||||
// inline data attributes) can pull it without going through the label
|
||||
// formatter.
|
||||
export function ruleCitation(r: RuleLike): string {
|
||||
return r.legal_source || r.rule_code || r.code || "";
|
||||
}
|
||||
|
||||
// formatCustomRuleLabelHTML — render a free-text custom rule label with
|
||||
// a "Custom" badge slot. Used by surfaces that may display either a
|
||||
// catalog rule (formatRuleLabelHTML) or a custom one. Returns "" when
|
||||
// the text is empty so callers can fall through to "—".
|
||||
export function formatCustomRuleLabelHTML(text: string | null | undefined, esc: (s: string) => string): string {
|
||||
const trimmed = (text ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
|
||||
return (
|
||||
`<span class="rule-label-name">${esc(trimmed)}</span>` +
|
||||
`<span class="rule-label-badge rule-label-badge--custom">${esc(badge)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
// formatCustomRuleLabel — plain-text equivalent of the above.
|
||||
export function formatCustomRuleLabel(text: string | null | undefined): string {
|
||||
const trimmed = (text ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
|
||||
return `${trimmed} · ${badge}`;
|
||||
}
|
||||
@@ -11,6 +11,13 @@ const WIDTH_KEY = "paliad-sidebar-width";
|
||||
const SIDEBAR_WIDTH_MIN = 180;
|
||||
const SIDEBAR_WIDTH_MAX = 480;
|
||||
const SIDEBAR_WIDTH_DEFAULT = 240;
|
||||
// Per-tab scroll position of the .sidebar-nav scroll container. Persisted
|
||||
// on every scroll event, restored on initSidebar() so a full-page nav
|
||||
// click doesn't bounce the user back to the top of a long sidebar
|
||||
// (Werkzeuge + projects + user views can easily overflow). sessionStorage
|
||||
// scopes it to the tab — opening a sidebar link in a new tab (Cmd-click)
|
||||
// starts that tab fresh at the top, which matches user expectation.
|
||||
const SCROLL_KEY = "paliad.sidebar.scroll";
|
||||
|
||||
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
|
||||
// BottomNav menu slot can call it without duplicating the open/close
|
||||
@@ -49,6 +56,23 @@ function applySidebarWidth(px: number): void {
|
||||
document.documentElement.style.setProperty("--sidebar-width", `${px}px`);
|
||||
}
|
||||
|
||||
// readStoredScroll returns the persisted scrollTop or 0 when missing /
|
||||
// malformed. Bounds are checked at apply time against the actual
|
||||
// scrollHeight, so a stale value pointing past the current scroll range
|
||||
// is harmless (the browser clamps assignments to [0, max]).
|
||||
function readStoredScroll(): number {
|
||||
const raw = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (raw === null) return 0;
|
||||
const n = parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
function applySidebarScroll(nav: HTMLElement, px: number): void {
|
||||
if (px <= 0) return;
|
||||
nav.scrollTop = px;
|
||||
}
|
||||
|
||||
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
|
||||
// first load and removes the stale entry. Drop this fallback once the rename
|
||||
// grace period is over.
|
||||
@@ -79,6 +103,7 @@ export function initSidebar() {
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
initSidebarResize(sidebar);
|
||||
initSidebarScrollRestore(sidebar);
|
||||
|
||||
const pinBtn = sidebar.querySelector<HTMLButtonElement>(".sidebar-pin");
|
||||
const hamburger = document.querySelector<HTMLButtonElement>(".sidebar-hamburger");
|
||||
@@ -293,6 +318,29 @@ function initSidebarResize(sidebar: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// initSidebarScrollRestore wires the .sidebar-nav scroll container to
|
||||
// sessionStorage so the user's scroll position survives a full-page
|
||||
// navigation (every sidebar link click is a real reload — see m/paliad#85).
|
||||
// Restore is synchronous on init so the first paint is already at the
|
||||
// right offset; the passive scroll listener persists subsequent moves.
|
||||
// reapplySidebarScroll() exists so callers that mutate sidebar content
|
||||
// async (initUserViewsGroup appending /api/user-views into the Ansichten
|
||||
// group) can nudge the scroll back to where it was after the layout shift.
|
||||
function initSidebarScrollRestore(sidebar: HTMLElement): void {
|
||||
const nav = sidebar.querySelector<HTMLElement>(".sidebar-nav");
|
||||
if (!nav) return;
|
||||
applySidebarScroll(nav, readStoredScroll());
|
||||
nav.addEventListener("scroll", () => {
|
||||
sessionStorage.setItem(SCROLL_KEY, String(nav.scrollTop));
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function reapplySidebarScroll(): void {
|
||||
const nav = document.querySelector<HTMLElement>(".sidebar .sidebar-nav");
|
||||
if (!nav) return;
|
||||
applySidebarScroll(nav, readStoredScroll());
|
||||
}
|
||||
|
||||
// Changelog badge — fetches the count of entries newer than the locally
|
||||
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
|
||||
// link. Skipped on the changelog page itself because changelog.ts stamps
|
||||
@@ -432,6 +480,11 @@ function initUserViewsGroup(): void {
|
||||
for (const view of views) {
|
||||
items.appendChild(renderUserViewItem(view, currentPath));
|
||||
}
|
||||
// The synchronous restore in initSidebarScrollRestore() happened
|
||||
// before these views were appended, so a saved scrollTop that
|
||||
// pointed below the Ansichten group would now sit on the wrong
|
||||
// row. Re-apply once the layout has stabilised.
|
||||
reapplySidebarScroll();
|
||||
// After rendering, kick off count refresh for views that opted in.
|
||||
for (const view of views) {
|
||||
if (view.show_count) {
|
||||
|
||||
1104
frontend/src/client/submission-draft.ts
Normal file
1104
frontend/src/client/submission-draft.ts
Normal file
File diff suppressed because it is too large
Load Diff
130
frontend/src/client/submissions-index.ts
Normal file
130
frontend/src/client/submissions-index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-240 — global Schriftsätze drafts index. Loads
|
||||
// /api/user/submission-drafts and renders one entity-table row per
|
||||
// draft. Row click → editor at /projects/{project_id}/submissions/
|
||||
// {submission_code}/draft/{draft_id}. Per project CLAUDE.md row-click
|
||||
// contract: a table whose rows look clickable must navigate on click;
|
||||
// inner links / buttons keep their own affordance.
|
||||
|
||||
interface DraftRow {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
project_title: string | null;
|
||||
project_reference?: string | null;
|
||||
submission_code: string;
|
||||
name: string;
|
||||
last_exported_at?: string | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let drafts: DraftRow[] = [];
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const isEN = getLang() === "en";
|
||||
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-index-loading")!;
|
||||
const empty = document.getElementById("submissions-index-empty")!;
|
||||
const error = document.getElementById("submissions-index-error")!;
|
||||
const wrap = document.getElementById("submissions-index-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/user/submission-drafts");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
drafts = (data.drafts ?? []) as DraftRow[];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
if (drafts.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
render();
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const body = document.getElementById("submissions-index-body")!;
|
||||
|
||||
const isEN = getLang() === "en";
|
||||
const noProjectLabel = isEN ? "(no project)" : "(kein Projekt)";
|
||||
|
||||
body.innerHTML = drafts.map((d) => {
|
||||
const projectCell = (() => {
|
||||
if (!d.project_id) {
|
||||
return `<span class="submissions-index-no-project">${esc(noProjectLabel)}</span>`;
|
||||
}
|
||||
const title = esc(d.project_title ?? "");
|
||||
if (d.project_reference) {
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project"><span class="entity-ref">${esc(d.project_reference)}</span> ${title}</a>`;
|
||||
}
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project">${title}</a>`;
|
||||
})();
|
||||
|
||||
const href = d.project_id
|
||||
? `/projects/${esc(d.project_id)}/submissions/${esc(d.submission_code)}/draft/${esc(d.id)}`
|
||||
: `/submissions/draft/${esc(d.id)}`;
|
||||
|
||||
return `<tr class="submissions-index-row" data-href="${esc(href)}">
|
||||
<td>${projectCell}</td>
|
||||
<td>${esc(d.submission_code)}</td>
|
||||
<td><a href="${esc(href)}" class="submissions-index-draft-name">${esc(d.name)}</a></td>
|
||||
<td>${esc(fmtDate(d.updated_at))}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
body.querySelectorAll<HTMLTableRowElement>(".submissions-index-row").forEach((row) => {
|
||||
const href = row.dataset.href!;
|
||||
row.addEventListener("click", (e) => {
|
||||
// Inner <a> elements (project link, draft name) handle their own
|
||||
// navigation — let the browser dispatch them.
|
||||
if ((e.target as HTMLElement).closest("a, button")) return;
|
||||
window.location.href = href;
|
||||
});
|
||||
});
|
||||
|
||||
// Keep tsc happy for the imported `t` (used only via data-i18n on
|
||||
// static markup — keep the import so future dynamic strings can hook
|
||||
// in without re-importing).
|
||||
void t;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(() => {
|
||||
if (drafts.length > 0) render();
|
||||
});
|
||||
void load();
|
||||
});
|
||||
368
frontend/src/client/submissions-new.ts
Normal file
368
frontend/src/client/submissions-new.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { initI18n, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-243 — client for /submissions/new. Fetches the
|
||||
// cross-proceeding submission catalog, groups it by proceeding, filters
|
||||
// by text + chip, and offers two start paths per row: with project
|
||||
// (modal picker) or without (project-less draft → /submissions/draft/{id}).
|
||||
|
||||
interface CatalogEntry {
|
||||
submission_code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
event_type?: string;
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
entries: CatalogEntry[];
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
interface State {
|
||||
entries: CatalogEntry[];
|
||||
activeProceeding: string | null; // null = all
|
||||
searchTerm: string;
|
||||
pickerForCode: string | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
entries: [],
|
||||
activeProceeding: null,
|
||||
searchTerm: "",
|
||||
pickerForCode: null,
|
||||
};
|
||||
|
||||
function isEN(): boolean {
|
||||
return getLang() === "en";
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function partyLabel(role: string | undefined): string {
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "claimant": return isEN() ? "Claimant" : "Klägerin";
|
||||
case "defendant": return isEN() ? "Defendant" : "Beklagte";
|
||||
case "both": return isEN() ? "Both" : "Beide";
|
||||
case "court": return isEN() ? "Court" : "Gericht";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-new-loading")!;
|
||||
const error = document.getElementById("submissions-new-error")!;
|
||||
const wrap = document.getElementById("submissions-new-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/submissions/catalog");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as CatalogResponse;
|
||||
state.entries = data.entries ?? [];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
renderChips();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderChips(): void {
|
||||
const host = document.getElementById("submissions-new-proceeding-chips");
|
||||
if (!host) return;
|
||||
const seen = new Map<string, string>();
|
||||
for (const e of state.entries) {
|
||||
if (!seen.has(e.proceeding_code)) {
|
||||
seen.set(e.proceeding_code, isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name);
|
||||
}
|
||||
}
|
||||
const chips: string[] = [];
|
||||
const allLabel = isEN() ? "All" : "Alle";
|
||||
const allActive = state.activeProceeding === null;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${allActive ? " submissions-new-chip--active" : ""}" data-code="">${esc(allLabel)}</button>`);
|
||||
for (const [code, name] of seen) {
|
||||
const active = state.activeProceeding === code;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${active ? " submissions-new-chip--active" : ""}" data-code="${esc(code)}">${esc(name)} <span class="submissions-new-chip-code">${esc(code)}</span></button>`);
|
||||
}
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".submissions-new-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code ?? "";
|
||||
state.activeProceeding = code === "" ? null : code;
|
||||
renderChips();
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function filtered(): CatalogEntry[] {
|
||||
const term = state.searchTerm.trim().toLowerCase();
|
||||
return state.entries.filter((e) => {
|
||||
if (state.activeProceeding !== null && e.proceeding_code !== state.activeProceeding) {
|
||||
return false;
|
||||
}
|
||||
if (term === "") return true;
|
||||
const name = isEN() && e.name_en ? e.name_en : e.name;
|
||||
const hay = [
|
||||
name,
|
||||
e.submission_code,
|
||||
e.legal_source ?? "",
|
||||
e.proceeding_code,
|
||||
e.proceeding_name,
|
||||
e.proceeding_name_en,
|
||||
].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(): void {
|
||||
const body = document.getElementById("submissions-new-body");
|
||||
const empty = document.getElementById("submissions-new-empty");
|
||||
const wrap = document.getElementById("submissions-new-tablewrap");
|
||||
if (!body || !empty || !wrap) return;
|
||||
|
||||
const rows = filtered();
|
||||
if (rows.length === 0) {
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
|
||||
// Group by proceeding.
|
||||
const groups = new Map<string, { name: string; entries: CatalogEntry[] }>();
|
||||
for (const e of rows) {
|
||||
const gname = isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name;
|
||||
const bucket = groups.get(e.proceeding_code);
|
||||
if (bucket) {
|
||||
bucket.entries.push(e);
|
||||
} else {
|
||||
groups.set(e.proceeding_code, { name: gname, entries: [e] });
|
||||
}
|
||||
}
|
||||
|
||||
const colspan = 4;
|
||||
const html: string[] = [];
|
||||
for (const [code, group] of groups) {
|
||||
html.push(`<tr class="entity-table-group-header"><th colspan="${colspan}" scope="colgroup"><span class="entity-table-group-header__name">${esc(group.name)}</span> <span class="entity-table-group-header__code">${esc(code)}</span></th></tr>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-no-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
if (code) void startDraft(code, null);
|
||||
});
|
||||
});
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-with-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
if (code) openProjectPicker(code);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: CatalogEntry): string {
|
||||
const name = isEN() && entry.name_en ? entry.name_en : entry.name;
|
||||
const source = entry.legal_source ?? "";
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${esc(isEN() ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage")}">${esc(isEN() ? "universal" : "universell")}</span>`;
|
||||
const withProject = isEN() ? "Mit Projekt…" : "Mit Projekt…";
|
||||
const noProject = isEN() ? "Ohne Projekt" : "Ohne Projekt";
|
||||
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${esc(name)}</span>
|
||||
<span class="submission-code">${esc(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<td>${esc(partyLabel(entry.primary_party))}</td>
|
||||
<td>${esc(source)}</td>
|
||||
<td class="submission-action-cell">
|
||||
<button type="button" class="btn-secondary btn-small submissions-new-start-with-project" data-code="${esc(entry.submission_code)}">${esc(withProject)}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(noProject)}</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
async function startDraft(submissionCode: string, projectID: string | null): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/api/submission-drafts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ submission_code: submissionCode, project_id: projectID }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch { /* ignore */ }
|
||||
alert((isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.") + (detail ? `\n\n${detail}` : ""));
|
||||
return;
|
||||
}
|
||||
const view = await resp.json() as { draft: { id: string; project_id: string | null; submission_code: string } };
|
||||
const id = view.draft.id;
|
||||
const pid = view.draft.project_id;
|
||||
const code = view.draft.submission_code;
|
||||
if (pid) {
|
||||
window.location.href = `/projects/${pid}/submissions/${encodeURIComponent(code)}/draft/${id}`;
|
||||
} else {
|
||||
window.location.href = `/submissions/draft/${id}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("submissions-new createDraft:", err);
|
||||
alert(isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Project picker modal
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let pickerProjects: ProjectRow[] = [];
|
||||
let pickerLoaded = false;
|
||||
|
||||
function openProjectPicker(submissionCode: string): void {
|
||||
state.pickerForCode = submissionCode;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "";
|
||||
if (!pickerLoaded) {
|
||||
void loadPickerProjects();
|
||||
} else {
|
||||
renderPickerList();
|
||||
}
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.value = "";
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function closeProjectPicker(): void {
|
||||
state.pickerForCode = null;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
async function loadPickerProjects(): Promise<void> {
|
||||
const loadingEl = document.getElementById("submissions-new-project-loading");
|
||||
if (loadingEl) loadingEl.style.display = "";
|
||||
try {
|
||||
const resp = await fetch("/api/projects?status=active");
|
||||
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
||||
const rows = (await resp.json()) as ProjectRow[];
|
||||
pickerProjects = rows ?? [];
|
||||
pickerLoaded = true;
|
||||
} catch (err) {
|
||||
console.error("submissions-new loadPickerProjects:", err);
|
||||
pickerProjects = [];
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
}
|
||||
renderPickerList();
|
||||
}
|
||||
|
||||
function renderPickerList(): void {
|
||||
const list = document.getElementById("submissions-new-project-list");
|
||||
const empty = document.getElementById("submissions-new-project-empty");
|
||||
if (!list || !empty) return;
|
||||
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
||||
|
||||
const matches = pickerProjects.filter((p) => {
|
||||
if (term === "") return true;
|
||||
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
}).slice(0, 50);
|
||||
|
||||
if (matches.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
list.innerHTML = matches.map((p) => {
|
||||
const ref = p.reference ? `<span class="entity-ref">${esc(p.reference)}</span> ` : "";
|
||||
return `<li class="submissions-new-project-item" data-id="${esc(p.id)}">${ref}<span class="submissions-new-project-title">${esc(p.title)}</span></li>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const pid = li.dataset.id;
|
||||
const code = state.pickerForCode;
|
||||
if (pid && code) {
|
||||
closeProjectPicker();
|
||||
void startDraft(code, pid);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Boot
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function wireToolbar(): void {
|
||||
const search = document.getElementById("submissions-new-search") as HTMLInputElement | null;
|
||||
if (search) {
|
||||
search.addEventListener("input", () => {
|
||||
state.searchTerm = search.value;
|
||||
renderTable();
|
||||
});
|
||||
}
|
||||
|
||||
const closeBtn = document.getElementById("submissions-new-project-modal-close");
|
||||
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectPicker());
|
||||
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) {
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
const pickerSearch = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (pickerSearch) {
|
||||
pickerSearch.addEventListener("input", () => renderPickerList());
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.pickerForCode) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
wireToolbar();
|
||||
void loadCatalog();
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
// Submissions panel — fetches the project's submission catalog and
|
||||
// renders one row per filing-type rule, with a [Generieren] action
|
||||
// when a .docx template resolves server-side.
|
||||
// Submissions panel — fetches the full submission catalog across every
|
||||
// proceeding and renders it grouped by proceeding, with the project's
|
||||
// own proceeding pinned at the top.
|
||||
//
|
||||
// t-paliad-215 Slice 1. Loaded lazily by the projects-detail tab
|
||||
// switcher so projects without the Schriftsätze tab open don't pay
|
||||
// for the per-row template-availability probes.
|
||||
// t-paliad-215 Slice 1 introduced the per-project list. t-paliad-242
|
||||
// broadened it to the catalog: from any project a lawyer can pick a
|
||||
// Statement of Defence under UPC.INF.CFI, a Klageerwiderung under
|
||||
// DE.INF.LG, an Opposition under EPO, etc. — the editor (t-paliad-238)
|
||||
// handles missing variables gracefully via the [KEIN WERT: …] marker,
|
||||
// so cross-proceeding picks still render cleanly.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
@@ -23,11 +26,15 @@ interface SubmissionEntry {
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface SubmissionListResponse {
|
||||
project_id: string;
|
||||
proceeding_type_id?: number;
|
||||
project_proceeding_code?: string;
|
||||
entries: SubmissionEntry[];
|
||||
}
|
||||
|
||||
@@ -74,13 +81,13 @@ function render(data: SubmissionListResponse): void {
|
||||
const body = document.getElementById("project-submissions-body");
|
||||
if (!empty || !noProc || !wrap || !body) return;
|
||||
|
||||
if (data.proceeding_type_id == null || data.proceeding_type_id === 0) {
|
||||
noProc.style.display = "";
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
noProc.style.display = "none";
|
||||
// t-paliad-242: the catalog is shown to every project regardless of
|
||||
// whether a proceeding is bound — the no-proceeding hint stays as a
|
||||
// soft nudge above the table, but no longer hides the catalog.
|
||||
noProc.style.display = data.proceeding_type_id == null || data.proceeding_type_id === 0
|
||||
? ""
|
||||
: "none";
|
||||
|
||||
if (data.entries.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
@@ -90,29 +97,56 @@ function render(data: SubmissionListResponse): void {
|
||||
wrap.style.display = "";
|
||||
|
||||
const isEN = document.documentElement.lang === "en";
|
||||
body.innerHTML = data.entries.map((entry) => {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const action = entry.has_template
|
||||
? `<button type="button" class="btn-primary btn-cta-lime btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(data.project_id)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`
|
||||
: `<span class="submission-no-template" data-i18n="projects.detail.submissions.action.no_template">${isEN ? "No template" : "Keine Vorlage"}</span>`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
// Wire button clicks. One click handler per render to avoid stale
|
||||
// closures from the previous render's data.
|
||||
// Group entries by proceeding_code. Build a stable group order:
|
||||
// project's own proceeding first (when present), then alphabetical
|
||||
// by proceeding_code for the rest.
|
||||
const groups = new Map<string, { name: string; entries: SubmissionEntry[] }>();
|
||||
for (const entry of data.entries) {
|
||||
const key = entry.proceeding_code || "";
|
||||
const groupName = isEN && entry.proceeding_name_en
|
||||
? entry.proceeding_name_en
|
||||
: entry.proceeding_name;
|
||||
const bucket = groups.get(key);
|
||||
if (bucket) {
|
||||
bucket.entries.push(entry);
|
||||
} else {
|
||||
groups.set(key, { name: groupName, entries: [entry] });
|
||||
}
|
||||
}
|
||||
|
||||
const ownCode = data.project_proceeding_code ?? "";
|
||||
const orderedCodes: string[] = [];
|
||||
if (ownCode && groups.has(ownCode)) orderedCodes.push(ownCode);
|
||||
for (const code of Array.from(groups.keys()).sort()) {
|
||||
if (code !== ownCode) orderedCodes.push(code);
|
||||
}
|
||||
|
||||
const ownSuffix = isEN ? " (this project)" : " (dieses Projekt)";
|
||||
const colspan = 4;
|
||||
|
||||
const html: string[] = [];
|
||||
for (const code of orderedCodes) {
|
||||
const group = groups.get(code);
|
||||
if (!group) continue;
|
||||
const isOwn = code === ownCode;
|
||||
const label = group.name + (isOwn ? ownSuffix : "");
|
||||
const headerClass = isOwn
|
||||
? "entity-table-group-header entity-table-group-header--own"
|
||||
: "entity-table-group-header";
|
||||
html.push(`<tr class="${headerClass}">`
|
||||
+ `<th colspan="${colspan}" scope="colgroup">`
|
||||
+ `<span class="entity-table-group-header__name">${escapeHtml(label)}</span>`
|
||||
+ ` <span class="entity-table-group-header__code">${escapeHtml(code)}</span>`
|
||||
+ `</th></tr>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry, data.project_id, isEN));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
// Wire button clicks. One handler per render to avoid stale closures
|
||||
// from the previous render's data.
|
||||
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -122,6 +156,33 @@ function render(data: SubmissionListResponse): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: SubmissionEntry, projectID: string, isEN: boolean): string {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const draftHref = `/projects/${encodeURIComponent(projectID)}/submissions/${encodeURIComponent(entry.submission_code)}/draft`;
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${isEN ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage"}">${isEN ? "universal" : "universell"}</span>`;
|
||||
const editBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-i18n="projects.detail.submissions.action.edit">${isEN ? "Edit" : "Bearbeiten"}</a>`;
|
||||
const generateBtn = `<button type="button" class="btn-secondary btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(projectID)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`;
|
||||
const action = `${editBtn} ${generateBtn}`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderError(): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
|
||||
import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -341,28 +341,64 @@ function buildProjectFilter() {
|
||||
function buildBroadcastButton() {
|
||||
const wrap = document.getElementById("team-broadcast-wrap");
|
||||
if (!wrap) return;
|
||||
if (!canBroadcast()) {
|
||||
// Wait for /api/me so the affordance never flickers between admin (form)
|
||||
// and non-admin (mailto) on initial paint. canBroadcast() already returns
|
||||
// false when me is null but we'd briefly render the mailto anchor before
|
||||
// the admin form, which is visually jarring.
|
||||
if (!me) {
|
||||
wrap.innerHTML = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl");
|
||||
const counter = `<span class="team-broadcast-count" id="team-broadcast-count">0</span>`;
|
||||
if (canBroadcast()) {
|
||||
// Admin path (global_admin or project-lead-of-selected): opens the
|
||||
// in-app compose modal that POSTs to /api/team/broadcast.
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${label} ${counter}
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
} else {
|
||||
// Non-admin path (t-paliad-244): native mailto: anchor pre-filled with
|
||||
// the current filter set. href is refreshed in updateBroadcastButton()
|
||||
// whenever filters change so the link always reflects what's visible.
|
||||
wrap.innerHTML = `
|
||||
<a class="btn btn-primary" id="team-broadcast-btn" href="mailto:">
|
||||
${label} ${counter}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateBroadcastButton() {
|
||||
buildBroadcastButton();
|
||||
const recipients = displayedRecipients();
|
||||
const countEl = document.getElementById("team-broadcast-count");
|
||||
if (countEl) {
|
||||
const n = displayedRecipients().length;
|
||||
countEl.textContent = String(n);
|
||||
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = n === 0;
|
||||
if (countEl) countEl.textContent = String(recipients.length);
|
||||
const btn = document.getElementById("team-broadcast-btn");
|
||||
if (!btn) return;
|
||||
if (btn.tagName === "BUTTON") {
|
||||
(btn as HTMLButtonElement).disabled = recipients.length === 0;
|
||||
} else {
|
||||
// Anchor (non-admin): regenerate the mailto: href against the current
|
||||
// visible recipients, and disable the affordance when empty so a click
|
||||
// doesn't open an empty mail composer.
|
||||
const a = btn as HTMLAnchorElement;
|
||||
if (recipients.length === 0) {
|
||||
a.setAttribute("href", "mailto:");
|
||||
a.setAttribute("aria-disabled", "true");
|
||||
a.style.pointerEvents = "none";
|
||||
a.style.opacity = "0.5";
|
||||
} else {
|
||||
a.setAttribute("href", buildMailtoHref(recipients));
|
||||
a.removeAttribute("aria-disabled");
|
||||
a.style.pointerEvents = "";
|
||||
a.style.opacity = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,14 +709,21 @@ function renderSelectionFooter(): void {
|
||||
"{n}",
|
||||
String(n),
|
||||
);
|
||||
const sendLabel = esc(t("team.selection.send") || "E-Mail an Auswahl");
|
||||
// t-paliad-244: mirror buildBroadcastButton() so the bottom send button
|
||||
// behaves the same as the filter-bar one. Admin (canBroadcast) opens the
|
||||
// compose modal; non-admin gets a native mailto: anchor pre-filled with
|
||||
// the explicit selection.
|
||||
const adminPath = canBroadcast();
|
||||
const sendAction = adminPath
|
||||
? `<button type="button" class="btn-primary" id="team-selection-send">${sendLabel}</button>`
|
||||
: `<a class="btn-primary" id="team-selection-send" href="${buildMailtoHref(selectedRecipients())}">${sendLabel}</a>`;
|
||||
footer.innerHTML = `
|
||||
<span class="team-selection-count">${esc(countLabel)}</span>
|
||||
<button type="button" class="btn-secondary btn-small" id="team-selection-clear">
|
||||
${esc(t("team.selection.clear") || "Auswahl aufheben")}
|
||||
</button>
|
||||
<button type="button" class="btn-primary" id="team-selection-send">
|
||||
${esc(t("team.selection.send") || "E-Mail an Auswahl")}
|
||||
</button>
|
||||
${sendAction}
|
||||
`;
|
||||
footer.style.display = "";
|
||||
document.body.classList.add("team-has-selection");
|
||||
@@ -691,9 +734,12 @@ function renderSelectionFooter(): void {
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
});
|
||||
document.getElementById("team-selection-send")?.addEventListener("click", () => {
|
||||
onBroadcastFromSelection();
|
||||
});
|
||||
if (adminPath) {
|
||||
document.getElementById("team-selection-send")?.addEventListener("click", () => {
|
||||
onBroadcastFromSelection();
|
||||
});
|
||||
}
|
||||
// Anchor path has no click handler — native href open is the action.
|
||||
}
|
||||
|
||||
// selectedRecipients maps the explicit selection Set into the
|
||||
|
||||
@@ -12,6 +12,7 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
@@ -24,6 +25,70 @@ import {
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
|
||||
// view is shareable and survives reload:
|
||||
// ?side=claimant|defendant → swaps which column owns the user's
|
||||
// side (proactive vs reactive label).
|
||||
// Default null = claimant-on-the-left.
|
||||
// ?appellant=claimant|defendant → collapses party=both rows into the
|
||||
// appellant's column (no mirror).
|
||||
// Only meaningful for role-swap
|
||||
// proceedings (Appeal etc.). Default
|
||||
// null = legacy mirror behaviour.
|
||||
let currentSide: Side = null;
|
||||
let currentAppellant: Side = null;
|
||||
|
||||
// Proceedings where one party initiates and "both" rows are role-swap
|
||||
// (i.e. either party files depending on who acted at the lower
|
||||
// instance). For these proceedings the appellant selector is meaningful
|
||||
// — when set, "both" rows collapse to a single row in the appellant's
|
||||
// column. For first-instance proceedings (Inf, Rev, …) the selector is
|
||||
// hidden because there's no appellant axis.
|
||||
//
|
||||
// Today: every upc.apl.* family member plus dpma.appeal.* and
|
||||
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
|
||||
// Conservative — false negatives just hide a control; false positives
|
||||
// would show an irrelevant control.
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.merits",
|
||||
"upc.apl.cost",
|
||||
"upc.apl.order",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
"dpma.appeal.bpatg",
|
||||
"dpma.appeal.bgh",
|
||||
"epa.opp.boa",
|
||||
]);
|
||||
|
||||
function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function readSideFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("side");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function readAppellantFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("appellant");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (s === null) url.searchParams.delete("side");
|
||||
else url.searchParams.set("side", s);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
function writeAppellantToURL(a: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (a === null) url.searchParams.delete("appellant");
|
||||
else url.searchParams.set("appellant", a);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
@@ -154,20 +219,31 @@ async function doCalc() {
|
||||
}
|
||||
|
||||
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||
// label from the calc response. The root rule (isRootEvent=true) is
|
||||
// the first event in the proceeding — e.g. Klageerhebung for
|
||||
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
|
||||
// active proceeding name if no root rule fires (shouldn't happen for
|
||||
// healthy data, but safer than a blank). Fallback respects language —
|
||||
// proceedingNameEN is consulted on EN before the DE proceedingName
|
||||
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
|
||||
// proceedings like upc.ccr.cfi which had no rules → no root).
|
||||
// label from the calc response. Precedence:
|
||||
//
|
||||
// 1. Server-supplied triggerEventLabel from proceeding_types
|
||||
// (mig 121, m/paliad#81). UPC Appeal sets this to
|
||||
// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
|
||||
// all carry a non-zero duration off the trigger date so none is
|
||||
// the root, and the proceedingName fallback ("Berufungsverfahren")
|
||||
// misnamed the input as the proceeding itself.
|
||||
// 2. Root rule (isRootEvent=true) — the first event in the
|
||||
// proceeding, e.g. Klageerhebung for upc.inf.cfi,
|
||||
// Nichtigkeitsklage for upc.rev.cfi.
|
||||
// 3. Active proceeding name — last-resort fallback. Language-aware
|
||||
// (m/paliad#58: prior code rendered DE on EN for sub-track
|
||||
// proceedings like upc.ccr.cfi which had no rules → no root).
|
||||
function triggerEventLabelFor(data: DeadlineResponse): string {
|
||||
const lang = getLang();
|
||||
const curated = lang === "en"
|
||||
? (data.triggerEventLabelEN || data.triggerEventLabel)
|
||||
: (data.triggerEventLabel || data.triggerEventLabelEN);
|
||||
if (curated) return curated;
|
||||
const root = data.deadlines.find((d) => d.isRootEvent);
|
||||
if (root) {
|
||||
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
}
|
||||
if (getLang() === "en") {
|
||||
if (lang === "en") {
|
||||
return data.proceedingNameEN || data.proceedingName || "";
|
||||
}
|
||||
return data.proceedingName || data.proceedingNameEN || "";
|
||||
@@ -213,7 +289,12 @@ function renderResults(data: DeadlineResponse) {
|
||||
: "";
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
? renderColumnsBody(data, {
|
||||
editable: true,
|
||||
showNotes,
|
||||
side: currentSide,
|
||||
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
|
||||
})
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + noteHtml + bodyHtml;
|
||||
@@ -276,6 +357,7 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppellantRowVisibility();
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
@@ -283,6 +365,29 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
scheduleCalc(0);
|
||||
}
|
||||
|
||||
// syncAppellantRowVisibility hides the appellant selector for
|
||||
// proceedings that have no appellant axis (first-instance Inf, Rev,
|
||||
// …). Clears the in-memory state and the URL param when hidden so a
|
||||
// shared link with ?appellant= doesn't leak into an unrelated
|
||||
// proceeding's render.
|
||||
function syncAppellantRowVisibility() {
|
||||
const row = document.getElementById("appellant-row");
|
||||
if (!row) return;
|
||||
const visible = hasAppellantAxis(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppellant !== null) {
|
||||
currentAppellant = null;
|
||||
writeAppellantToURL(null);
|
||||
syncRadioGroup("appellant", "");
|
||||
}
|
||||
}
|
||||
|
||||
function syncRadioGroup(name: string, value: string) {
|
||||
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
|
||||
input.checked = input.value === value;
|
||||
});
|
||||
}
|
||||
|
||||
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
|
||||
// Mirrors the events.ts pattern (body.events-view-*). The print
|
||||
// stylesheet keys `body.verfahrensablauf-view-timeline` to
|
||||
@@ -321,6 +426,38 @@ function initViewToggle() {
|
||||
toggle.style.display = "none";
|
||||
}
|
||||
|
||||
// initPerspectiveControls hydrates side+appellant from the URL,
|
||||
// reflects state into the radio inputs, and wires onchange handlers
|
||||
// that update state + URL + re-render. Re-render path skips the
|
||||
// /api/tools/fristenrechner round-trip — perspective is a pure
|
||||
// projection of the last response, no backend involved.
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppellant = readAppellantFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeAppellantToURL(currentAppellant);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
@@ -390,6 +527,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
onLangChange(() => {
|
||||
// Active-button name updates with language change (the data-i18n
|
||||
|
||||
@@ -32,6 +32,11 @@ export function renderListShape(host: HTMLElement, rows: ViewRow[], render: Rend
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowAction === "inbox") {
|
||||
host.appendChild(renderInboxList(sorted));
|
||||
return;
|
||||
}
|
||||
|
||||
if (density === "compact") {
|
||||
host.appendChild(renderCompact(sorted));
|
||||
} else {
|
||||
@@ -147,8 +152,22 @@ function formatColumn(row: ViewRow, col: string): string {
|
||||
const s = (row.detail.status as string | undefined) ?? "";
|
||||
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
|
||||
}
|
||||
case "rule":
|
||||
return (row.detail.rule_code as string | undefined) ?? "—";
|
||||
case "rule": {
|
||||
// t-paliad-258 — canonical "Name · Citation" pattern; fall back
|
||||
// to custom_rule_text + " · Custom" for Custom-mode deadlines.
|
||||
const lang = getLang();
|
||||
const nameKey = lang === "en" ? "rule_name_en" : "rule_name";
|
||||
const name = (row.detail[nameKey] as string | undefined)
|
||||
|| (row.detail.rule_name as string | undefined)
|
||||
|| "";
|
||||
const cite = (row.detail.rule_code as string | undefined) ?? "";
|
||||
if (name && cite) return `${name} · ${cite}`;
|
||||
if (name) return name;
|
||||
if (cite) return cite;
|
||||
const custom = (row.detail.custom_rule_text as string | undefined) ?? "";
|
||||
if (custom.trim()) return `${custom} · Custom`;
|
||||
return "—";
|
||||
}
|
||||
case "event_type":
|
||||
return (row.detail.event_type as string | undefined) ?? "—";
|
||||
case "location":
|
||||
@@ -219,111 +238,215 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list views-approval-list";
|
||||
for (const row of rows) {
|
||||
const detail = (row.detail || {}) as ApprovalDetail;
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row views-approval-row";
|
||||
li.dataset.requestId = row.id;
|
||||
li.dataset.status = detail.status ?? "";
|
||||
|
||||
// Header: entity / lifecycle
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
|
||||
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
|
||||
const entityTitle = detail.entity_title || row.title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = detail.required_role
|
||||
? t(("approvals.required_role." + detail.required_role) as I18nKey)
|
||||
: "";
|
||||
const requester = detail.requester_name || row.actor_name || "";
|
||||
const requesterTag = detail.requester_kind === "agent"
|
||||
? `${requester} ✨ ${t("approvals.agent.byline")}`
|
||||
: requester;
|
||||
const projectTitle = row.project_title ?? "";
|
||||
const parts = [
|
||||
projectTitle,
|
||||
`${reqByLabel} ${requesterTag}`,
|
||||
];
|
||||
if (roleLabel) parts.push(`${roleLabel}+`);
|
||||
parts.push(formatRelativeTime(row.event_date));
|
||||
meta.textContent = parts.filter(Boolean).join(" · ");
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete
|
||||
const diff = renderDiff(detail);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
if (detail.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = detail.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row — surface attaches handlers via data-attrs.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// All four actions are stamped on every pending row; the per-viewer
|
||||
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
|
||||
// decide which are enabled vs. greyed out with a tooltip. m's ask
|
||||
// (2026-05-17): show what's possible but disable what isn't, rather
|
||||
// than alert-after-click. The server still enforces — disabled buttons
|
||||
// are a UI hint, not a security gate.
|
||||
//
|
||||
// suggest_changes is hidden for non-update lifecycles (the backend
|
||||
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
|
||||
// so we don't even render the button for them).
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
if (detail.lifecycle_event === "update") {
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
}
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
} else if (detail.status) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
|
||||
if (detail.decider_name && detail.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
// Back-link from the OLD changes_requested row to the NEW pending
|
||||
// counter row (t-paliad-216). Hydrated server-side as
|
||||
// detail.next_request_id; the surface renders a link that scrolls
|
||||
// / filters to the new row. Falsy next_request_id = no link (e.g.
|
||||
// older rows pre-mig-103, or rows where the server hasn't joined the
|
||||
// back-pointer).
|
||||
if (detail.status === "changes_requested" && detail.next_request_id) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "inbox-row-next-request";
|
||||
link.href = `#request-${detail.next_request_id}`;
|
||||
link.dataset.nextRequestId = detail.next_request_id;
|
||||
const deciderName = detail.decider_name || "";
|
||||
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
|
||||
li.appendChild(link);
|
||||
}
|
||||
|
||||
ul.appendChild(li);
|
||||
ul.appendChild(renderApprovalRow(row));
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
// renderApprovalRow stamps one <li> for an approval_request row.
|
||||
// Factored out of renderApprovalList in t-paliad-249 so the unified
|
||||
// inbox dispatch (renderInboxList) can reuse the exact same markup for
|
||||
// approval rows interleaved with project_event rows.
|
||||
export function renderApprovalRow(row: ViewRow): HTMLLIElement {
|
||||
const detail = (row.detail || {}) as ApprovalDetail;
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row views-approval-row";
|
||||
li.dataset.requestId = row.id;
|
||||
li.dataset.status = detail.status ?? "";
|
||||
|
||||
// Header: entity / lifecycle
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
|
||||
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
|
||||
const entityTitle = detail.entity_title || row.title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = detail.required_role
|
||||
? t(("approvals.required_role." + detail.required_role) as I18nKey)
|
||||
: "";
|
||||
const requester = detail.requester_name || row.actor_name || "";
|
||||
const requesterTag = detail.requester_kind === "agent"
|
||||
? `${requester} ✨ ${t("approvals.agent.byline")}`
|
||||
: requester;
|
||||
const projectTitle = row.project_title ?? "";
|
||||
const parts = [
|
||||
projectTitle,
|
||||
`${reqByLabel} ${requesterTag}`,
|
||||
];
|
||||
if (roleLabel) parts.push(`${roleLabel}+`);
|
||||
parts.push(formatRelativeTime(row.event_date));
|
||||
meta.textContent = parts.filter(Boolean).join(" · ");
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete
|
||||
const diff = renderDiff(detail);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
if (detail.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = detail.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row — surface attaches handlers via data-attrs.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// All four actions are stamped on every pending row; the per-viewer
|
||||
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
|
||||
// decide which are enabled vs. greyed out with a tooltip. m's ask
|
||||
// (2026-05-17): show what's possible but disable what isn't, rather
|
||||
// than alert-after-click. The server still enforces — disabled buttons
|
||||
// are a UI hint, not a security gate.
|
||||
//
|
||||
// suggest_changes is hidden for non-update lifecycles (the backend
|
||||
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
|
||||
// so we don't even render the button for them).
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
if (detail.lifecycle_event === "update") {
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
}
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
} else if (detail.status) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
|
||||
if (detail.decider_name && detail.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
// Back-link from the OLD changes_requested row to the NEW pending
|
||||
// counter row (t-paliad-216). Hydrated server-side as
|
||||
// detail.next_request_id; the surface renders a link that scrolls
|
||||
// / filters to the new row. Falsy next_request_id = no link (e.g.
|
||||
// older rows pre-mig-103, or rows where the server hasn't joined the
|
||||
// back-pointer).
|
||||
if (detail.status === "changes_requested" && detail.next_request_id) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "inbox-row-next-request";
|
||||
link.href = `#request-${detail.next_request_id}`;
|
||||
link.dataset.nextRequestId = detail.next_request_id;
|
||||
const deciderName = detail.decider_name || "";
|
||||
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
|
||||
li.appendChild(link);
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// row_action = "inbox" — unified inbox layout (t-paliad-249)
|
||||
//
|
||||
// Dispatches per row.kind so approval_request rows reuse the existing
|
||||
// approve/reject/revoke markup while project_event rows render as a
|
||||
// compact stream row (timestamp + actor + title + project chip +
|
||||
// Öffnen link to the underlying entity).
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function renderInboxList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list inbox-list--unified";
|
||||
for (const row of rows) {
|
||||
if (row.kind === "approval_request") {
|
||||
ul.appendChild(renderApprovalRow(row));
|
||||
} else if (row.kind === "project_event") {
|
||||
ul.appendChild(renderProjectEventInboxRow(row));
|
||||
}
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
interface ProjectEventDetail {
|
||||
event_type?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
function renderProjectEventInboxRow(row: ViewRow): HTMLLIElement {
|
||||
const detail = (row.detail || {}) as ProjectEventDetail;
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row inbox-row--project-event";
|
||||
li.dataset.eventId = row.id;
|
||||
if (detail.event_type) li.dataset.eventType = detail.event_type;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
// Prefer the row.title (server-side authored, project-aware); fall
|
||||
// back to a synthesised event-kind label so a malformed row never
|
||||
// produces an empty <li>.
|
||||
const kindLabelText = detail.event_type ? t(("event.title." + detail.event_type) as I18nKey) : "";
|
||||
title.textContent = row.title || kindLabelText || "—";
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const parts: string[] = [];
|
||||
if (row.project_title) parts.push(row.project_title);
|
||||
if (row.actor_name) parts.push(row.actor_name);
|
||||
parts.push(formatRelativeTime(row.event_date));
|
||||
meta.textContent = parts.filter(Boolean).join(" · ");
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
if (detail.description) {
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "inbox-row-description";
|
||||
desc.textContent = detail.description;
|
||||
li.appendChild(desc);
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
const openLink = projectEventLink(row, detail);
|
||||
if (openLink) actions.appendChild(openLink);
|
||||
li.appendChild(actions);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
// projectEventLink builds an "Öffnen" anchor that points to the most
|
||||
// useful target for the event kind. Falls back to the project detail
|
||||
// page when the kind doesn't carry a richer pointer.
|
||||
//
|
||||
// Slice B can deepen this (e.g. note_created → scroll to note anchor);
|
||||
// keep it minimal for Slice A.
|
||||
function projectEventLink(row: ViewRow, detail: ProjectEventDetail): HTMLAnchorElement | null {
|
||||
if (!row.project_id) return null;
|
||||
const kind = detail.event_type ?? "";
|
||||
const a = document.createElement("a");
|
||||
a.className = "inbox-row-open";
|
||||
a.textContent = t("inbox.action.open");
|
||||
if (kind.startsWith("deadline_")) {
|
||||
a.href = `/projects/${row.project_id}#deadlines`;
|
||||
} else if (kind.startsWith("appointment_")) {
|
||||
a.href = `/projects/${row.project_id}#appointments`;
|
||||
} else if (kind === "note_created") {
|
||||
a.href = `/projects/${row.project_id}#notes`;
|
||||
} else {
|
||||
a.href = `/projects/${row.project_id}`;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
||||
const before = (detail.pre_image || {}) as Record<string, unknown>;
|
||||
const after = (detail.payload || {}) as Record<string, unknown>;
|
||||
|
||||
@@ -19,8 +19,8 @@ export interface ScopeSpec {
|
||||
}
|
||||
|
||||
export type TimeHorizon =
|
||||
| "next_7d" | "next_30d" | "next_90d"
|
||||
| "past_7d" | "past_30d" | "past_90d"
|
||||
| "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "all" | "custom";
|
||||
|
||||
export type TimeField = "auto" | "created_at";
|
||||
@@ -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[];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
@@ -65,3 +66,141 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
});
|
||||
|
||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||
// left") instead of the misleading Proaktiv/Reaktiv pair.
|
||||
// Hits bucketDeadlinesIntoColumns directly so the assertions stay
|
||||
// in pure-Node territory (renderColumnsBody goes through escHtml ->
|
||||
// document.createElement which isn't available in plain bun test).
|
||||
//
|
||||
// Scenario fixture mirrors the UPC Appeal "both parties" case m
|
||||
// pasted into #81: every filing rule carries party='both' so the
|
||||
// legacy mirror path duplicates every row across both columns.
|
||||
// With ?appellant= set, the duplicate must collapse to a single
|
||||
// row in the appellant's column.
|
||||
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => {
|
||||
const both = (name: string, due: string): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
});
|
||||
const partySpecific = (party: string, name: string, due: string): CalculatedDeadline => ({
|
||||
...both(name, due),
|
||||
party,
|
||||
});
|
||||
|
||||
test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].court).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
]);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
});
|
||||
|
||||
test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
|
||||
{ appellant: "claimant" },
|
||||
);
|
||||
expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([
|
||||
["Notice of Appeal"],
|
||||
["Statement of Grounds"],
|
||||
]);
|
||||
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
|
||||
});
|
||||
|
||||
test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => {
|
||||
// User is on the defendant side: defendant filings land in 'ours'
|
||||
// (left), claimant filings land in 'opponent' (right). Court rules
|
||||
// stay in court regardless of side.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
partySpecific("court", "Urteil", "2026-10-01"),
|
||||
],
|
||||
{ side: "defendant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => {
|
||||
// The user is the defendant AND the appellant, so the appellant's
|
||||
// column == the user's own column == ours after the swap.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => {
|
||||
// Side flip + appellant axis combined: the claimant is the appellant
|
||||
// but NOT us, so the collapsed 'both' row lands in the opponent
|
||||
// column (right). This is the UPC Appeal "they appealed, we
|
||||
// respond" scenario.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "claimant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
|
||||
const sameDate = "2026-07-23";
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "A", sameDate),
|
||||
partySpecific("defendant", "B", sameDate),
|
||||
partySpecific("court", "C", sameDate),
|
||||
]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]);
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
|
||||
});
|
||||
|
||||
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("court", "Oral Hearing", ""),
|
||||
partySpecific("claimant", "Statement of Claim", "2026-01-01"),
|
||||
partySpecific("court", "Decision", ""),
|
||||
]);
|
||||
expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([
|
||||
["Statement of Claim"],
|
||||
["Oral Hearing"],
|
||||
["Decision"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,16 @@ export interface DeadlineResponse {
|
||||
// explains the framing. (m/paliad#58)
|
||||
contextualNote?: string;
|
||||
contextualNoteEN?: string;
|
||||
// triggerEventLabel / triggerEventLabelEN: optional caption for the
|
||||
// "Auslösendes Ereignis" / "Triggering event" field on
|
||||
// /tools/verfahrensablauf. Populated from paliad.proceeding_types
|
||||
// when set (mig 121). The page prefers this over the proceedingName
|
||||
// fallback that fires when no rule has isRootEvent=true. UPC Appeal
|
||||
// uses this so the field reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// (m/paliad#81)
|
||||
triggerEventLabel?: string;
|
||||
triggerEventLabelEN?: string;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -412,42 +422,124 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row shares a dueDate so same-day events line up
|
||||
// across columns; party=both renders in BOTH the Proactive and Reactive
|
||||
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
|
||||
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite.
|
||||
//
|
||||
// The columns are user-perspective ("WE are always on the left", per
|
||||
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
|
||||
// Klägerseite is sometimes proactive (filing the claim) and sometimes
|
||||
// reactive (responding to a counterclaim), so the static "Proaktiv =
|
||||
// Klägerseite" label-pair was wrong half the time. The new axis is
|
||||
// "ours vs opponent" — the side toggle picks who WE are in this
|
||||
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
|
||||
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
|
||||
// placement re-resolves around that pick.
|
||||
//
|
||||
// Column assignment per deadline (default opts.side === null keeps
|
||||
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
|
||||
//
|
||||
// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent
|
||||
// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
|
||||
// - party=court → court (independent of side)
|
||||
// - party=both → BOTH ours AND opponent (mirror)
|
||||
//
|
||||
// When `opts.appellant` is set (claimant|defendant), "both" rows
|
||||
// collapse to a single row in the appellant's column — the intent is
|
||||
// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both"
|
||||
// really means "either party files, depending on who initiated".
|
||||
// Appellant axis is independent of `side`: in an Appeal CoA, the
|
||||
// appellant selector pins which party appealed; the side toggle
|
||||
// still picks which of those is us.
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
// Internal column-position alias. "ours" is always rendered in the
|
||||
// left grid column ("Unsere Seite"); "opponent" is always the right
|
||||
// column ("Gegnerseite"). Field names mirror the labels so the
|
||||
// bucketing primitive reads as a direct mapping.
|
||||
type ColumnPosition = "ours" | "opponent";
|
||||
|
||||
export interface ColumnsBodyOpts {
|
||||
editable?: boolean;
|
||||
showNotes?: boolean;
|
||||
// side: which side the user is on. Drives column placement;
|
||||
// does NOT filter rows. Default null = claimant-on-the-left
|
||||
// (i.e. "ours = claimant", legacy default).
|
||||
side?: Side;
|
||||
// appellant: which side initiated the appeal / counterclaim.
|
||||
// When set, party=both rows go to the appellant's column ONLY
|
||||
// (no mirror). Default null = mirror "both" into both cells
|
||||
// (legacy behaviour). Independent of `side`.
|
||||
appellant?: Side;
|
||||
}
|
||||
|
||||
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
|
||||
// so unit tests can hit the pure routing logic without going through
|
||||
// document.createElement (no jsdom in this repo).
|
||||
export interface ColumnsRow {
|
||||
key: string;
|
||||
ours: CalculatedDeadline[];
|
||||
court: CalculatedDeadline[];
|
||||
opponent: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
export interface BucketingOpts {
|
||||
side?: Side;
|
||||
appellant?: Side;
|
||||
}
|
||||
|
||||
// bucketDeadlinesIntoColumns is the pure routing primitive that
|
||||
// renderColumnsBody uses. Extracted as its own export so the per-row
|
||||
// column placement (including the side-swap + appellant-collapse
|
||||
// logic from m/paliad#81 and the user-perspective re-frame from
|
||||
// m/paliad#88) is unit-testable without a DOM. The returned rows are
|
||||
// sorted: dated rows ascending by dueDate, then unscheduled rows in
|
||||
// declaration order (each keyed by sequence).
|
||||
export function bucketDeadlinesIntoColumns(
|
||||
deadlines: CalculatedDeadline[],
|
||||
opts: BucketingOpts = {},
|
||||
): ColumnsRow[] {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
// Default (side=null) treats the user as claimant — keeps the
|
||||
// legacy claimant-on-the-left layout when no perspective is picked.
|
||||
const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
|
||||
const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
|
||||
const appellantColumn: ColumnPosition | null =
|
||||
opts.appellant === "claimant" ? claimantColumn
|
||||
: opts.appellant === "defendant" ? defendantColumn
|
||||
: null;
|
||||
|
||||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
const rowsMap = new Map<string, ColumnsRow>();
|
||||
const ensureRow = (key: string): ColumnsRow => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
r = { key, ours: [], court: [], opponent: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
data.deadlines.forEach((dl, idx) => {
|
||||
deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
row.proactive.push(dl);
|
||||
row[claimantColumn].push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
row.reactive.push(dl);
|
||||
row[defendantColumn].push(dl);
|
||||
break;
|
||||
case "court":
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
if (appellantColumn !== null) {
|
||||
// Role-swap collapse: appellant initiated → both → one row
|
||||
// in appellant's column. Mirror suppressed.
|
||||
row[appellantColumn].push(dl);
|
||||
} else {
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
row.court.push(dl);
|
||||
@@ -462,17 +554,28 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
}
|
||||
datedKeys.sort();
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
return [...datedKeys, ...unscheduledKeys].map((k) => rowsMap.get(k)!);
|
||||
}
|
||||
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
|
||||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
|
||||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||||
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
|
||||
// be misleading. Keep it for the legacy mirror path.
|
||||
const showMirrorTag = !appellantPinned;
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
const mirrorTag = showMirrorTag && dl.party === "both"
|
||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
@@ -487,16 +590,19 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.ours);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
html += renderCell(row.opponent);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
|
||||
@@ -13,6 +13,10 @@ const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
// at a glance.
|
||||
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
// Document-with-lines icon for /submissions (t-paliad-240) — distinct
|
||||
// from ICON_BOOK / ICON_BOOK_OPEN / ICON_NEWSPAPER so the Schriftsätze
|
||||
// affordance reads as "a draft document" at a glance.
|
||||
const ICON_FILE_TEXT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
|
||||
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
|
||||
@@ -175,6 +179,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
||||
@@ -202,6 +207,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
|
||||
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}
|
||||
|
||||
@@ -41,6 +41,19 @@ export function renderDeadlinesDetail(): string {
|
||||
<div className="entity-detail-title-col">
|
||||
<h1 id="deadline-title-display" />
|
||||
<input type="text" id="deadline-title-edit" className="entity-title-input" style="display:none" />
|
||||
{/* t-paliad-251 Part 4 — Standardtitel button only
|
||||
visible in edit mode; clicking replaces the
|
||||
title with a default derived from the project
|
||||
and the deadline's event types / rule. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
<div className="entity-detail-meta">
|
||||
<span id="deadline-due-chip" className="frist-due-chip" />
|
||||
<span id="deadline-status-chip" className="entity-status-chip" />
|
||||
@@ -95,7 +108,36 @@ export function renderDeadlinesDetail(): string {
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
<dd>
|
||||
<span id="deadline-rule-display">—</span>
|
||||
{/* t-paliad-258 — Auto / Custom rule editor.
|
||||
Mirrors /deadlines/new: read-only Auto display
|
||||
(resolved from Type) or free-text Custom input,
|
||||
with a toggle link. Hidden outside edit mode. */}
|
||||
<div className="rule-edit-block" id="deadline-rule-edit" style="display:none">
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-rule-mode-toggle"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
|
||||
>
|
||||
Eigene Regel eingeben
|
||||
</button>
|
||||
<div className="rule-mode-auto" id="deadline-rule-auto-display">
|
||||
<span className="form-hint-badge" data-i18n="deadlines.field.rule.auto_badge">Auto</span>
|
||||
<span id="deadline-rule-auto-text" className="rule-auto-text">—</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-rule-custom-input"
|
||||
className="rule-mode-custom"
|
||||
style="display:none"
|
||||
placeholder="z.B. interner Review-Termin"
|
||||
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.source">Quelle</dt>
|
||||
<dd id="deadline-source-display" />
|
||||
|
||||
@@ -45,7 +45,22 @@ export function renderDeadlinesNew(): string {
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
<div className="form-field-label-row">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
{/* t-paliad-251 Part 4 — derive a Standardtitel from the
|
||||
currently-known context (event type → rule → proceeding
|
||||
type → fallback) with the project reference as suffix.
|
||||
Always replaces the title; no destructive confirmation
|
||||
because the user invoked it explicitly. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-title"
|
||||
@@ -57,58 +72,42 @@ export function renderDeadlinesNew(): string {
|
||||
|
||||
<div className="form-field" id="deadline-event-type-field">
|
||||
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
|
||||
{/* t-paliad-165 follow-up — collapsed view: when a Regel
|
||||
is selected and a default event_type is known, the
|
||||
Typ chip is hidden and the type is rendered inline
|
||||
as a single read-only summary with an "Anderen Typ
|
||||
wählen" link that re-expands the picker. */}
|
||||
<div
|
||||
className="event-type-collapsed"
|
||||
id="deadline-event-type-collapsed"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="event-type-collapsed-label"
|
||||
id="deadline-event-type-collapsed-label"
|
||||
/>
|
||||
<span
|
||||
className="event-type-collapsed-source"
|
||||
data-i18n="deadlines.field.rule.autofill_inline"
|
||||
>
|
||||
(vorgegeben durch Regel)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="event-type-collapsed-override"
|
||||
id="deadline-event-type-override-btn"
|
||||
data-i18n="deadlines.field.rule.override"
|
||||
>
|
||||
Anderen Typ wählen
|
||||
</button>
|
||||
</div>
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
{/* Soft warning when the user is in expanded mode AND
|
||||
has picked an event_type that doesn't include the
|
||||
rule's canonical default. Reuses the existing
|
||||
yellow form-hint--warning style; never blocking. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-event-type-rule-mismatch"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.rule.mismatch"
|
||||
>
|
||||
Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* m/paliad#56 — Regel sits directly beneath the Typ
|
||||
picker so the parent/child relationship reads at a
|
||||
glance. Due date is its own row below. */}
|
||||
{/* t-paliad-258 / m/paliad#89 — binary Rule field.
|
||||
Auto (default): rule_id derived from the chosen
|
||||
Type, displayed read-only with a canonical
|
||||
"Name · Citation" label. Custom: free-text input,
|
||||
no catalog FK. Toggle switches modes. */}
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
</select>
|
||||
<div className="form-field-label-row">
|
||||
<label data-i18n="deadlines.field.rule">Regel</label>
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-rule-mode-toggle"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
|
||||
>
|
||||
Eigene Regel eingeben
|
||||
</button>
|
||||
</div>
|
||||
<div className="rule-mode-auto" id="deadline-rule-auto-display">
|
||||
<span
|
||||
className="form-hint-badge"
|
||||
data-i18n="deadlines.field.rule.auto_badge"
|
||||
>Auto</span>
|
||||
<span id="deadline-rule-auto-text" className="rule-auto-text">—</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-rule-custom-input"
|
||||
className="rule-mode-custom"
|
||||
style="display:none"
|
||||
placeholder="z.B. interner Review-Termin"
|
||||
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -90,6 +90,28 @@ export type I18nKey =
|
||||
| "admin.audit.source.reminder_log"
|
||||
| "admin.audit.subtitle"
|
||||
| "admin.audit.title"
|
||||
| "admin.backups.col.actions"
|
||||
| "admin.backups.col.kind"
|
||||
| "admin.backups.col.requested_by"
|
||||
| "admin.backups.col.rows"
|
||||
| "admin.backups.col.size"
|
||||
| "admin.backups.col.started"
|
||||
| "admin.backups.col.status"
|
||||
| "admin.backups.download"
|
||||
| "admin.backups.empty"
|
||||
| "admin.backups.footer.note"
|
||||
| "admin.backups.heading"
|
||||
| "admin.backups.kind.on_demand"
|
||||
| "admin.backups.kind.scheduled"
|
||||
| "admin.backups.loading"
|
||||
| "admin.backups.run_now"
|
||||
| "admin.backups.running"
|
||||
| "admin.backups.status.done"
|
||||
| "admin.backups.status.failed"
|
||||
| "admin.backups.status.running"
|
||||
| "admin.backups.subtitle"
|
||||
| "admin.backups.success"
|
||||
| "admin.backups.title"
|
||||
| "admin.broadcasts.col.count"
|
||||
| "admin.broadcasts.col.sender"
|
||||
| "admin.broadcasts.col.sent_at"
|
||||
@@ -682,9 +704,20 @@ export type I18nKey =
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
| "approvals.withdraw.cancel"
|
||||
| "approvals.withdraw.confirm"
|
||||
| "approvals.withdraw.cta"
|
||||
| "approvals.withdraw.destructive.label"
|
||||
| "approvals.withdraw.error"
|
||||
| "approvals.withdraw.lead.create.appointment"
|
||||
| "approvals.withdraw.lead.create.deadline"
|
||||
| "approvals.withdraw.lead.delete"
|
||||
| "approvals.withdraw.lead.update"
|
||||
| "approvals.withdraw.modal.title"
|
||||
| "approvals.withdraw.primary.label"
|
||||
| "approvals.withdraw.sub.create"
|
||||
| "approvals.withdraw.sub.delete"
|
||||
| "approvals.withdraw.sub.update"
|
||||
| "bottomnav.add"
|
||||
| "bottomnav.add.appointment"
|
||||
| "bottomnav.add.appointment.sub"
|
||||
@@ -1104,6 +1137,33 @@ export type I18nKey =
|
||||
| "dashboard.urgency.urgent"
|
||||
| "dashboard.when.today"
|
||||
| "dashboard.when.tomorrow"
|
||||
| "date_range.button.label"
|
||||
| "date_range.button.label.custom_range"
|
||||
| "date_range.center.label"
|
||||
| "date_range.custom.apply"
|
||||
| "date_range.custom.cancel"
|
||||
| "date_range.custom.from"
|
||||
| "date_range.custom.invalid"
|
||||
| "date_range.custom.invalid_format"
|
||||
| "date_range.custom.invalid_missing"
|
||||
| "date_range.custom.to"
|
||||
| "date_range.dialog.label"
|
||||
| "date_range.fan.future.label"
|
||||
| "date_range.fan.past.label"
|
||||
| "date_range.horizon.any"
|
||||
| "date_range.horizon.custom"
|
||||
| "date_range.horizon.next_14d"
|
||||
| "date_range.horizon.next_1d"
|
||||
| "date_range.horizon.next_30d"
|
||||
| "date_range.horizon.next_7d"
|
||||
| "date_range.horizon.next_90d"
|
||||
| "date_range.horizon.next_all"
|
||||
| "date_range.horizon.past_14d"
|
||||
| "date_range.horizon.past_1d"
|
||||
| "date_range.horizon.past_30d"
|
||||
| "date_range.horizon.past_7d"
|
||||
| "date_range.horizon.past_90d"
|
||||
| "date_range.horizon.past_all"
|
||||
| "deadlines.action.reopen"
|
||||
| "deadlines.adjusted"
|
||||
| "deadlines.adjusted.holiday"
|
||||
@@ -1112,6 +1172,10 @@ export type I18nKey =
|
||||
| "deadlines.adjusted.weekend"
|
||||
| "deadlines.adjusted.weekend.saturday"
|
||||
| "deadlines.adjusted.weekend.sunday"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
| "deadlines.appellant.none"
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.card.calc.add_to_project"
|
||||
| "deadlines.card.calc.add_to_project.disabled"
|
||||
@@ -1138,8 +1202,8 @@ export type I18nKey =
|
||||
| "deadlines.col.court"
|
||||
| "deadlines.col.due"
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
@@ -1227,12 +1291,16 @@ export type I18nKey =
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.rule.auto_badge"
|
||||
| "deadlines.field.rule.auto_no_match"
|
||||
| "deadlines.field.rule.auto_pick_type"
|
||||
| "deadlines.field.rule.custom_badge"
|
||||
| "deadlines.field.rule.custom_placeholder"
|
||||
| "deadlines.field.rule.mode.toggle_to_auto"
|
||||
| "deadlines.field.rule.mode.toggle_to_custom"
|
||||
| "deadlines.field.title"
|
||||
| "deadlines.field.title.default_btn"
|
||||
| "deadlines.field.title.default_fallback"
|
||||
| "deadlines.field.title.placeholder"
|
||||
| "deadlines.filter.akte"
|
||||
| "deadlines.filter.akte.all"
|
||||
@@ -1366,6 +1434,10 @@ export type I18nKey =
|
||||
| "deadlines.search.placeholder"
|
||||
| "deadlines.search.results.count"
|
||||
| "deadlines.search.results.count_one"
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
@@ -1574,6 +1646,8 @@ export type I18nKey =
|
||||
| "event_types.browse.apply"
|
||||
| "event_types.browse.cancel"
|
||||
| "event_types.browse.empty"
|
||||
| "event_types.browse.jurisdiction.all"
|
||||
| "event_types.browse.jurisdiction.filter_label"
|
||||
| "event_types.browse.jurisdiction.none"
|
||||
| "event_types.browse.search"
|
||||
| "event_types.browse.selected_count"
|
||||
@@ -1716,9 +1790,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"
|
||||
@@ -1869,6 +1949,7 @@ export type I18nKey =
|
||||
| "login.title"
|
||||
| "modal.close.label"
|
||||
| "nav.admin.audit"
|
||||
| "nav.admin.backups"
|
||||
| "nav.admin.bereich"
|
||||
| "nav.admin.event_types"
|
||||
| "nav.admin.paliadin"
|
||||
@@ -1904,6 +1985,7 @@ export type I18nKey =
|
||||
| "nav.paliadin"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.submissions"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
@@ -2187,6 +2269,11 @@ export type I18nKey =
|
||||
| "projects.detail.parteien.role.defendant"
|
||||
| "projects.detail.parteien.role.thirdparty"
|
||||
| "projects.detail.save"
|
||||
| "projects.detail.settings.archive.cta"
|
||||
| "projects.detail.settings.archive.description"
|
||||
| "projects.detail.settings.archive.heading"
|
||||
| "projects.detail.settings.export.description"
|
||||
| "projects.detail.settings.export.heading"
|
||||
| "projects.detail.smarttimeline.add.cancel"
|
||||
| "projects.detail.smarttimeline.add.choice.amend"
|
||||
| "projects.detail.smarttimeline.add.choice.appointment"
|
||||
@@ -2260,6 +2347,7 @@ export type I18nKey =
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.submissions.action.edit"
|
||||
| "projects.detail.submissions.action.generate"
|
||||
| "projects.detail.submissions.action.no_template"
|
||||
| "projects.detail.submissions.col.action"
|
||||
@@ -2275,6 +2363,7 @@ export type I18nKey =
|
||||
| "projects.detail.tab.kinder"
|
||||
| "projects.detail.tab.notizen"
|
||||
| "projects.detail.tab.parteien"
|
||||
| "projects.detail.tab.settings"
|
||||
| "projects.detail.tab.submissions"
|
||||
| "projects.detail.tab.team"
|
||||
| "projects.detail.tab.termine"
|
||||
@@ -2495,6 +2584,45 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "submissions.draft.action.delete"
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.loading"
|
||||
| "submissions.draft.name.placeholder"
|
||||
| "submissions.draft.notfound"
|
||||
| "submissions.draft.preview.hint"
|
||||
| "submissions.draft.preview.title"
|
||||
| "submissions.draft.switcher.label"
|
||||
| "submissions.draft.title"
|
||||
| "submissions.index.action.new"
|
||||
| "submissions.index.col.draft"
|
||||
| "submissions.index.col.project"
|
||||
| "submissions.index.col.submission"
|
||||
| "submissions.index.col.updated"
|
||||
| "submissions.index.empty"
|
||||
| "submissions.index.empty.cta"
|
||||
| "submissions.index.error"
|
||||
| "submissions.index.heading"
|
||||
| "submissions.index.loading"
|
||||
| "submissions.index.subtitle"
|
||||
| "submissions.index.title"
|
||||
| "submissions.new.back"
|
||||
| "submissions.new.col.actions"
|
||||
| "submissions.new.col.name"
|
||||
| "submissions.new.col.party"
|
||||
| "submissions.new.col.source"
|
||||
| "submissions.new.empty.filtered"
|
||||
| "submissions.new.error"
|
||||
| "submissions.new.heading"
|
||||
| "submissions.new.loading"
|
||||
| "submissions.new.picker.empty"
|
||||
| "submissions.new.picker.loading"
|
||||
| "submissions.new.picker.placeholder"
|
||||
| "submissions.new.picker.title"
|
||||
| "submissions.new.search.placeholder"
|
||||
| "submissions.new.subtitle"
|
||||
| "submissions.new.title"
|
||||
| "team.broadcast.body"
|
||||
| "team.broadcast.body_placeholder"
|
||||
| "team.broadcast.button"
|
||||
@@ -2589,12 +2717,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"
|
||||
@@ -2602,6 +2735,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"
|
||||
@@ -2619,16 +2753,6 @@ export type I18nKey =
|
||||
| "views.bar.shape.list"
|
||||
| "views.bar.sort.date_asc"
|
||||
| "views.bar.sort.date_desc"
|
||||
| "views.bar.time.all"
|
||||
| "views.bar.time.any"
|
||||
| "views.bar.time.custom"
|
||||
| "views.bar.time.custom.coming_soon"
|
||||
| "views.bar.time.next_30d"
|
||||
| "views.bar.time.next_7d"
|
||||
| "views.bar.time.next_90d"
|
||||
| "views.bar.time.past_30d"
|
||||
| "views.bar.time.past_7d"
|
||||
| "views.bar.time.past_90d"
|
||||
| "views.bar.timeline_status.court_set"
|
||||
| "views.bar.timeline_status.done"
|
||||
| "views.bar.timeline_status.macro.future"
|
||||
@@ -2641,6 +2765,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"
|
||||
@@ -2701,11 +2827,18 @@ export type I18nKey =
|
||||
| "views.horizon.all"
|
||||
| "views.horizon.any"
|
||||
| "views.horizon.custom"
|
||||
| "views.horizon.next_14d"
|
||||
| "views.horizon.next_1d"
|
||||
| "views.horizon.next_30d"
|
||||
| "views.horizon.next_7d"
|
||||
| "views.horizon.next_90d"
|
||||
| "views.horizon.next_all"
|
||||
| "views.horizon.past_14d"
|
||||
| "views.horizon.past_1d"
|
||||
| "views.horizon.past_30d"
|
||||
| "views.horizon.past_7d"
|
||||
| "views.horizon.past_90d"
|
||||
| "views.horizon.past_all"
|
||||
| "views.kind.appointment"
|
||||
| "views.kind.approval_request"
|
||||
| "views.kind.deadline"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -89,20 +89,9 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
|
||||
{/* t-paliad-214 Slice 2 — project-subtree export button.
|
||||
Sits at the end of the tab nav. Hidden by default; the
|
||||
client unhides it after /api/me confirms the caller can
|
||||
extract (responsibility ∈ {lead, member} OR global_admin). */}
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="entity-tab entity-tab-action"
|
||||
style="display:none"
|
||||
title=""
|
||||
data-i18n-title="projects.detail.export.tooltip"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
{/* Verwaltung — rare admin actions (export, archive). Sits
|
||||
last in the tab list per t-paliad-245. */}
|
||||
<a className="entity-tab" data-tab="settings" href="#" data-i18n="projects.detail.tab.settings">Verwaltung</a>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
@@ -625,17 +614,18 @@ export function renderProjectsDetail(): string {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Submissions (Schriftsätze) — t-paliad-215 Slice 1.
|
||||
Lists the project's filing-type rules with a per-row
|
||||
[Generieren] button when a .docx template resolves
|
||||
in the registry's fallback chain (firm → base/code →
|
||||
base/family → skeleton). Empty for projects with no
|
||||
proceeding bound; otherwise enumerates every active
|
||||
filing rule for the proceeding. */}
|
||||
{/* Submissions (Schriftsätze) — t-paliad-242 broadened
|
||||
the original t-paliad-215 list to the full
|
||||
cross-proceeding catalog. The table shows every
|
||||
active filing rule across every proceeding, grouped
|
||||
by proceeding; the project's own proceeding is
|
||||
pinned to the top. The no-proceeding hint stays as
|
||||
a soft nudge above the catalog (the table renders
|
||||
regardless). */}
|
||||
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
|
||||
<div id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none">
|
||||
<p data-i18n="projects.detail.submissions.empty.no_proceeding">
|
||||
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt. Bitte im Projekt bearbeiten.
|
||||
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -646,7 +636,7 @@ export function renderProjectsDetail(): string {
|
||||
</button>
|
||||
</div>
|
||||
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
|
||||
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
|
||||
Es sind aktuell keine Schriftsatzvorlagen hinterlegt.
|
||||
</p>
|
||||
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
@@ -665,6 +655,39 @@ export function renderProjectsDetail(): string {
|
||||
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Verwaltung — rare admin actions (export, archive). Each
|
||||
sub-section hides itself if the caller is not entitled
|
||||
(export: §4 gate; archive: global_admin). */}
|
||||
<section className="entity-tab-panel" id="tab-settings" style="display:none">
|
||||
<div className="settings-section" id="project-settings-export" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.export.heading">Daten exportieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.export.description">
|
||||
Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-section" id="project-settings-archive" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.archive.heading">Projekt archivieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.archive.description">
|
||||
Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-settings-archive-link"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.settings.archive.cta">
|
||||
Bearbeiten öffnen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Full edit modal — same form as /projects/new, pre-filled. */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
143
frontend/src/submission-draft.tsx
Normal file
143
frontend/src/submission-draft.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-238 Slice A — dedicated Submissions/Schriftsätze editor page.
|
||||
//
|
||||
// Lawyer picks (or creates) a draft for one (project, submission_code),
|
||||
// edits placeholder variables in a sticky sidebar, sees a read-only
|
||||
// HTML preview of the merged document, and exports the result as
|
||||
// .docx. Drafts persist server-side per paliad.submission_drafts.
|
||||
//
|
||||
// Pure shell: client/submission-draft.ts hydrates draft list + variable
|
||||
// form + preview pane after page load. The same dist/submission-draft.html
|
||||
// serves every (project_id, submission_code, [draft_id]) URL.
|
||||
//
|
||||
// Design ref: docs/design-submission-page-2026-05-22.md §6.
|
||||
|
||||
export function renderSubmissionDraft(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.draft.title">Schriftsatz bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-submission-draft">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submission-draft-page">
|
||||
<div className="container">
|
||||
<a
|
||||
id="submission-draft-back-link"
|
||||
href="/projects"
|
||||
className="back-link"
|
||||
data-i18n="submissions.draft.back">
|
||||
← Zurück zum Projekt
|
||||
</a>
|
||||
|
||||
<div id="submission-draft-loading" className="entity-loading">
|
||||
<p data-i18n="submissions.draft.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-notfound" className="entity-empty" style="display:none">
|
||||
<p data-i18n="submissions.draft.notfound">
|
||||
Schriftsatz nicht gefunden oder keine Berechtigung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-error" className="entity-empty" style="display:none" />
|
||||
|
||||
<div id="submission-draft-body" style="display:none">
|
||||
<header className="submission-draft-header">
|
||||
<div className="submission-draft-header-text">
|
||||
<h1 id="submission-draft-title" />
|
||||
<p id="submission-draft-subtitle" className="tool-subtitle" />
|
||||
</div>
|
||||
<div className="submission-draft-header-actions">
|
||||
<button
|
||||
id="submission-draft-export-btn"
|
||||
type="button"
|
||||
className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.draft.action.export">
|
||||
Als .docx exportieren
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="submission-draft-grid">
|
||||
{/* Sidebar — draft switcher + variable groups. */}
|
||||
<aside className="submission-draft-sidebar" id="submission-draft-sidebar">
|
||||
<div className="submission-draft-switcher">
|
||||
<label htmlFor="submission-draft-pick" data-i18n="submissions.draft.switcher.label">
|
||||
Entwurf
|
||||
</label>
|
||||
<select id="submission-draft-pick" />
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-new-btn"
|
||||
className="btn-small btn-secondary"
|
||||
data-i18n="submissions.draft.action.new">
|
||||
+ Neuer Entwurf
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="submission-draft-name-row">
|
||||
<input
|
||||
type="text"
|
||||
id="submission-draft-name"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.draft.name.placeholder"
|
||||
placeholder="Name dieses Entwurfs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-delete-btn"
|
||||
className="btn-small btn-link-danger"
|
||||
data-i18n="submissions.draft.action.delete">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
|
||||
<div className="submission-draft-variables" id="submission-draft-variables" />
|
||||
</aside>
|
||||
|
||||
{/* Preview pane — read-only HTML render of the merged
|
||||
document body. Re-renders on autosave round-trip. */}
|
||||
<section className="submission-draft-preview-wrap">
|
||||
<header className="submission-draft-preview-header">
|
||||
<h2 data-i18n="submissions.draft.preview.title">Vorschau</h2>
|
||||
<span
|
||||
className="submission-draft-preview-hint"
|
||||
data-i18n="submissions.draft.preview.hint">
|
||||
Read-only Vorschau — finale Bearbeitung in Word.
|
||||
</span>
|
||||
</header>
|
||||
<div className="submission-draft-preview" id="submission-draft-preview" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
<script src="/assets/submission-draft.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
84
frontend/src/submissions-index.tsx
Normal file
84
frontend/src/submissions-index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-240 — global Schriftsätze drafts index. Top-level sidebar
|
||||
// entry that lists every draft the caller owns across visible projects.
|
||||
// Per-project editor stays at /projects/{id}/submissions/{code}/draft —
|
||||
// this page only adds a discovery surface and click-through to it.
|
||||
|
||||
export function renderSubmissionsIndex(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.index.title">Schriftsätze — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="submissions-index-headline">
|
||||
<div>
|
||||
<h1 data-i18n="submissions.index.heading">Schriftsätze</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.index.subtitle">
|
||||
Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.action.new">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-index-loading"
|
||||
data-i18n="submissions.index.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-empty" style="display:none">
|
||||
<p data-i18n="submissions.index.empty">
|
||||
Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.
|
||||
</p>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.empty.cta">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-error" style="display:none">
|
||||
<p data-i18n="submissions.index.error">Schriftsätze konnten nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-index-tablewrap" style="display:none">
|
||||
<table className="entity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.index.col.project">Projekt</th>
|
||||
<th data-i18n="submissions.index.col.submission">Schriftsatz</th>
|
||||
<th data-i18n="submissions.index.col.draft">Entwurf</th>
|
||||
<th data-i18n="submissions.index.col.updated">Zuletzt geändert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-index-body" />
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
118
frontend/src/submissions-new.tsx
Normal file
118
frontend/src/submissions-new.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-243 — global Schriftsatz picker. Lists the full
|
||||
// cross-proceeding submission catalog (grouped by proceeding,
|
||||
// filterable) and lets the lawyer start a draft with or without
|
||||
// binding a project. Picking "Ohne Projekt" jumps straight to
|
||||
// /submissions/draft/{id}; picking "Mit Projekt verknüpfen" opens an
|
||||
// autocomplete project picker, then redirects to the project-scoped
|
||||
// editor.
|
||||
|
||||
export function renderSubmissionsNew(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.new.title">Neuer Schriftsatz — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submissions-new-page">
|
||||
<div className="container">
|
||||
<a href="/submissions" className="back-link"
|
||||
data-i18n="submissions.new.back">← Zurück zur Übersicht</a>
|
||||
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="submissions.new.heading">Neuer Schriftsatz</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.new.subtitle">
|
||||
Wählen Sie eine Vorlage. Optional verknüpfen Sie den
|
||||
Entwurf mit einem Projekt — sonst füllen Sie alle
|
||||
Variablen manuell.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="submissions-new-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.search.placeholder"
|
||||
placeholder="Suche nach Schriftsatz, Code oder Norm…" />
|
||||
<div id="submissions-new-proceeding-chips" className="submissions-new-chips" />
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-new-loading"
|
||||
data-i18n="submissions.new.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-new-error" style="display:none">
|
||||
<p data-i18n="submissions.new.error">Katalog konnte nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-new-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.new.col.name">Schriftsatz</th>
|
||||
<th data-i18n="submissions.new.col.party">Partei</th>
|
||||
<th data-i18n="submissions.new.col.source">Rechtsgrundlage</th>
|
||||
<th data-i18n="submissions.new.col.actions">Entwurf starten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-new-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="entity-empty" id="submissions-new-empty" style="display:none">
|
||||
<span data-i18n="submissions.new.empty.filtered">
|
||||
Keine passenden Schriftsätze. Filter zurücksetzen.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Project picker modal — opened by "Mit Projekt verknüpfen". */}
|
||||
<div id="submissions-new-project-modal" className="modal-overlay" style="display:none" role="dialog" aria-modal="true">
|
||||
<div className="modal-card">
|
||||
<header className="modal-header">
|
||||
<h2 data-i18n="submissions.new.picker.title">Projekt wählen</h2>
|
||||
<button type="button" id="submissions-new-project-modal-close"
|
||||
className="modal-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-project-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.picker.placeholder"
|
||||
placeholder="Projekt suchen (Titel oder Aktenzeichen)…" />
|
||||
<ul id="submissions-new-project-list" className="submissions-new-project-list" />
|
||||
<p id="submissions-new-project-loading" className="entity-events-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.loading">Lädt Projekte…</p>
|
||||
<p id="submissions-new-project-empty" className="entity-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.empty">Keine sichtbaren Projekte.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -210,6 +210,53 @@ export function renderVerfahrensablauf(): string {
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Perspective strip (t-paliad-250 / m/paliad#81). Side
|
||||
swaps the column LABELS so the user's own side is
|
||||
proactive (= "your filings"). Appellant collapses
|
||||
party=both rows to a single column when set — only
|
||||
relevant for role-swap proceedings (Appeal etc.);
|
||||
the row hides itself when the picked proceeding has
|
||||
no appellant axis (see hasAppellantAxis() in the
|
||||
client). Both selectors are URL-driven (?side= +
|
||||
?appellant=) so the perspective survives reload
|
||||
and is shareable. */}
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.both">Beide</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
|
||||
7
internal/db/migrations/119_submission_drafts.down.sql
Normal file
7
internal/db/migrations/119_submission_drafts.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- t-paliad-238: revert submission_drafts table.
|
||||
--
|
||||
-- The shared paliad.tg_set_updated_at trigger function is intentionally
|
||||
-- left in place — it may be in use by other tables. DROP TABLE cascades
|
||||
-- to the trigger that references it on this table only.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_drafts;
|
||||
88
internal/db/migrations/119_submission_drafts.up.sql
Normal file
88
internal/db/migrations/119_submission_drafts.up.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- t-paliad-238: dedicated Submissions/Schriftsätze page.
|
||||
--
|
||||
-- paliad.submission_drafts holds the lawyer's per-(project, submission_code)
|
||||
-- draft state for the new editor at /projects/{id}/submissions/{code}/draft.
|
||||
-- Each row is one named draft owned by one user; multiple drafts per
|
||||
-- (project, submission_code, user_id) are supported via the `name` field
|
||||
-- (auto-generated "Entwurf 1", "Entwurf 2", … and lawyer-renameable).
|
||||
--
|
||||
-- `variables` carries the lawyer's overrides for the placeholder map
|
||||
-- assembled at export time by SubmissionVarsService — empty string forces
|
||||
-- the [KEIN WERT: …] marker; absent key falls back to the resolved bag.
|
||||
--
|
||||
-- `last_exported_at` / `last_exported_sha` record provenance of the most
|
||||
-- recent .docx export (template SHA pinned for audit). Audit rows live
|
||||
-- in paliad.system_audit_log + paliad.project_events; this table is the
|
||||
-- lawyer's working state, not the audit log.
|
||||
--
|
||||
-- Visibility: per project CLAUDE.md, every project-scoped resource gates
|
||||
-- on paliad.can_see_project. UPDATE / DELETE additionally require the
|
||||
-- draft's user_id to match auth.uid() so two co-team-members don't stomp
|
||||
-- on each other's drafts (head-confirmed Q-E4 owner-scoped pick).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
|
||||
variables jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
last_exported_at timestamptz,
|
||||
last_exported_sha text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_drafts_unique_per_user
|
||||
UNIQUE (project_id, submission_code, user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_drafts_project_user_idx
|
||||
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
-- updated_at maintenance: trigger function lives once per schema; reuse if
|
||||
-- it already exists, otherwise create the standard shape.
|
||||
CREATE OR REPLACE FUNCTION paliad.tg_set_updated_at()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_drafts_set_updated_at ON paliad.submission_drafts;
|
||||
CREATE TRIGGER submission_drafts_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_drafts IS
|
||||
't-paliad-238: per-(project, submission_code, user) named drafts for the dedicated Submissions/Schriftsätze page. Each row holds the lawyer-edited variable overrides for the .docx export. Audit rows live in paliad.system_audit_log + paliad.project_events.';
|
||||
@@ -0,0 +1,46 @@
|
||||
-- t-paliad-243 revert: restore NOT NULL on project_id.
|
||||
--
|
||||
-- The revert refuses to run if any project-less draft exists — those
|
||||
-- rows would silently fail the NOT NULL re-imposition and corrupt the
|
||||
-- migration runner's state. The safe revert path is to surface the
|
||||
-- conflict to the operator who can decide whether to attach the rows
|
||||
-- to a project or delete them before retrying the down.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts WHERE project_id IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'cannot re-impose NOT NULL on paliad.submission_drafts.project_id: '
|
||||
'project-less drafts exist. Attach them to a project or delete '
|
||||
'them, then re-run the down migration.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ALTER COLUMN project_id SET NOT NULL;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
@@ -0,0 +1,70 @@
|
||||
-- t-paliad-243: drafts may exist without a project attached.
|
||||
--
|
||||
-- The global /submissions/new picker lets a lawyer start a Schriftsatz
|
||||
-- draft straight from the top-level Schriftsätze sidebar, with or
|
||||
-- without binding it to a project. project_id therefore becomes
|
||||
-- optional. Existing rows are unaffected; new rows may insert NULL.
|
||||
--
|
||||
-- RLS rewrite: every policy splits on (project_id IS NULL):
|
||||
--
|
||||
-- project_id IS NOT NULL → gate on paliad.can_see_project (existing
|
||||
-- inheritance-aware visibility).
|
||||
-- project_id IS NULL → owner-only (user_id = auth.uid()). A
|
||||
-- project-less draft is a personal scratch
|
||||
-- space — never shared, never visible to
|
||||
-- other team members.
|
||||
--
|
||||
-- INSERT enforces the same shape via WITH CHECK: a project-less insert
|
||||
-- only writes user_id = auth.uid(); a project-scoped insert additionally
|
||||
-- requires can_see_project.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ALTER COLUMN project_id DROP NOT NULL;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND user_id = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Drop the optional trigger-event label columns added in
|
||||
-- 121_proceeding_trigger_event_label.up.sql. Any populated rows lose
|
||||
-- their override; the frontend falls back to proceedingName.
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS trigger_event_label_en,
|
||||
DROP COLUMN IF EXISTS trigger_event_label_de;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- t-paliad-250 / m/paliad#81 — Concern B: UPC Appeal trigger-event label.
|
||||
--
|
||||
-- The /tools/verfahrensablauf "Auslösendes Ereignis" caption falls back
|
||||
-- to `paliad.proceeding_types.name` whenever the calculator finds no
|
||||
-- root rule (duration_value=0 + parent_id=NULL + !is_court_set). For
|
||||
-- UPC Appeal (upc.apl.merits) all rules carry a non-zero duration off
|
||||
-- the trigger date, so the caption reads "Berufungsverfahren" /
|
||||
-- "Appeal" — the proceeding itself — instead of the appealable
|
||||
-- decision that actually starts the clock.
|
||||
--
|
||||
-- Fix: add an optional `trigger_event_label_de` / `trigger_event_label_en`
|
||||
-- pair on proceeding_types. When set, the calculator surfaces it on the
|
||||
-- response (TriggerEventLabel{,EN}) and the frontend prefers it over
|
||||
-- proceedingName. No deadline-rule additions, no slug changes; existing
|
||||
-- proceeding_type.code stays stable (hard rule from the issue).
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_label_de text,
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_label_en text;
|
||||
|
||||
-- UPC Appeal: the trigger date is the date of the appealable first-instance
|
||||
-- decision (per UPC RoP R.224(1)(a) the 2-month appeal clock runs from
|
||||
-- service of the decision per R.220.1(a)/(b)).
|
||||
UPDATE paliad.proceeding_types
|
||||
SET trigger_event_label_de = 'Anfechtbare Entscheidung',
|
||||
trigger_event_label_en = 'Appealable Decision'
|
||||
WHERE code = 'upc.apl.merits';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- t-paliad-258: revert the additive custom_rule_text column.
|
||||
-- Drop the column; rows that used the Custom path lose their free-text
|
||||
-- label and read as "no rule".
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS custom_rule_text;
|
||||
26
internal/db/migrations/122_deadlines_custom_rule_text.up.sql
Normal file
26
internal/db/migrations/122_deadlines_custom_rule_text.up.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- t-paliad-258 / m/paliad#89 — binary Auto/Custom Rule model on the
|
||||
-- deadline form.
|
||||
--
|
||||
-- t-paliad-251 shipped the form with a full deadline_rules catalog
|
||||
-- dropdown. m's verdict: too noisy (4 "Oral hearings" across UPC CFI,
|
||||
-- UPC CoA, DPMA, EPO etc.). Replace with a binary model:
|
||||
--
|
||||
-- 1. Auto — rule_id derived from the chosen event_type, displayed
|
||||
-- read-only.
|
||||
-- 2. Custom — rule_id is NULL and the lawyer's free-text label is
|
||||
-- stored here.
|
||||
--
|
||||
-- The column is additive + nullable: existing rows keep their
|
||||
-- deadline_rule_id and read as Auto-equivalent. A future row with both
|
||||
-- columns NULL renders as "keine Regel" (matches today's no-rule state).
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN IF NOT EXISTS custom_rule_text text;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadlines.custom_rule_text IS
|
||||
'Free-text rule label entered when the lawyer chose Custom on the '
|
||||
'deadline form (t-paliad-258). Mutually exclusive with rule_id at '
|
||||
'the application layer: Auto path sets rule_id and leaves this '
|
||||
'NULL; Custom path sets this and leaves rule_id NULL. Display '
|
||||
'surfaces prefer the rule_id-joined deadline_rules.name when '
|
||||
'present, else fall back to custom_rule_text + a "Custom" badge.';
|
||||
11
internal/db/migrations/123_backups.down.sql
Normal file
11
internal/db/migrations/123_backups.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- t-paliad-246 / m/paliad#77 — revert Backup Mode catalog table.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 123 down: drop paliad.backups catalog (t-paliad-246 / m/paliad#77 Slice A)',
|
||||
true);
|
||||
|
||||
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
|
||||
DROP INDEX IF EXISTS paliad.backups_kind_status_idx;
|
||||
DROP INDEX IF EXISTS paliad.backups_started_at_desc_idx;
|
||||
DROP TABLE IF EXISTS paliad.backups;
|
||||
86
internal/db/migrations/123_backups.up.sql
Normal file
86
internal/db/migrations/123_backups.up.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- t-paliad-246 / m/paliad#77 — Backup Mode catalog table.
|
||||
--
|
||||
-- Design: docs/design-backup-mode-2026-05-25.md §4. One row per backup
|
||||
-- run (on-demand or scheduled). The catalog is operational metadata for
|
||||
-- the /admin/backups UI (size, row counts, storage URI, status). The
|
||||
-- audit chain stays on paliad.system_audit_log — this table is the
|
||||
-- richer-shape duplicate that the UI lists from without parsing JSON.
|
||||
--
|
||||
-- INSERT/UPDATE happen only through the Go service path (BackupRunner)
|
||||
-- under the migration-runner role, so we don't add a write RLS policy
|
||||
-- for end users. SELECT is admin-only, mirroring system_audit_log.
|
||||
--
|
||||
-- Idempotent: CREATE TABLE / INDEX / POLICY all guarded.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 123: add paliad.backups catalog for Backup Mode (t-paliad-246 / m/paliad#77 Slice A)',
|
||||
true);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.backups (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kind text NOT NULL CHECK (kind IN ('scheduled', 'on_demand')),
|
||||
status text NOT NULL CHECK (status IN ('running', 'done', 'failed')),
|
||||
-- requested_by is NULL for kind='scheduled' (no human caller).
|
||||
requested_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
-- requested_by_email is captured at write time so the row survives
|
||||
-- a subsequent user deletion. For scheduled runs we write a sentinel
|
||||
-- like 'system@paliad' (no real user attached).
|
||||
requested_by_email text NOT NULL,
|
||||
-- audit_id back-references the system_audit_log row written before
|
||||
-- the artifact is generated. Nullable so a catalog row can still be
|
||||
-- INSERTed if the audit write itself fails (defense-in-depth).
|
||||
audit_id uuid REFERENCES paliad.system_audit_log(id) ON DELETE SET NULL,
|
||||
-- storage_uri is populated when status flips to 'done'. Resolves
|
||||
-- through the Go-side ArtifactStore interface ('file://...' for
|
||||
-- LocalDiskStore today; future stores get their own URI scheme).
|
||||
storage_uri text,
|
||||
size_bytes bigint,
|
||||
row_counts jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
sheet_count int,
|
||||
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
-- error is NULL unless status='failed'. Free-form, captured from
|
||||
-- the Go-side error.Error().
|
||||
error text,
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
-- deleted_at marks artifacts the lifecycle cleanup removed from
|
||||
-- storage (Slice B). The catalog row itself stays forever — it's
|
||||
-- part of the audit chain. NULL means "still on disk".
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
-- Read patterns:
|
||||
-- - "show me recent backups" — started_at DESC
|
||||
-- - "find last successful scheduled backup today" — kind + status + started_at
|
||||
CREATE INDEX IF NOT EXISTS backups_started_at_desc_idx
|
||||
ON paliad.backups (started_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS backups_kind_status_idx
|
||||
ON paliad.backups (kind, status);
|
||||
|
||||
ALTER TABLE paliad.backups ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Admin-only read. INSERT/UPDATE/DELETE happen via the Go service path
|
||||
-- under the migration-runner role (no end-user write surface).
|
||||
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
|
||||
CREATE POLICY backups_select_admin ON paliad.backups
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.backups IS
|
||||
'Catalog of org-scope backup runs (t-paliad-246 / m/paliad#77). One row per scheduled or on-demand backup. status transitions: running → done | failed. storage_uri is resolved by the Go-side ArtifactStore interface. audit_id links to system_audit_log; the catalog row is the richer-shape duplicate, the audit row is the trust signal.';
|
||||
|
||||
COMMENT ON COLUMN paliad.backups.requested_by_email IS
|
||||
'Captured at write time so the row survives user deletion. Sentinel ''system@paliad'' for scheduled runs.';
|
||||
|
||||
COMMENT ON COLUMN paliad.backups.storage_uri IS
|
||||
'Resolved by the Go-side ArtifactStore implementation. file://... for LocalDiskStore; future stores use their own URI scheme.';
|
||||
|
||||
COMMENT ON COLUMN paliad.backups.deleted_at IS
|
||||
'Set when the artifact is removed from storage by lifecycle cleanup. Catalog row stays forever (audit chain). NULL means artifact is still on disk.';
|
||||
@@ -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;
|
||||
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.';
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -221,6 +222,16 @@ func handleListInboxMine(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// GET /api/inbox/count — bell badge count for the sidebar.
|
||||
//
|
||||
// Since t-paliad-249 (Slice A) the count is the **unified** unread
|
||||
// count: pending approval requests (regardless of cursor) +
|
||||
// curated project_events (InboxProjectEventKinds) on visible
|
||||
// projects whose created_at is newer than users.inbox_seen_at. See
|
||||
// ApprovalService.UnseenInboxCountForUser for the contract.
|
||||
//
|
||||
// The legacy approval-only count is still reachable via
|
||||
// PendingCountForUser inside the dashboard widget — that path
|
||||
// doesn't go through this endpoint.
|
||||
func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -229,7 +240,7 @@ func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := dbSvc.approval.PendingCountForUser(r.Context(), uid)
|
||||
n, err := dbSvc.approval.UnseenInboxCountForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -237,6 +248,57 @@ func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]int{"count": n})
|
||||
}
|
||||
|
||||
// POST /api/inbox/mark-all-seen — advance the caller's inbox read
|
||||
// cursor (paliad.users.inbox_seen_at). Optional body
|
||||
// `{"up_to": "<iso8601>"}` pins the advance to the timestamp of the
|
||||
// newest row the client actually saw — handy when a second tab made
|
||||
// the inbox grow between the read and the click. Missing body =>
|
||||
// advance to now().
|
||||
//
|
||||
// Returns the new cursor as `{"inbox_seen_at": "<iso8601>"}` so the
|
||||
// client can keep its local state in sync.
|
||||
func handleInboxMarkAllSeen(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
UpTo string `json:"up_to"`
|
||||
}
|
||||
if r.Body != nil && r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
}
|
||||
var upTo time.Time
|
||||
if body.UpTo != "" {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, body.UpTo)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "up_to must be RFC3339"})
|
||||
return
|
||||
}
|
||||
upTo = parsed
|
||||
}
|
||||
if err := dbSvc.approval.MarkInboxSeen(r.Context(), uid, upTo); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
cur, err := dbSvc.approval.InboxSeenAt(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
resp := map[string]any{}
|
||||
if cur != nil {
|
||||
resp["inbox_seen_at"] = cur.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// parseInboxFilter pulls common filter knobs off the query string.
|
||||
//
|
||||
// Status / EntityType pass through validation: an unrecognised value is
|
||||
@@ -326,6 +388,56 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
handleApprovalDecision(w, r, "revoke")
|
||||
}
|
||||
|
||||
// POST /api/approval-requests/{id}/edit-entity — t-paliad-252 / m/paliad#83.
|
||||
//
|
||||
// Lets the requester revise the in-flight entity (e.g. tweak the title on a
|
||||
// pending create) without withdrawing the request. The non-destructive
|
||||
// sibling of /revoke that m asked for after noticing that withdraw silently
|
||||
// deletes the underlying event.
|
||||
//
|
||||
// Body: {"fields": {<entity-shape>}}
|
||||
// 200: {"status": "ok"}
|
||||
//
|
||||
// Status mapping (mapApprovalError):
|
||||
//
|
||||
// 400 suggestion_requires_change — payload has no allowlisted fields
|
||||
// 403 not_authorized — caller isn't the requested_by
|
||||
// 404 — request not found / not visible
|
||||
// 409 request_not_pending — request already decided / revoked
|
||||
type editPendingEntityBody struct {
|
||||
Fields map[string]any `json:"fields"`
|
||||
}
|
||||
|
||||
func handleEditPendingEntity(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requestID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
||||
return
|
||||
}
|
||||
var body editPendingEntityBody
|
||||
if r.Body != nil && r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"code": "invalid_body",
|
||||
"message": "Ungültiger Body.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := dbSvc.approval.EditPendingEntity(r.Context(), requestID, uid, body.Fields); err != nil {
|
||||
writeApprovalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
|
||||
// counter_payload is an entity-shaped jsonb of the approver's edited
|
||||
// values (allowlist enforced server-side); note is the optional free-text
|
||||
|
||||
247
internal/handlers/backups.go
Normal file
247
internal/handlers/backups.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package handlers
|
||||
|
||||
// Admin Backup Mode handlers (t-paliad-246 / m/paliad#77 Slice A).
|
||||
//
|
||||
// POST /api/admin/backups/run — kick off an on-demand backup
|
||||
// GET /api/admin/backups — chronological list
|
||||
// GET /api/admin/backups/{id} — single catalog row
|
||||
// GET /api/admin/backups/{id}/file — stream the artifact (records
|
||||
// a backup_downloaded audit row)
|
||||
// GET /admin/backups — admin page (SPA shell)
|
||||
//
|
||||
// Authorisation: every route registers behind adminGate(users, …) in
|
||||
// handlers.go, so every handler in this file can assume the caller is a
|
||||
// global_admin and only validate the request shape.
|
||||
//
|
||||
// The runner is wired in cmd/server/main.go only when PALIAD_EXPORT_DIR
|
||||
// is set. When unset, every handler returns 503 — same shape as
|
||||
// requireDB.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// backupRequestTimeout caps a single on-demand backup. At firm-scale
|
||||
// data shapes (today: ~600 user-content rows + ~1000 reference rows)
|
||||
// a backup runs sub-second; the watchdog surfaces "stuck" as a 500
|
||||
// instead of letting the client hang forever.
|
||||
const backupRequestTimeout = 5 * time.Minute
|
||||
|
||||
// requireBackup writes a 503 if the BackupRunner is not wired (typically
|
||||
// PALIAD_EXPORT_DIR is unset) and returns false. Mirrors requireDB.
|
||||
func requireBackup(w http.ResponseWriter) bool {
|
||||
if dbSvc == nil || dbSvc.backup == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "backup service not configured — set PALIAD_EXPORT_DIR on the server",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleAdminBackupsPage renders the /admin/backups SPA shell. The
|
||||
// catalog rows are fetched client-side via /api/admin/backups.
|
||||
func handleAdminBackupsPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-backups.html")
|
||||
}
|
||||
|
||||
// handleAdminRunBackup kicks off a synchronous on-demand backup and
|
||||
// returns the resulting BackupSummary as JSON. Synchronous: at firm-
|
||||
// scale the whole run is under 5s; an async path with polling is Slice
|
||||
// B (the scheduler reuses the same runner internally).
|
||||
//
|
||||
// Returns 201 on success with the catalog row, 500 on failure (the
|
||||
// catalog/audit rows are still flipped to failed/backup_failed before
|
||||
// the response).
|
||||
func handleAdminRunBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), backupRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("backup: user lookup failed for %s: %v", uid, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "user lookup failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
actor := services.BackupActor{
|
||||
ID: &uid,
|
||||
Email: user.Email,
|
||||
Label: user.DisplayName,
|
||||
}
|
||||
result, err := dbSvc.backup.Run(ctx, services.BackupKindOnDemand, actor)
|
||||
if err != nil {
|
||||
log.Printf("backup: Run failed for admin=%s: %v", uid, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "backup generation failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the freshly-written catalog row so the UI doesn't need a
|
||||
// follow-up GET to render the new line item.
|
||||
row, err := dbSvc.backup.GetBackup(ctx, result.ID)
|
||||
if err != nil {
|
||||
// The backup did succeed — log + return the bare result.
|
||||
log.Printf("backup: post-run GetBackup failed for %s: %v", result.ID, err)
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
}
|
||||
|
||||
// handleAdminListBackups returns the most recent N catalog rows as
|
||||
// JSON. ?limit=N caps the page (default 100).
|
||||
func handleAdminListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
limit := 100
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("limit")); q != "" {
|
||||
if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 500 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
rows, err := dbSvc.backup.ListBackups(r.Context(), limit)
|
||||
if err != nil {
|
||||
log.Printf("backup: list failed: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "list failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []services.BackupSummary{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// handleAdminGetBackup returns one catalog row. Used by the UI for
|
||||
// "is the backup I just kicked off done yet?" polling — though at the
|
||||
// synchronous shape today this rarely matters.
|
||||
func handleAdminGetBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.backup.GetBackup(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("backup: get failed for %s: %v", id, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// handleAdminDownloadBackup streams the artifact bytes through the
|
||||
// ArtifactStore (LocalDiskStore for v1). Records a backup_downloaded
|
||||
// audit row before flushing.
|
||||
//
|
||||
// 404 if the catalog row is missing; 410 (Gone) if the artifact was
|
||||
// already lifecycle-deleted; 409 if status is not 'done'; 500 on any
|
||||
// store/IO error.
|
||||
func handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
row, err := dbSvc.backup.GetBackup(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("backup: download GetBackup failed for %s: %v", id, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
|
||||
return
|
||||
}
|
||||
if row.Status != services.BackupStatusDone || row.StorageURI == nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "backup not available for download",
|
||||
"status": row.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
if row.DeletedAt != nil {
|
||||
// 410 Gone — the artifact is past its retention window. Catalog
|
||||
// row stays as the audit trail; clients should not retry.
|
||||
writeJSON(w, http.StatusGone, map[string]string{
|
||||
"error": "artifact has been removed (retention)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rc, size, err := dbSvc.backup.Store().Get(r.Context(), *row.StorageURI)
|
||||
if err != nil {
|
||||
log.Printf("backup: download store.Get failed for %s: %v", id, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store read failed"})
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// Record the download audit row before flushing. If the audit
|
||||
// write fails we still serve the file (the user can see it; the
|
||||
// chain just missed a row — surface in logs).
|
||||
user, uErr := dbSvc.users.GetByID(r.Context(), uid)
|
||||
if uErr == nil && user != nil {
|
||||
auditErr := dbSvc.backup.RecordDownload(r.Context(), id, services.BackupActor{
|
||||
ID: &uid,
|
||||
Email: user.Email,
|
||||
Label: user.DisplayName,
|
||||
})
|
||||
if auditErr != nil {
|
||||
log.Printf("backup: RecordDownload failed for %s by %s: %v", id, uid, auditErr)
|
||||
}
|
||||
} else if uErr != nil {
|
||||
log.Printf("backup: user lookup for audit failed (%s): %v", uid, uErr)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("paliad-backup-%s.zip", row.StartedAt.UTC().Format("20060102T1504Z"))
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.Header().Set("X-Paliad-Backup-Id", id.String())
|
||||
if _, err := io.Copy(w, rc); err != nil {
|
||||
log.Printf("backup: response write failed for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ type fileEntry struct {
|
||||
//
|
||||
// The URL slug ("hl-patents-style.dotm") is preserved as a stable public
|
||||
// identifier so existing bookmarks keep working post-rebrand.
|
||||
//
|
||||
// Per-submission templates (slug `submission/<code>.docx`) are server-only:
|
||||
// only the submission-draft editor reaches them via fetchSubmissionTemplateBytes.
|
||||
// handleFileDownload serves any slug that lands here, but the public URL
|
||||
// surface for submission templates is the export endpoint, not /files.
|
||||
var fileRegistry = map[string]fileEntry{
|
||||
"hl-patents-style.dotm": {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/HL%20Patents%20Style.dotm",
|
||||
@@ -46,6 +51,92 @@ var fileRegistry = map[string]fileEntry{
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/HL Patents Style.dotm",
|
||||
},
|
||||
// Per-submission demo template (t-paliad-241). Exercises every
|
||||
// placeholder SubmissionVarsService resolves so the
|
||||
// /projects/{id}/submissions/{code}/draft editor has variables to
|
||||
// substitute. One file per submission_code; future codes register
|
||||
// the same way — slug shape "submission/<code>.docx" so the
|
||||
// namespace stays separate from the universal style template.
|
||||
"submission/de.inf.lg.erwidg.docx": {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
DownloadName: "Klageerwiderung — " + branding.Name + ".docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
},
|
||||
// Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter
|
||||
// that carries every placeholder SubmissionVarsService resolves but no
|
||||
// submission_code-specific body structure. Slot between the per-firm
|
||||
// per-code template and the bare HL Patents Style .dotm fallback: every
|
||||
// submission_code without a dedicated template still renders with
|
||||
// variables substituted instead of the macro-only letterhead.
|
||||
skeletonSubmissionSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
DownloadName: branding.Name + " — Schriftsatz-Skelett.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
},
|
||||
}
|
||||
|
||||
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||
// the shared fileRegistry cache. Exported via a const so handler code
|
||||
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
||||
// the same string the registry uses.
|
||||
const skeletonSubmissionSlug = "submission/_skeleton.docx"
|
||||
|
||||
// submissionTemplateRegistry maps a deadline-rule submission_code to a
|
||||
// fileRegistry slug. Lookup order matches the cronus design fallback
|
||||
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
|
||||
// universal HL Patents Style as the global fallback.
|
||||
//
|
||||
// Add new entries here as the firm authors per-submission templates;
|
||||
// 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.
|
||||
var submissionTemplateRegistry = map[string]string{
|
||||
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
|
||||
}
|
||||
|
||||
// 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.
|
||||
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
|
||||
slug, ok := submissionTemplateRegistry[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 {
|
||||
return nil, "", false, err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", false, fmt.Errorf("file proxy: %s cache empty after fetch", slug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, true, nil
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
@@ -118,6 +209,46 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
|
||||
}
|
||||
|
||||
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
|
||||
// template bytes plus its provenance SHA. Sits between the per-firm
|
||||
// per-submission_code template (fetchSubmissionTemplateBytes) and the
|
||||
// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in
|
||||
// resolveSubmissionTemplate's fallback chain — used for every
|
||||
// submission_code that has no dedicated template registered. Same
|
||||
// stale-while-revalidate semantics as the rest of the file proxy: first
|
||||
// call warms the cache synchronously from mWorkRepo via Gitea; later
|
||||
// calls return immediately while a background refresh runs.
|
||||
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
entry, ok := fileRegistry[skeletonSubmissionSlug]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
|
||||
}
|
||||
ce := getCacheEntry(skeletonSubmissionSlug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, nil
|
||||
}
|
||||
|
||||
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
|
||||
// bytes. Shared accessor used by both the /files/{slug} download path
|
||||
// (Word auto-update channel) and the submission generator
|
||||
|
||||
@@ -98,6 +98,14 @@ type Services struct {
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
// t-paliad-246 — Backup Mode (org-scope admin backups). Nil when
|
||||
// DATABASE_URL or PALIAD_EXPORT_DIR is unset; the /admin/backups
|
||||
// routes return 503 in that case.
|
||||
Backup *services.BackupRunner
|
||||
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||
SubmissionDraft *services.SubmissionDraftService
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
// (remote → mRiver via SSH) or local tmux availability. Stays nil
|
||||
@@ -159,6 +167,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
firmDashboardDefault: svc.FirmDashboardDefault,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
backup: svc.Backup,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +282,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/checklist-instances/{id}/reset", handleResetChecklistInstance)
|
||||
protected.HandleFunc("DELETE /api/checklist-instances/{id}", handleDeleteChecklistInstance)
|
||||
protected.HandleFunc("GET /api/projects/{id}/checklists", handleListChecklistInstancesForProject)
|
||||
// t-paliad-240 — global Schriftsätze drafts index (top-level sidebar
|
||||
// entry). Lists every draft the caller owns across visible projects.
|
||||
// The per-project Schriftsätze tab keeps the editor itself project-
|
||||
// scoped; this index is the cross-project landing.
|
||||
protected.HandleFunc("GET /submissions", gateOnboarded(handleSubmissionsIndexPage))
|
||||
protected.HandleFunc("GET /courts", handleCourtsPage)
|
||||
protected.HandleFunc("GET /api/courts", handleCourtsAPI)
|
||||
protected.HandleFunc("POST /api/courts/feedback", handleCourtsFeedback)
|
||||
@@ -313,6 +328,33 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// writes an audit row.
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions", handleListProjectSubmissions)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze draft editor.
|
||||
// Per (project, submission_code, user) named drafts with autosaved
|
||||
// variable overrides; export merges the bag with the universal HL
|
||||
// Patents Style template (Slice A) or the per-code template
|
||||
// (Slice B, future).
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts", handleListSubmissionDrafts)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts", handleCreateSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleGetSubmissionDraft)
|
||||
protected.HandleFunc("PATCH /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handlePatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleDeleteSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}/preview", handlePreviewSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts/{draft_id}/export", handleExportSubmissionDraft)
|
||||
// t-paliad-240 — global drafts index (across visible projects).
|
||||
protected.HandleFunc("GET /api/user/submission-drafts", handleListUserSubmissionDrafts)
|
||||
// t-paliad-243 — global Schriftsätze drafts with optional project
|
||||
// binding. The picker page at /submissions/new lists the full
|
||||
// cross-proceeding catalog (without a project context) and posts to
|
||||
// POST /api/submission-drafts to spawn a draft. The
|
||||
// /api/submission-drafts/{draft_id}* endpoints back the project-less
|
||||
// editor and ALSO accept project-scoped drafts (the draft row
|
||||
// carries its own project_id so the project segment is redundant).
|
||||
protected.HandleFunc("GET /api/submissions/catalog", handleListSubmissionCatalog)
|
||||
protected.HandleFunc("POST /api/submission-drafts", handleCreateGlobalSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/submission-drafts/{draft_id}", handleGetGlobalSubmissionDraft)
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
|
||||
// /counterclaim creates a CCR sub-project linked via the new
|
||||
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
|
||||
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
|
||||
@@ -470,6 +512,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// Without this route the deep-link 404s; the tab still works via
|
||||
// in-page click since it just toggles a panel.
|
||||
protected.HandleFunc("GET /projects/{id}/submissions", gateOnboarded(handleProjectsDetailPage))
|
||||
// t-paliad-238 — dedicated submission draft editor. Both routes
|
||||
// render the project detail page; the actual editor mounts
|
||||
// client-side based on the URL path.
|
||||
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft", gateOnboarded(handleSubmissionDraftPage))
|
||||
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft/{draft_id}", gateOnboarded(handleSubmissionDraftPage))
|
||||
// t-paliad-243 — global Schriftsätze pages: picker + project-less
|
||||
// editor. Both render dist/* files; client bundles parse the URL
|
||||
// and branch on whether a project segment is present.
|
||||
protected.HandleFunc("GET /submissions/new", gateOnboarded(handleSubmissionsNewPage))
|
||||
protected.HandleFunc("GET /submissions/draft/{draft_id}", gateOnboarded(handleSubmissionDraftGlobalPage))
|
||||
// t-paliad-177 — standalone Project Timeline / Chart page (Slice 1).
|
||||
// Horizontal SVG renderer mounted client-side; reuses the existing
|
||||
// /api/projects/{id}/timeline JSON endpoint for data.
|
||||
@@ -524,6 +576,17 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))
|
||||
protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEditPage)))
|
||||
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
|
||||
|
||||
// t-paliad-246 / m/paliad#77 Slice A — Backup Mode admin page +
|
||||
// API. Routes only register when Users is wired (matches the
|
||||
// other admin routes); per-request 503 if BackupRunner itself
|
||||
// is unwired (PALIAD_EXPORT_DIR unset).
|
||||
protected.HandleFunc("GET /admin/backups", adminGate(users, gateOnboarded(handleAdminBackupsPage)))
|
||||
protected.HandleFunc("POST /api/admin/backups/run", adminGate(users, handleAdminRunBackup))
|
||||
protected.HandleFunc("GET /api/admin/backups", adminGate(users, handleAdminListBackups))
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
|
||||
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
||||
@@ -608,10 +671,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/inbox/pending-mine", handleListInboxPendingMine)
|
||||
protected.HandleFunc("GET /api/inbox/mine", handleListInboxMine)
|
||||
protected.HandleFunc("GET /api/inbox/count", handleInboxCount)
|
||||
protected.HandleFunc("POST /api/inbox/mark-all-seen", handleInboxMarkAllSeen)
|
||||
protected.HandleFunc("GET /api/approval-requests/{id}", handleGetApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
|
||||
// t-paliad-252 — non-destructive sibling of /revoke: lets the
|
||||
// requester revise the in-flight entity without withdrawing.
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/edit-entity", handleEditPendingEntity)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
|
||||
|
||||
// t-paliad-154 — form-time effective policy lookup. Reachable by
|
||||
|
||||
@@ -61,6 +61,13 @@ type dbServices struct {
|
||||
firmDashboardDefault *services.FirmDashboardDefaultService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
|
||||
// t-paliad-246 — Backup Mode orchestrator. Nil when DATABASE_URL or
|
||||
// PALIAD_EXPORT_DIR is unset (the /admin/backups routes return 503).
|
||||
backup *services.BackupRunner
|
||||
|
||||
// t-paliad-238 — submission draft editor.
|
||||
submissionDraft *services.SubmissionDraftService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
1102
internal/handlers/submission_drafts.go
Normal file
1102
internal/handlers/submission_drafts.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,32 @@
|
||||
package handlers
|
||||
|
||||
// Submission generator HTTP layer (t-paliad-230 — format-only scope
|
||||
// reduction of t-paliad-215).
|
||||
// reduction of t-paliad-215; t-paliad-242 broadened the list endpoint
|
||||
// to the full cross-proceeding catalog; t-paliad-253 promoted /generate
|
||||
// from format-only to the same merge engine the draft editor uses).
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// GET /api/projects/{id}/submissions
|
||||
// Lists the project's proceeding-relevant filing rules.
|
||||
// has_template is unconditionally true: every project gets
|
||||
// offered the universal HL Patents Style template.
|
||||
// Lists every published filing rule across every active
|
||||
// proceeding the platform knows about, joined with its
|
||||
// proceeding_type so the frontend can group by proceeding.
|
||||
// has_template flips per-row: true when a per-submission .docx
|
||||
// is wired in submissionTemplateRegistry, false when the
|
||||
// editor falls back to the universal HL Patents Style.
|
||||
//
|
||||
// POST /api/projects/{id}/submissions/{code}/generate
|
||||
// Fetches the cached HL Patents Style .dotm (same proxy used
|
||||
// by /files/hl-patents-style.dotm), converts it to a clean
|
||||
// .docx via services.ConvertDotmToDocx, writes one
|
||||
// paliad.system_audit_log row, and streams the result as an
|
||||
// attachment download.
|
||||
//
|
||||
// No variable substitution, no per-submission templates, no
|
||||
// project_events/documents writes. Those layers are deferred to a
|
||||
// future "merge engine" slice; today's generator hands the lawyer a
|
||||
// clean .docx of the firm style and lets them edit and save under
|
||||
// their own filename.
|
||||
// Resolves the template through the cronus fallback chain
|
||||
// (per-firm `submissionTemplateRegistry[code]` first, HL
|
||||
// Patents Style as the universal fallback), builds a fresh
|
||||
// variable bag via SubmissionVarsService.Build, and runs the
|
||||
// SubmissionRenderer merge so every {{placeholder}} resolves
|
||||
// to project state (or `[KEIN WERT: key]` for empties). Writes
|
||||
// one paliad.system_audit_log row and streams the .docx as an
|
||||
// attachment download. The HL Patents Style fallback has no
|
||||
// placeholders today, so for codes without a per-firm template
|
||||
// the renderer is a no-op on substitution but still runs the
|
||||
// .dotm→.docx pre-pass.
|
||||
//
|
||||
// Visibility: every endpoint runs through ProjectService.GetByID
|
||||
// (paliad.can_see_project gate). Unauthorised callers get 404 — same
|
||||
@@ -62,26 +67,48 @@ const hlPatentsStyleSlug = "hl-patents-style.dotm"
|
||||
|
||||
// submissionListEntry is one row in the Schriftsätze panel.
|
||||
type submissionListEntry struct {
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
ProceedingCode string `json:"proceeding_code"`
|
||||
ProceedingName string `json:"proceeding_name"`
|
||||
ProceedingNameEN string `json:"proceeding_name_en"`
|
||||
}
|
||||
|
||||
// submissionListResponse wraps the list with a project-level header.
|
||||
//
|
||||
// ProjectProceedingCode names the project's own proceeding so the
|
||||
// frontend can pin its group to the top of the grouped catalog
|
||||
// (t-paliad-242). nil when the project hasn't bound a proceeding yet.
|
||||
type submissionListResponse struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
Entries []submissionListEntry `json:"entries"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
ProjectProceedingCode *string `json:"project_proceeding_code,omitempty"`
|
||||
Entries []submissionListEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// handleListProjectSubmissions returns the published filing rules for
|
||||
// the project's proceeding_type. has_template is true for every row —
|
||||
// Slice 1 (t-paliad-230) ships one universal template, so the only
|
||||
// "no template" case is a project that has no proceeding_type bound.
|
||||
// handleListProjectSubmissions returns every published filing rule
|
||||
// across every active proceeding the platform knows about, joined with
|
||||
// its proceeding_type so the Schriftsätze tab can group rows by
|
||||
// proceeding (t-paliad-242 — m wants to see the entire catalog from any
|
||||
// project, not just the rules for the project's own proceeding).
|
||||
//
|
||||
// Visibility is gated on the PROJECT (paliad.can_see_project via
|
||||
// ProjectService.GetByID); the rules themselves are static reference
|
||||
// data shared across the firm.
|
||||
//
|
||||
// has_template flips when a per-submission .docx is wired into
|
||||
// submissionTemplateRegistry (files.go). When false, the universal HL
|
||||
// Patents Style .dotm is the fallback — the editor (t-paliad-238)
|
||||
// resolves both flavours transparently, so every row remains
|
||||
// generatable and editable from the UI.
|
||||
//
|
||||
// Rows are sorted by (proceeding_code, submission_code) so the
|
||||
// frontend's groupBy stays cheap and the order is stable.
|
||||
func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -110,53 +137,145 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
Entries: []submissionListEntry{},
|
||||
}
|
||||
|
||||
if project.ProceedingTypeID == nil {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := dbSvc.rules.List(ctx, project.ProceedingTypeID)
|
||||
entries, ownCode, err := loadSubmissionCatalog(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
log.Printf("submissions: list rules for proceeding %d: %v", *project.ProceedingTypeID, err)
|
||||
log.Printf("submissions: list submission catalog: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule.SubmissionCode == nil || *rule.SubmissionCode == "" {
|
||||
continue
|
||||
}
|
||||
if rule.EventType == nil || *rule.EventType != "filing" {
|
||||
continue
|
||||
}
|
||||
if rule.LifecycleState != "published" {
|
||||
continue
|
||||
}
|
||||
entry := submissionListEntry{
|
||||
SubmissionCode: *rule.SubmissionCode,
|
||||
Name: rule.Name,
|
||||
NameEN: rule.NameEN,
|
||||
HasTemplate: true,
|
||||
}
|
||||
if rule.EventType != nil {
|
||||
entry.EventType = *rule.EventType
|
||||
}
|
||||
if rule.PrimaryParty != nil {
|
||||
entry.PrimaryParty = *rule.PrimaryParty
|
||||
}
|
||||
if rule.LegalSource != nil {
|
||||
entry.LegalSource = *rule.LegalSource
|
||||
}
|
||||
resp.Entries = append(resp.Entries, entry)
|
||||
}
|
||||
resp.Entries = entries
|
||||
resp.ProjectProceedingCode = ownCode
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleGenerateProjectSubmission fetches the universal HL Patents
|
||||
// Style .dotm, converts it to a clean .docx, writes one audit row, and
|
||||
// streams the result. No variable substitution; the bytes that go down
|
||||
// the wire are the firm style template with macros stripped.
|
||||
// handleListSubmissionCatalog returns the same cross-proceeding catalog
|
||||
// without a project context — used by the global /submissions/new
|
||||
// picker (t-paliad-243). No project_proceeding_code is returned since
|
||||
// the picker isn't pinned to one project.
|
||||
func handleListSubmissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
entries, _, err := loadSubmissionCatalog(r.Context(), nil)
|
||||
if err != nil {
|
||||
log.Printf("submissions: list global submission catalog: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
||||
}
|
||||
|
||||
// loadSubmissionCatalog runs the shared catalog query. When
|
||||
// projectProceedingTypeID is non-nil, the returned ownCode points at
|
||||
// that proceeding's code so the frontend can pin its group to the top;
|
||||
// otherwise ownCode is nil.
|
||||
func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([]submissionListEntry, *string, error) {
|
||||
type catalogRow struct {
|
||||
SubmissionCode string `db:"submission_code"`
|
||||
Name string `db:"name"`
|
||||
NameEN string `db:"name_en"`
|
||||
EventType *string `db:"event_type"`
|
||||
PrimaryParty *string `db:"primary_party"`
|
||||
LegalSource *string `db:"legal_source"`
|
||||
ProceedingID int `db:"proceeding_type_id"`
|
||||
ProceedingCode string `db:"proceeding_code"`
|
||||
ProceedingName string `db:"proceeding_name"`
|
||||
ProceedingNameEN string `db:"proceeding_name_en"`
|
||||
}
|
||||
|
||||
var rows []catalogRow
|
||||
err := dbSvc.projects.DB().SelectContext(ctx, &rows,
|
||||
`SELECT dr.submission_code AS submission_code,
|
||||
dr.name AS name,
|
||||
dr.name_en AS name_en,
|
||||
dr.event_type AS event_type,
|
||||
dr.primary_party AS primary_party,
|
||||
dr.legal_source AS legal_source,
|
||||
dr.proceeding_type_id AS proceeding_type_id,
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name,
|
||||
pt.name_en AS proceeding_name_en
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE dr.is_active = true
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.event_type = 'filing'
|
||||
AND dr.submission_code IS NOT NULL
|
||||
AND dr.submission_code <> ''
|
||||
AND pt.is_active = true
|
||||
ORDER BY pt.code ASC, dr.submission_code ASC`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
entries := make([]submissionListEntry, 0, len(rows))
|
||||
var ownCode *string
|
||||
for _, row := range rows {
|
||||
entry := submissionListEntry{
|
||||
SubmissionCode: row.SubmissionCode,
|
||||
Name: row.Name,
|
||||
NameEN: row.NameEN,
|
||||
HasTemplate: hasPerSubmissionTemplate(row.SubmissionCode),
|
||||
ProceedingCode: row.ProceedingCode,
|
||||
ProceedingName: row.ProceedingName,
|
||||
ProceedingNameEN: row.ProceedingNameEN,
|
||||
}
|
||||
if row.EventType != nil {
|
||||
entry.EventType = *row.EventType
|
||||
}
|
||||
if row.PrimaryParty != nil {
|
||||
entry.PrimaryParty = *row.PrimaryParty
|
||||
}
|
||||
if row.LegalSource != nil {
|
||||
entry.LegalSource = *row.LegalSource
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
if projectProceedingTypeID != nil && row.ProceedingID == *projectProceedingTypeID && ownCode == nil {
|
||||
code := row.ProceedingCode
|
||||
ownCode = &code
|
||||
}
|
||||
}
|
||||
|
||||
// If the project's proceeding has no filing rules of its own, fall
|
||||
// back to a direct proceeding_types lookup so the frontend can still
|
||||
// pin the right group even when the catalog ordering wouldn't have
|
||||
// surfaced the code via a row.
|
||||
if projectProceedingTypeID != nil && ownCode == nil {
|
||||
var code string
|
||||
if err := dbSvc.projects.DB().GetContext(ctx, &code,
|
||||
`SELECT code FROM paliad.proceeding_types WHERE id = $1`, *projectProceedingTypeID); err == nil && code != "" {
|
||||
ownCode = &code
|
||||
}
|
||||
}
|
||||
|
||||
return entries, ownCode, nil
|
||||
}
|
||||
|
||||
// hasPerSubmissionTemplate reports whether a per-submission .docx is
|
||||
// wired in the fileRegistry (files.go). false means the editor falls
|
||||
// back to the universal HL Patents Style — still renderable, still
|
||||
// editable, but the UI may want to surface a "universal Vorlage"
|
||||
// indicator. Read-only — no I/O, just a map lookup.
|
||||
func hasPerSubmissionTemplate(submissionCode string) bool {
|
||||
_, ok := submissionTemplateRegistry[submissionCode]
|
||||
return ok
|
||||
}
|
||||
|
||||
// handleGenerateProjectSubmission resolves the per-submission template
|
||||
// (per-firm first, HL Patents Style fallback), builds a fresh variable
|
||||
// bag from project state via SubmissionVarsService, runs the merge
|
||||
// engine so every {{placeholder}} substitutes, writes one audit row,
|
||||
// and streams the result. Pre-t-paliad-253 this handler ignored the
|
||||
// per-firm registry and returned the bare HL Patents Style .dotm with
|
||||
// no substitution — the "Generieren" button on the Schriftsätze tab
|
||||
// therefore produced a generic firm-style .docx instead of a
|
||||
// project-merged Klageerwiderung, which is what m noticed in
|
||||
// m/paliad#84.
|
||||
func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -165,6 +284,12 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "submissions not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
@@ -179,60 +304,37 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
|
||||
defer cancel()
|
||||
|
||||
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
|
||||
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
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
|
||||
}
|
||||
|
||||
rule, err := loadPublishedRuleByCode(ctx, submissionCode)
|
||||
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, errRuleNotFound) {
|
||||
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("submissions: load rule %q: %v", submissionCode, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
// ErrNotVisible / project ErrNotFound from the visibility gate
|
||||
// surface through writeServiceError as 404, matching the rest
|
||||
// of the project surfaces.
|
||||
log.Printf("submissions: render (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
dotm, err := fetchHLPatentsStyleBytes(ctx)
|
||||
if err != nil {
|
||||
log.Printf("submissions: fetch HL Patents Style .dotm: %v", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "template upstream unreachable",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
docx, err := services.ConvertDotmToDocx(dotm)
|
||||
if err != nil {
|
||||
log.Printf("submissions: convert dotm for project %s code %s: %v", projectID, submissionCode, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "convert failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||
if err != nil {
|
||||
log.Printf("submissions: load user %s: %v", uid, err)
|
||||
}
|
||||
lang := "de"
|
||||
if user != nil && user.Lang != "" {
|
||||
lang = user.Lang
|
||||
}
|
||||
|
||||
filename := submissionFileName(rule, project, lang)
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
|
||||
// Audit write is best-effort with a background context so the
|
||||
// download still succeeds if the DB races. Audit failure here only
|
||||
// affects the system_audit_log feed — never the user's response.
|
||||
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelBG()
|
||||
if err := writeSubmissionAuditRow(bgCtx, user, project.ID, submissionCode, rule.Name, filename); err != nil {
|
||||
if err := writeSubmissionAuditRow(bgCtx, resolved.User, projectID, submissionCode, resolved.Rule.Name, filename); err != nil {
|
||||
log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
|
||||
@@ -244,41 +346,6 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// errRuleNotFound is the sentinel for "no published rule with that
|
||||
// submission_code" — distinguished from a generic DB error so the
|
||||
// handler returns 404 instead of 500.
|
||||
var errRuleNotFound = errors.New("submission rule not found")
|
||||
|
||||
// loadPublishedRuleByCode fetches the rule the user requested. Only
|
||||
// published+active rows resolve; drafts and archived rules never feed
|
||||
// a real submission.
|
||||
func loadPublishedRuleByCode(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, errRuleNotFound
|
||||
}
|
||||
var rule models.DeadlineRule
|
||||
err := dbSvc.projects.DB().GetContext(ctx, &rule,
|
||||
`SELECT id, proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type, duration_value, duration_unit,
|
||||
timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
||||
concept_id, legal_source, is_spawn, spawn_label, is_active,
|
||||
created_at, updated_at, lifecycle_state
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order
|
||||
LIMIT 1`, submissionCode)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, errRuleNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
// submissionFileName produces the user-facing download name per
|
||||
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
|
||||
// Empty case_number drops the segment entirely (no fallback hash —
|
||||
|
||||
@@ -313,6 +313,14 @@ type Deadline struct {
|
||||
// changes to paliad.deadline_rules and accepts citations from
|
||||
// outside that table.
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
// CustomRuleText holds the lawyer's free-text rule label when the
|
||||
// deadline form is in Custom mode (t-paliad-258 / m/paliad#89).
|
||||
// Mutually exclusive with RuleID at the application layer: the Auto
|
||||
// path sets RuleID and leaves this NULL; the Custom path sets this
|
||||
// and leaves RuleID NULL. Display surfaces prefer the joined
|
||||
// deadline_rules.name when RuleID is set, else fall back to this
|
||||
// text + a "Custom" badge.
|
||||
CustomRuleText *string `db:"custom_rule_text" json:"custom_rule_text,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
|
||||
@@ -721,6 +729,14 @@ type ProceedingType struct {
|
||||
DefaultColor string `db:"default_color" json:"default_color"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
|
||||
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
|
||||
// that fires when no rule has IsRootEvent=true. Populated for UPC Appeal
|
||||
// (mig 121) so the caption reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// NULL on most proceedings — they already carry a root rule.
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerEvent is a UPC procedural event that can start one or more deadlines
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -364,6 +365,135 @@ func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.U
|
||||
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
|
||||
}
|
||||
|
||||
// EditPendingEntity lets the REQUESTER of a pending approval_request revise
|
||||
// the in-flight entity (e.g. tweak the title or due_date on a pending
|
||||
// create) without withdrawing the request. t-paliad-252 / m/paliad#83 added
|
||||
// this as the non-destructive sibling of Revoke — m's mental model is
|
||||
// "withdraw deletes the event; let me edit the event instead, keep the
|
||||
// approval request alive".
|
||||
//
|
||||
// Authorization: caller MUST be the original requested_by (no approver can
|
||||
// edit on the requester's behalf — that would collapse into SuggestChanges).
|
||||
// Request status MUST be pending.
|
||||
//
|
||||
// Allowlist: uses the WIDER counter-allowlist already maintained for
|
||||
// SuggestChanges (buildCounterSetClauses) — every editable field on the
|
||||
// entity, not just the date-bearing approval triggers. Unknown keys are
|
||||
// silently dropped. Returns ErrSuggestionRequiresChange when fields carries
|
||||
// no allowlisted key for the entity_type (would be a no-op write).
|
||||
//
|
||||
// Side effects in one tx: entity columns updated (and event_type_ids junction
|
||||
// rewritten for deadlines), approval_request.payload merged with the new
|
||||
// values so the approver sees what was revised, and a distinct
|
||||
// `<entity>_approval_edited_by_requester` project_event emitted so the
|
||||
// Verlauf shows the revision separately from the original *_requested row.
|
||||
//
|
||||
// The approval_request stays pending; entity.approval_status stays pending.
|
||||
// The approver inbox sees a fresh updated_at + the merged payload.
|
||||
func (s *ApprovalService) EditPendingEntity(ctx context.Context, requestID, callerID uuid.UUID, fields map[string]any) error {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
req, err := s.getRequestForUpdate(ctx, tx, requestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Status != RequestStatusPending {
|
||||
return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
|
||||
}
|
||||
if callerID != req.RequestedBy {
|
||||
return ErrNotApprover
|
||||
}
|
||||
|
||||
// Validate the counter-allowlist intersect produces at least one
|
||||
// settable column. applyEntityUpdate also wraps this check; pre-checking
|
||||
// here lets us emit a cleaner error before opening the entity-write.
|
||||
if _, _, err := buildCounterSetClauses(req.EntityType, fields); err != nil {
|
||||
// Already wraps ErrSuggestionRequiresChange for empty / title-cleared
|
||||
// cases. Propagate verbatim.
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply the field updates to the entity row via the shared
|
||||
// counter-allowlist path (same as SuggestChanges).
|
||||
if err := s.applyEntityUpdate(ctx, tx, req.EntityType, req.EntityID, fields); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Merge new fields into the request payload so the approver's inbox
|
||||
// reflects what the requester revised to. Keys overwrite; event_type_ids
|
||||
// is replaced wholesale per the same semantics applyEntityUpdate uses
|
||||
// for the junction rewrite.
|
||||
var existing map[string]any
|
||||
if len(req.Payload) > 0 {
|
||||
if err := json.Unmarshal(req.Payload, &existing); err != nil {
|
||||
return fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
}
|
||||
if existing == nil {
|
||||
existing = map[string]any{}
|
||||
}
|
||||
maps.Copy(existing, fields)
|
||||
merged, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal merged payload: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.approval_requests
|
||||
SET payload = $1, updated_at = $2
|
||||
WHERE id = $3`,
|
||||
merged, now, requestID); err != nil {
|
||||
return fmt.Errorf("update payload: %w", err)
|
||||
}
|
||||
|
||||
// Audit emit. Distinct event_type so the Verlauf surfaces the revision
|
||||
// separately from the original *_requested or any decision row.
|
||||
verlaufKind := "edited_by_requester"
|
||||
eventType := approvalEventType(req.EntityType, verlaufKind)
|
||||
descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
|
||||
editedKeys := sortedKeys(fields)
|
||||
meta := map[string]any{
|
||||
"approval_request_id": req.ID.String(),
|
||||
"lifecycle_event": req.LifecycleEvent,
|
||||
req.EntityType + "_id": req.EntityID.String(),
|
||||
"edited_fields": editedKeys,
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// sortedKeys returns m's keys in stable alphabetical order so the audit-log
|
||||
// metadata is byte-for-byte stable across calls (helps when diffing audit
|
||||
// logs or asserting on them in tests).
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
// Use the stdlib sort; the slice is small (≤ counter-allowlist size).
|
||||
sortStrings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// sortStrings: indirection so we don't add a new top-level import group.
|
||||
// In Go 1.21+ slices.Sort exists; this package is currently importing
|
||||
// strings + standard libs and adding "sort" would re-fan the imports.
|
||||
// Kept as a one-line wrapper to localise the dependency if a later move
|
||||
// to slices.Sort feels right.
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j-1] > s[j]; j-- {
|
||||
s[j-1], s[j] = s[j], s[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SuggestChanges is the fourth approval action (t-paliad-216). The caller
|
||||
// proposes a counter-payload + optional free-text note; in one transaction
|
||||
// we close the old request as 'changes_requested', revert the entity from
|
||||
@@ -1477,6 +1607,95 @@ func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// UnseenInboxCountForUser returns the unified inbox badge count
|
||||
// (t-paliad-249, Slice A):
|
||||
//
|
||||
// - pending approval_requests where the caller is qualified to
|
||||
// approve (same predicate as PendingCountForUser); these count
|
||||
// regardless of users.inbox_seen_at — pending approvals never
|
||||
// fall behind the cursor.
|
||||
// - project_events with event_type ∈ InboxProjectEventKinds whose
|
||||
// created_at > users.inbox_seen_at (NULL cursor → every row is
|
||||
// unseen) on visible projects, EXCLUDING the caller's own events
|
||||
// (you don't get notified about your own actions) and excluding
|
||||
// the `*_approval_*` audit duplicates of approval_request rows.
|
||||
//
|
||||
// One round-trip; UNION ALL across two SELECTs so the two halves can
|
||||
// use their own indexes.
|
||||
func (s *ApprovalService) UnseenInboxCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
|
||||
inboxKinds := InboxProjectEventKinds
|
||||
q := `SELECT COALESCE(SUM(c), 0) FROM (
|
||||
SELECT COUNT(*) AS c
|
||||
FROM paliad.approval_requests ar
|
||||
JOIN paliad.projects p ON p.id = ar.project_id
|
||||
WHERE ar.status = 'pending'
|
||||
AND ar.requested_by <> $1
|
||||
AND ` + approvalEligibilitySQL + `
|
||||
UNION ALL
|
||||
SELECT COUNT(*) AS c
|
||||
FROM paliad.project_events pe
|
||||
JOIN paliad.projects p ON p.id = pe.project_id
|
||||
LEFT JOIN paliad.users u ON u.id = $1
|
||||
WHERE pe.event_type = ANY($2)
|
||||
AND (pe.created_by IS DISTINCT FROM $1)
|
||||
AND (u.inbox_seen_at IS NULL OR pe.created_at > u.inbox_seen_at)
|
||||
AND ` + visibilityPredicatePositional("p", 1) + `
|
||||
) sub`
|
||||
var n int
|
||||
if err := s.db.GetContext(ctx, &n, q, callerID, pq.Array(inboxKinds)); err != nil {
|
||||
return 0, fmt.Errorf("unseen inbox count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// MarkInboxSeen advances the caller's inbox read cursor.
|
||||
// If `upTo` is zero, advances to now(); otherwise advances to upTo
|
||||
// (used by the client to pin to the newest visible row so a stray
|
||||
// second tab doesn't lose items between the read and the click).
|
||||
//
|
||||
// The cursor only moves forward — calls with upTo < current are
|
||||
// no-ops so a stale tab can't rewind.
|
||||
func (s *ApprovalService) MarkInboxSeen(ctx context.Context, callerID uuid.UUID, upTo time.Time) error {
|
||||
var (
|
||||
q string
|
||||
args []any
|
||||
)
|
||||
if upTo.IsZero() {
|
||||
q = `UPDATE paliad.users
|
||||
SET inbox_seen_at = now()
|
||||
WHERE id = $1
|
||||
AND (inbox_seen_at IS NULL OR inbox_seen_at < now())`
|
||||
args = []any{callerID}
|
||||
} else {
|
||||
q = `UPDATE paliad.users
|
||||
SET inbox_seen_at = $2
|
||||
WHERE id = $1
|
||||
AND (inbox_seen_at IS NULL OR inbox_seen_at < $2)`
|
||||
args = []any{callerID, upTo.UTC()}
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, q, args...); err != nil {
|
||||
return fmt.Errorf("mark inbox seen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InboxSeenAt returns the caller's current inbox read cursor, or nil
|
||||
// if the user has never marked the inbox as seen. Used by the inbox
|
||||
// run path to overlay the unread_only predicate (t-paliad-249, §3 of
|
||||
// the design doc).
|
||||
func (s *ApprovalService) InboxSeenAt(ctx context.Context, callerID uuid.UUID) (*time.Time, error) {
|
||||
var t sql.NullTime
|
||||
q := `SELECT inbox_seen_at FROM paliad.users WHERE id = $1`
|
||||
if err := s.db.GetContext(ctx, &t, q, callerID); err != nil {
|
||||
return nil, fmt.Errorf("inbox seen lookup: %w", err)
|
||||
}
|
||||
if !t.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
v := t.Time
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Policy CRUD — paliad.approval_policies (t-paliad-138 + t-paliad-154).
|
||||
//
|
||||
|
||||
555
internal/services/backup_service.go
Normal file
555
internal/services/backup_service.go
Normal file
@@ -0,0 +1,555 @@
|
||||
package services
|
||||
|
||||
// Backup Mode runtime (t-paliad-246 / m/paliad#77 Slice A).
|
||||
//
|
||||
// One file because all four pieces are tightly coupled:
|
||||
//
|
||||
// - ArtifactStore interface + LocalDiskStore implementation
|
||||
// (storage abstraction; m picked local disk for v1, the interface
|
||||
// stays so a future swap to Supabase Storage is one impl away).
|
||||
//
|
||||
// - BackupRunner — the orchestration the on-demand handler and the
|
||||
// (Slice B) scheduler share. Wraps the export pipeline:
|
||||
// 1. INSERT paliad.backups (status='running')
|
||||
// 2. INSERT paliad.system_audit_log (event_type='backup_created')
|
||||
// 3. ExportService.WriteOrg → in-memory buffer
|
||||
// 4. ArtifactStore.Put → file
|
||||
// 5. UPDATE paliad.backups (status='done', storage_uri, …)
|
||||
// 6. PATCH paliad.system_audit_log metadata
|
||||
//
|
||||
// Design: docs/design-backup-mode-2026-05-25.md.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ArtifactStore interface + LocalDiskStore impl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ArtifactStore persists the bytes of a backup artifact. The interface
|
||||
// is deliberately small so Slice B can drop in a SupabaseStorageStore
|
||||
// (or any object-store implementation) without changing the runner.
|
||||
//
|
||||
// URIs returned by Put are opaque to callers — they round-trip through
|
||||
// Get/Delete. v1's LocalDiskStore uses `file://<absolute-path>`.
|
||||
type ArtifactStore interface {
|
||||
// Put writes the given body to the store under the given key and
|
||||
// returns the URI for later retrieval. Implementations must overwrite
|
||||
// an existing object at the same key (catalog rows make keys unique
|
||||
// in practice, but the contract is overwrite-on-conflict to keep
|
||||
// retries idempotent).
|
||||
Put(ctx context.Context, key string, body []byte) (uri string, err error)
|
||||
// Get streams the artifact bytes at the given URI.
|
||||
Get(ctx context.Context, uri string) (rc io.ReadCloser, size int64, err error)
|
||||
// Delete removes the artifact at the given URI. Returns nil if the
|
||||
// artifact is already absent (idempotent).
|
||||
Delete(ctx context.Context, uri string) error
|
||||
}
|
||||
|
||||
// LocalDiskStore is the v1 ArtifactStore — writes artifacts to a local
|
||||
// directory specified at construction time. Mode 0700 on the directory
|
||||
// + 0600 on artifact files keeps the files private to the paliad
|
||||
// process owner on the Dokploy host.
|
||||
type LocalDiskStore struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewLocalDiskStore creates a LocalDiskStore rooted at dir. Creates the
|
||||
// directory (0700) if it doesn't exist. Returns an error if dir is
|
||||
// empty or the mkdir fails.
|
||||
func NewLocalDiskStore(dir string) (*LocalDiskStore, error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return nil, errors.New("LocalDiskStore: empty directory")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("LocalDiskStore mkdir %q: %w", dir, err)
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LocalDiskStore abs %q: %w", dir, err)
|
||||
}
|
||||
return &LocalDiskStore{dir: abs}, nil
|
||||
}
|
||||
|
||||
// Put writes body to <dir>/<key>. Returns a file:// URI.
|
||||
func (s *LocalDiskStore) Put(_ context.Context, key string, body []byte) (string, error) {
|
||||
if err := validateKey(key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
full := filepath.Join(s.dir, key)
|
||||
if err := os.WriteFile(full, body, 0o600); err != nil {
|
||||
return "", fmt.Errorf("LocalDiskStore write %q: %w", full, err)
|
||||
}
|
||||
return "file://" + full, nil
|
||||
}
|
||||
|
||||
// Get opens the file referenced by uri. Returns a *os.File (io.ReadCloser)
|
||||
// + the file's size in bytes.
|
||||
func (s *LocalDiskStore) Get(_ context.Context, uri string) (io.ReadCloser, int64, error) {
|
||||
path, err := s.pathFromURI(uri)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("LocalDiskStore stat %q: %w", path, err)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("LocalDiskStore open %q: %w", path, err)
|
||||
}
|
||||
return f, info.Size(), nil
|
||||
}
|
||||
|
||||
// Delete removes the file referenced by uri. Idempotent — missing file
|
||||
// is treated as success.
|
||||
func (s *LocalDiskStore) Delete(_ context.Context, uri string) error {
|
||||
path, err := s.pathFromURI(uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("LocalDiskStore remove %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pathFromURI parses a file:// URI and validates that the resolved
|
||||
// path is inside this store's directory. Defense-in-depth against a
|
||||
// malformed catalog row pointing at an arbitrary file.
|
||||
func (s *LocalDiskStore) pathFromURI(uri string) (string, error) {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LocalDiskStore parse uri %q: %w", uri, err)
|
||||
}
|
||||
if u.Scheme != "file" {
|
||||
return "", fmt.Errorf("LocalDiskStore: unsupported uri scheme %q (want file://)", u.Scheme)
|
||||
}
|
||||
// url.Parse drops the leading "/" for file:// URIs into u.Path.
|
||||
path := u.Path
|
||||
if u.Host != "" {
|
||||
// "file://host/path" — we don't issue these. Reject.
|
||||
return "", fmt.Errorf("LocalDiskStore: file:// uri with host is unsupported (%q)", uri)
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
rel, err := filepath.Rel(s.dir, clean)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("LocalDiskStore: uri %q resolves outside store dir %q", uri, s.dir)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// validateKey rejects keys that would escape the store dir (path
|
||||
// separators, "..", absolute paths). Backup runner uses
|
||||
// "<uuid>.zip" so this is a defensive guard.
|
||||
func validateKey(key string) error {
|
||||
if key == "" {
|
||||
return errors.New("ArtifactStore: empty key")
|
||||
}
|
||||
if strings.ContainsAny(key, "/\\") {
|
||||
return fmt.Errorf("ArtifactStore: key %q contains path separator", key)
|
||||
}
|
||||
if strings.Contains(key, "..") {
|
||||
return fmt.Errorf("ArtifactStore: key %q contains traversal", key)
|
||||
}
|
||||
if filepath.IsAbs(key) {
|
||||
return fmt.Errorf("ArtifactStore: key %q is absolute", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackupRunner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BackupKind discriminates a scheduled run from an on-demand one.
|
||||
const (
|
||||
BackupKindOnDemand = "on_demand"
|
||||
BackupKindScheduled = "scheduled"
|
||||
)
|
||||
|
||||
// BackupStatus values mirror the paliad.backups status check constraint.
|
||||
const (
|
||||
BackupStatusRunning = "running"
|
||||
BackupStatusDone = "done"
|
||||
BackupStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// SystemActorEmail is the sentinel actor_email written for scheduled
|
||||
// backups (kind='scheduled'). Matches design §3.4 — we don't seed a
|
||||
// phantom user, we just stamp the audit row with a stable sentinel.
|
||||
const SystemActorEmail = "system@paliad"
|
||||
|
||||
// BackupActor identifies who requested a backup. For kind='scheduled'
|
||||
// pass (nil, SystemActorEmail, "Paliad Backup System"). For on-demand
|
||||
// pass the calling admin's id/email/display_name.
|
||||
type BackupActor struct {
|
||||
ID *uuid.UUID
|
||||
Email string
|
||||
Label string
|
||||
}
|
||||
|
||||
// BackupResult is what Run returns to the caller. Empty on failure
|
||||
// (the error gets the failure detail; the catalog/audit rows are
|
||||
// already updated).
|
||||
type BackupResult struct {
|
||||
ID uuid.UUID
|
||||
AuditID uuid.UUID
|
||||
StorageURI string
|
||||
SizeBytes int64
|
||||
RowCounts map[string]int
|
||||
SheetCount int
|
||||
}
|
||||
|
||||
// BackupRunner orchestrates one backup run. Stateless except for the
|
||||
// wired dependencies; safe to share across goroutines (the handler
|
||||
// holds one instance; the Slice B scheduler will hold the same one).
|
||||
type BackupRunner struct {
|
||||
db *sqlx.DB
|
||||
export *ExportService
|
||||
store ArtifactStore
|
||||
}
|
||||
|
||||
// NewBackupRunner wires the runner. All three deps are required; the
|
||||
// caller (cmd/server/main.go) is responsible for instantiating the
|
||||
// ArtifactStore from env config.
|
||||
func NewBackupRunner(db *sqlx.DB, export *ExportService, store ArtifactStore) *BackupRunner {
|
||||
return &BackupRunner{db: db, export: export, store: store}
|
||||
}
|
||||
|
||||
// Store returns the configured store. Exposed for the download handler
|
||||
// to stream artifacts via Get.
|
||||
func (r *BackupRunner) Store() ArtifactStore { return r.store }
|
||||
|
||||
// Run performs one backup. Writes catalog + audit rows, generates the
|
||||
// bundle via ExportService.WriteOrg, uploads to the configured store,
|
||||
// patches catalog + audit on success/failure.
|
||||
//
|
||||
// On any error after the catalog/audit rows are written, the rows are
|
||||
// patched to status='failed' / event_type='backup_failed' before
|
||||
// returning. The returned error is always the export/upload failure —
|
||||
// catalog-update failures during the failure-recovery path are best-
|
||||
// effort logged but not surfaced (the real error is the one to bubble).
|
||||
func (r *BackupRunner) Run(ctx context.Context, kind string, actor BackupActor) (BackupResult, error) {
|
||||
if kind != BackupKindOnDemand && kind != BackupKindScheduled {
|
||||
return BackupResult{}, fmt.Errorf("BackupRunner.Run: invalid kind %q", kind)
|
||||
}
|
||||
if actor.Email == "" {
|
||||
return BackupResult{}, errors.New("BackupRunner.Run: empty actor email")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
spec := ExportSpec{
|
||||
Scope: ExportScopeOrg,
|
||||
ActorID: uuid.Nil, // overwritten below when actor.ID != nil
|
||||
ActorEmail: actor.Email,
|
||||
ActorLabel: actor.Label,
|
||||
GeneratedAt: now,
|
||||
}
|
||||
if actor.ID != nil {
|
||||
spec.ActorID = *actor.ID
|
||||
}
|
||||
|
||||
// Step 1+2: catalog row (status='running') + audit row
|
||||
// (event_type='backup_created'). Both happen before the export
|
||||
// generation so failure paths can always find them.
|
||||
catalogID, err := r.insertCatalogRow(ctx, kind, actor, uuid.Nil, now)
|
||||
if err != nil {
|
||||
return BackupResult{}, fmt.Errorf("backup catalog insert: %w", err)
|
||||
}
|
||||
auditID, err := r.insertAuditRow(ctx, kind, actor, catalogID, now)
|
||||
if err != nil {
|
||||
// Best-effort patch on the catalog row so it doesn't sit
|
||||
// "running" forever.
|
||||
r.patchCatalogRowFailed(context.Background(), catalogID, fmt.Errorf("audit insert: %w", err))
|
||||
return BackupResult{}, fmt.Errorf("backup audit insert: %w", err)
|
||||
}
|
||||
// Back-link the audit id into the catalog row so the UI can JOIN.
|
||||
if err := r.linkAuditID(ctx, catalogID, auditID); err != nil {
|
||||
// Non-fatal — the link is for UI convenience, not correctness.
|
||||
// The error is logged via the patch path; we keep going.
|
||||
}
|
||||
|
||||
// Step 3: generate the bundle into an in-memory buffer. We materialise
|
||||
// fully before uploading so a partial upload doesn't strand bytes in
|
||||
// the store under a "done" catalog row.
|
||||
var buf bytes.Buffer
|
||||
meta, err := r.export.WriteOrg(ctx, &buf, spec)
|
||||
if err != nil {
|
||||
r.failRun(context.Background(), catalogID, auditID, fmt.Errorf("generate: %w", err))
|
||||
return BackupResult{}, fmt.Errorf("backup generate: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: upload to storage. Key = "<catalog_id>.zip".
|
||||
key := catalogID.String() + ".zip"
|
||||
uri, err := r.store.Put(ctx, key, buf.Bytes())
|
||||
if err != nil {
|
||||
r.failRun(context.Background(), catalogID, auditID, fmt.Errorf("upload: %w", err))
|
||||
return BackupResult{}, fmt.Errorf("backup upload: %w", err)
|
||||
}
|
||||
|
||||
// Step 5+6: patch catalog + audit on success.
|
||||
size := int64(buf.Len())
|
||||
sheetCount := len(meta.RowCounts)
|
||||
if err := r.patchCatalogRowDone(ctx, catalogID, uri, size, sheetCount, meta); err != nil {
|
||||
// At this point the artifact is on disk, the audit row was
|
||||
// inserted, and the only thing that failed is the catalog
|
||||
// flip. Surface as an error so the handler can log; the
|
||||
// artifact is recoverable manually via the audit metadata.
|
||||
return BackupResult{}, fmt.Errorf("backup catalog patch: %w", err)
|
||||
}
|
||||
if err := r.patchAuditRowDone(ctx, auditID, uri, size, sheetCount, meta); err != nil {
|
||||
// Non-fatal — the catalog row is already authoritative; the
|
||||
// audit row is the audit-trail twin. Log via the caller.
|
||||
}
|
||||
|
||||
return BackupResult{
|
||||
ID: catalogID,
|
||||
AuditID: auditID,
|
||||
StorageURI: uri,
|
||||
SizeBytes: size,
|
||||
RowCounts: meta.RowCounts,
|
||||
SheetCount: sheetCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RecordDownload writes a paliad.system_audit_log row of
|
||||
// event_type='backup_downloaded' when an admin downloads a backup
|
||||
// via /api/admin/backups/{id}/file. Separate row per click — the
|
||||
// existing 'backup_created' row stays untouched.
|
||||
func (r *BackupRunner) RecordDownload(ctx context.Context, backupID uuid.UUID, by BackupActor) error {
|
||||
if by.Email == "" {
|
||||
return errors.New("BackupRunner.RecordDownload: empty actor email")
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{
|
||||
"backup_id": backupID.String(),
|
||||
"downloaded_by_email": by.Email,
|
||||
"downloaded_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
var actorID any
|
||||
if by.ID != nil {
|
||||
actorID = *by.ID
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO paliad.system_audit_log
|
||||
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||
VALUES ('backup_downloaded', $1, $2, 'org', NULL, $3::jsonb)`,
|
||||
actorID, by.Email, string(meta),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup_downloaded audit insert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog read helpers (List + Get for the admin UI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BackupSummary is the row shape returned by ListBackups + GetBackup —
|
||||
// shaped for the /admin/backups UI. Nullable columns are pointers.
|
||||
type BackupSummary struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Kind string `db:"kind" json:"kind"`
|
||||
Status string `db:"status" json:"status"`
|
||||
RequestedBy *uuid.UUID `db:"requested_by" json:"requested_by,omitempty"`
|
||||
RequestedByEmail string `db:"requested_by_email" json:"requested_by_email"`
|
||||
AuditID *uuid.UUID `db:"audit_id" json:"audit_id,omitempty"`
|
||||
StorageURI *string `db:"storage_uri" json:"storage_uri,omitempty"`
|
||||
SizeBytes *int64 `db:"size_bytes" json:"size_bytes,omitempty"`
|
||||
RowCounts []byte `db:"row_counts" json:"row_counts,omitempty"`
|
||||
SheetCount *int `db:"sheet_count" json:"sheet_count,omitempty"`
|
||||
Warnings []byte `db:"warnings" json:"warnings,omitempty"`
|
||||
Error *string `db:"error" json:"error,omitempty"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
|
||||
DeletedAt *time.Time `db:"deleted_at" json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// ListBackups returns the most recent backups (highest started_at first),
|
||||
// capped at limit. limit <= 0 means default (100).
|
||||
func (r *BackupRunner) ListBackups(ctx context.Context, limit int) ([]BackupSummary, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
var rows []BackupSummary
|
||||
err := r.db.SelectContext(ctx, &rows,
|
||||
`SELECT id, kind, status, requested_by, requested_by_email, audit_id,
|
||||
storage_uri, size_bytes, row_counts, sheet_count, warnings,
|
||||
error, started_at, finished_at, deleted_at
|
||||
FROM paliad.backups
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $1`,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list backups: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetBackup fetches one backup by id. Returns sql.ErrNoRows when not
|
||||
// found (caller maps to 404).
|
||||
func (r *BackupRunner) GetBackup(ctx context.Context, id uuid.UUID) (BackupSummary, error) {
|
||||
var row BackupSummary
|
||||
err := r.db.GetContext(ctx, &row,
|
||||
`SELECT id, kind, status, requested_by, requested_by_email, audit_id,
|
||||
storage_uri, size_bytes, row_counts, sheet_count, warnings,
|
||||
error, started_at, finished_at, deleted_at
|
||||
FROM paliad.backups
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return BackupSummary{}, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog + audit SQL helpers (private — used by Run + RecordDownload).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (r *BackupRunner) insertCatalogRow(ctx context.Context, kind string, actor BackupActor, auditID uuid.UUID, now time.Time) (uuid.UUID, error) {
|
||||
var actorID any
|
||||
if actor.ID != nil {
|
||||
actorID = *actor.ID
|
||||
}
|
||||
var auditArg any
|
||||
if auditID != uuid.Nil {
|
||||
auditArg = auditID
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := r.db.QueryRowxContext(ctx,
|
||||
`INSERT INTO paliad.backups
|
||||
(kind, status, requested_by, requested_by_email, audit_id, started_at)
|
||||
VALUES ($1, 'running', $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
kind, actorID, actor.Email, auditArg, now,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *BackupRunner) insertAuditRow(ctx context.Context, kind string, actor BackupActor, catalogID uuid.UUID, now time.Time) (uuid.UUID, error) {
|
||||
meta, _ := json.Marshal(map[string]any{
|
||||
"kind": kind,
|
||||
"catalog_id": catalogID.String(),
|
||||
"requested_by_email": actor.Email,
|
||||
"requested_at": now.Format(time.RFC3339),
|
||||
})
|
||||
var actorID any
|
||||
if actor.ID != nil {
|
||||
actorID = *actor.ID
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := r.db.QueryRowxContext(ctx,
|
||||
`INSERT INTO paliad.system_audit_log
|
||||
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||
VALUES ('backup_created', $1, $2, 'org', NULL, $3::jsonb)
|
||||
RETURNING id`,
|
||||
actorID, actor.Email, string(meta),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *BackupRunner) linkAuditID(ctx context.Context, catalogID, auditID uuid.UUID) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE paliad.backups SET audit_id = $2 WHERE id = $1`,
|
||||
catalogID, auditID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *BackupRunner) patchCatalogRowDone(ctx context.Context, id uuid.UUID, uri string, size int64, sheetCount int, meta ExportMeta) error {
|
||||
rcJSON, _ := json.Marshal(meta.RowCounts)
|
||||
warnJSON, _ := json.Marshal(meta.Warnings)
|
||||
if meta.Warnings == nil {
|
||||
warnJSON = []byte("[]")
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE paliad.backups
|
||||
SET status = 'done',
|
||||
storage_uri = $2,
|
||||
size_bytes = $3,
|
||||
sheet_count = $4,
|
||||
row_counts = $5::jsonb,
|
||||
warnings = $6::jsonb,
|
||||
finished_at = now()
|
||||
WHERE id = $1`,
|
||||
id, uri, size, sheetCount, string(rcJSON), string(warnJSON),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *BackupRunner) patchCatalogRowFailed(ctx context.Context, id uuid.UUID, runErr error) {
|
||||
_, _ = r.db.ExecContext(ctx,
|
||||
`UPDATE paliad.backups
|
||||
SET status = 'failed',
|
||||
error = $2,
|
||||
finished_at = now()
|
||||
WHERE id = $1`,
|
||||
id, runErr.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *BackupRunner) patchAuditRowDone(ctx context.Context, id uuid.UUID, uri string, size int64, sheetCount int, meta ExportMeta) error {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"row_counts": meta.RowCounts,
|
||||
"file_size_bytes": size,
|
||||
"sheet_count": sheetCount,
|
||||
"storage_uri": uri,
|
||||
"warnings": meta.Warnings,
|
||||
"completed_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE paliad.system_audit_log
|
||||
SET metadata = metadata || $2::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id, string(payload),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *BackupRunner) patchAuditRowFailed(ctx context.Context, id uuid.UUID, runErr error) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"error": runErr.Error(),
|
||||
"failed_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
_, _ = r.db.ExecContext(ctx,
|
||||
`UPDATE paliad.system_audit_log
|
||||
SET event_type = 'backup_failed',
|
||||
metadata = metadata || $2::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id, string(payload),
|
||||
)
|
||||
}
|
||||
|
||||
// failRun is the shared failure-recovery path: patch the catalog +
|
||||
// audit rows to their failed states. Uses a context.Background so the
|
||||
// patch happens even if the original ctx is already cancelled.
|
||||
func (r *BackupRunner) failRun(ctx context.Context, catalogID, auditID uuid.UUID, runErr error) {
|
||||
r.patchCatalogRowFailed(ctx, catalogID, runErr)
|
||||
r.patchAuditRowFailed(ctx, auditID, runErr)
|
||||
}
|
||||
193
internal/services/backup_service_test.go
Normal file
193
internal/services/backup_service_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for the Backup Mode runtime (t-paliad-246 / m/paliad#77).
|
||||
//
|
||||
// Live DB behaviour (the actual org dump end-to-end) needs a Postgres;
|
||||
// it would live in backup_service_live_test.go under TEST_DATABASE_URL.
|
||||
// This file covers the bits that don't need a database:
|
||||
//
|
||||
// - orgSheetQueries registry shape: no duplicates, no excluded
|
||||
// paliadin sheets, predictable prefix split between entity and ref.
|
||||
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
|
||||
// URI traversal rejection.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// orgSheetQueries registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if seen[sq.SheetName] {
|
||||
t.Fatalf("duplicate sheet name in orgSheetQueries: %q", sq.SheetName)
|
||||
}
|
||||
seen[sq.SheetName] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
|
||||
// m's t-paliad-214 Q5 decision + this design's §11 Q3 default:
|
||||
// paliadin_turns and paliadin_aichat_conversation must be ABSENT
|
||||
// from the registry (structural exclusion, not just column-drop).
|
||||
for _, sq := range orgSheetQueries() {
|
||||
name := sq.SheetName
|
||||
if strings.Contains(name, "paliadin") {
|
||||
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
|
||||
}
|
||||
// Belt-and-braces: SQL bodies should not reference the tables
|
||||
// either (no UNION joins, no subqueries pulling them in).
|
||||
if strings.Contains(sq.SQL, "paliadin_turns") || strings.Contains(sq.SQL, "paliadin_aichat_conversation") {
|
||||
t.Fatalf("orgSheetQueries[%q] SQL references a paliadin table: %s", name, sq.SQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
|
||||
// Every sheet whose data is read-only reference material is
|
||||
// expected to use the `ref__` prefix. The writer's downstream
|
||||
// consumers rely on this convention to group reference data
|
||||
// visually in the workbook.
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if !strings.HasPrefix(sq.SheetName, "ref__") {
|
||||
continue
|
||||
}
|
||||
// Reference sheets shouldn't carry per-row WHERE clauses (they
|
||||
// dump the whole reference table for portability).
|
||||
if strings.Contains(strings.ToUpper(sq.SQL), "WHERE") {
|
||||
t.Fatalf("orgSheetQueries[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sq.SheetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_OrderByForDeterminism(t *testing.T) {
|
||||
// Every sheet must specify an ORDER BY so the byte-deterministic
|
||||
// contract from t-paliad-214 §3 holds across runs.
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if !strings.Contains(strings.ToUpper(sq.SQL), "ORDER BY") {
|
||||
t.Fatalf("orgSheetQueries[%q] missing ORDER BY (determinism contract): %s", sq.SheetName, sq.SQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LocalDiskStore round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLocalDiskStore_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewLocalDiskStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalDiskStore: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
want := []byte("hello backup\n")
|
||||
|
||||
uri, err := store.Put(ctx, "test.zip", want)
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(uri, "file://") {
|
||||
t.Fatalf("expected file:// uri, got %q", uri)
|
||||
}
|
||||
rc, size, err := store.Get(ctx, uri)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
if size != int64(len(want)) {
|
||||
t.Fatalf("Get size = %d, want %d", size, len(want))
|
||||
}
|
||||
got, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("Get body = %q, want %q", got, want)
|
||||
}
|
||||
if err := store.Delete(ctx, uri); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
// File should be gone; Get returns an error.
|
||||
if _, _, err := store.Get(ctx, uri); err == nil {
|
||||
t.Fatalf("Get after Delete should fail")
|
||||
}
|
||||
// Delete is idempotent.
|
||||
if err := store.Delete(ctx, uri); err != nil {
|
||||
t.Fatalf("idempotent Delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiskStore_RejectsBadKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewLocalDiskStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalDiskStore: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
cases := []string{
|
||||
"",
|
||||
"sub/dir/file.zip",
|
||||
"..\\evil.zip",
|
||||
"../escape.zip",
|
||||
"/abs/path.zip",
|
||||
}
|
||||
for _, k := range cases {
|
||||
if _, err := store.Put(ctx, k, []byte("x")); err == nil {
|
||||
t.Fatalf("Put with bad key %q should fail", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiskStore_RejectsURIOutsideDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewLocalDiskStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalDiskStore: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
// A file:// URI pointing outside the store dir must be rejected
|
||||
// by both Get and Delete (defense in depth against a corrupted
|
||||
// catalog row).
|
||||
outside := "file://" + filepath.Join(filepath.Dir(dir), "elsewhere.zip")
|
||||
if _, _, err := store.Get(ctx, outside); err == nil {
|
||||
t.Fatalf("Get outside store dir should fail")
|
||||
}
|
||||
if err := store.Delete(ctx, outside); err == nil {
|
||||
t.Fatalf("Delete outside store dir should fail")
|
||||
}
|
||||
// Wrong scheme is also rejected.
|
||||
if _, _, err := store.Get(ctx, "https://example.com/foo.zip"); err == nil {
|
||||
t.Fatalf("Get with non-file:// scheme should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiskStore_CreatesDir(t *testing.T) {
|
||||
// A non-existent parent gets created at construction; mode 0700.
|
||||
base := t.TempDir()
|
||||
target := filepath.Join(base, "nested", "exports")
|
||||
store, err := NewLocalDiskStore(target)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalDiskStore(non-existent): %v", err)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatalf("expected store dir to exist: %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("expected directory, got file")
|
||||
}
|
||||
// Smoke-write to confirm the dir is actually usable.
|
||||
if _, err := store.Put(context.Background(), "ok.zip", []byte{}); err != nil {
|
||||
t.Fatalf("Put into fresh dir: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uui
|
||||
}
|
||||
|
||||
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
|
||||
warning_date, source, rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
|
||||
notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at`
|
||||
|
||||
@@ -81,6 +81,11 @@ type CreateDeadlineInput struct {
|
||||
// Sent by the Fristenrechner save flow so the title can stay clean
|
||||
// instead of carrying the citation as a prefix.
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
// CustomRuleText is the lawyer's free-text rule label when the
|
||||
// deadline form is in Custom mode (t-paliad-258). Mutually exclusive
|
||||
// with RuleID at the application layer; the service trims and treats
|
||||
// an all-whitespace value as nil.
|
||||
CustomRuleText *string `json:"custom_rule_text,omitempty"`
|
||||
Source string `json:"source,omitempty"` // default "manual"
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
@@ -108,6 +113,20 @@ type UpdateDeadlineInput struct {
|
||||
Status *string `json:"status,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
|
||||
// Rule pointer pair (t-paliad-258 / m/paliad#89). Three valid
|
||||
// shapes; the service rejects "both set":
|
||||
// - RuleSet=true, RuleID non-nil, CustomRuleText nil → Auto:
|
||||
// bind to the catalog rule, clear custom_rule_text.
|
||||
// - RuleSet=true, RuleID nil, CustomRuleText non-nil → Custom:
|
||||
// store free text, clear rule_id.
|
||||
// - RuleSet=true, RuleID nil, CustomRuleText nil → No rule:
|
||||
// clear both columns.
|
||||
// RuleSet=false leaves both columns untouched (the rest of the
|
||||
// PATCH body doesn't carry rule changes).
|
||||
RuleSet bool `json:"rule_set,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
CustomRuleText *string `json:"custom_rule_text,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
|
||||
@@ -241,7 +260,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
|
||||
query := `
|
||||
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.status, f.completed_at,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
|
||||
f.created_at, f.updated_at,
|
||||
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
|
||||
@@ -514,6 +533,23 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
}
|
||||
}
|
||||
|
||||
// Auto/Custom rule swap (t-paliad-258). Mutually exclusive at the
|
||||
// persistence boundary: setting one column NULLs the other.
|
||||
if input.RuleSet {
|
||||
if input.RuleID != nil && input.CustomRuleText != nil {
|
||||
return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
|
||||
}
|
||||
appendSet("rule_id", input.RuleID)
|
||||
var customText *string
|
||||
if input.CustomRuleText != nil {
|
||||
trimmed := strings.TrimSpace(*input.CustomRuleText)
|
||||
if trimmed != "" {
|
||||
customText = &trimmed
|
||||
}
|
||||
}
|
||||
appendSet("custom_rule_text", customText)
|
||||
}
|
||||
|
||||
// Project move (t-paliad-140). Visibility on the destination is enforced
|
||||
// the same way as on Create — a GetByID round-trip through ProjectService
|
||||
// returns ErrNotVisible if the user can't see the target. Same-project
|
||||
@@ -587,7 +623,7 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
// Did the PATCH touch anything beyond the project move?
|
||||
otherFieldsTouched := input.Title != nil || input.Description != nil ||
|
||||
input.DueDate != nil || input.Notes != nil || input.Status != nil ||
|
||||
input.EventTypeIDs != nil
|
||||
input.EventTypeIDs != nil || input.RuleSet
|
||||
if otherFieldsTouched {
|
||||
auditProject := current.ProjectID
|
||||
if movedFromProject != nil {
|
||||
@@ -1012,15 +1048,27 @@ func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, pro
|
||||
}
|
||||
}
|
||||
|
||||
// Auto vs Custom (t-paliad-258): RuleID and CustomRuleText are
|
||||
// mutually exclusive. If the caller passes both, the catalog rule
|
||||
// wins and the free-text is dropped — keeps the invariant simple at
|
||||
// the persistence boundary.
|
||||
var customRuleText *string
|
||||
if input.CustomRuleText != nil && input.RuleID == nil {
|
||||
trimmed := strings.TrimSpace(*input.CustomRuleText)
|
||||
if trimmed != "" {
|
||||
customRuleText = &trimmed
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(id, project_id, title, description, due_date, original_due_date,
|
||||
source, rule_id, rule_code, status, notes, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11, $12, $12)`,
|
||||
source, rule_id, rule_code, custom_rule_text, status, notes, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11, $12, $13, $13)`,
|
||||
id, projectID, title, input.Description, due, orig,
|
||||
source, input.RuleID, ruleCode, input.Notes, userID, now,
|
||||
source, input.RuleID, ruleCode, customRuleText, input.Notes, userID, now,
|
||||
); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert deadline: %w", err)
|
||||
}
|
||||
|
||||
@@ -107,11 +107,15 @@ type EventListItem struct {
|
||||
Status *string `json:"status,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Source *string `json:"source,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
RuleName *string `json:"rule_name,omitempty"`
|
||||
RuleNameEN *string `json:"rule_name_en,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
RuleName *string `json:"rule_name,omitempty"`
|
||||
RuleNameEN *string `json:"rule_name_en,omitempty"`
|
||||
// CustomRuleText surfaces the lawyer's free-text rule label when the
|
||||
// deadline was created via the Custom rule path (t-paliad-258).
|
||||
// Display surfaces fall back to it when RuleName is absent.
|
||||
CustomRuleText *string `json:"custom_rule_text,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
|
||||
// Appointment-only.
|
||||
StartAt *time.Time `json:"start_at,omitempty"`
|
||||
@@ -236,6 +240,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
|
||||
RuleCode: d.RuleCode,
|
||||
RuleName: d.RuleName,
|
||||
RuleNameEN: d.RuleNameEN,
|
||||
CustomRuleText: d.CustomRuleText,
|
||||
EventTypeIDs: d.EventTypeIDs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/csv"
|
||||
@@ -185,7 +186,7 @@ func (s *ExportService) WritePersonal(ctx context.Context, w io.Writer, spec Exp
|
||||
}
|
||||
|
||||
sheets := personalSheetQueries(spec.ActorID)
|
||||
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
|
||||
if err := s.writeBundle(ctx, s.db, w, sheets, &meta); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
return meta, nil
|
||||
@@ -238,7 +239,7 @@ func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec Expo
|
||||
}
|
||||
|
||||
sheets := projectSheetQueries(*spec.ScopeRoot, spec.DirectOnly)
|
||||
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
|
||||
if err := s.writeBundle(ctx, s.db, w, sheets, &meta); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
@@ -254,6 +255,55 @@ func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec Expo
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// WriteOrg streams the full org-scope backup bundle into w. Bypasses
|
||||
// paliad.can_see_project — admin-only, gated at the handler layer (the
|
||||
// service trusts the caller has been authorised).
|
||||
//
|
||||
// Wraps the entire read pass in a REPEATABLE READ READ ONLY transaction
|
||||
// so every sheet sees the same snapshot. Without this a backup that runs
|
||||
// while users are editing can land internally inconsistent rows (e.g. a
|
||||
// deadlines.project_id pointing at a project the projects sheet just
|
||||
// missed). Design §3.3.
|
||||
//
|
||||
// The handler is responsible for the audit-row INSERT / PATCH (the
|
||||
// org-scope backup uses BackupRunner.Run, not WriteAuditRow, because the
|
||||
// event_type is 'backup_created' not 'data_export').
|
||||
func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSpec) (ExportMeta, error) {
|
||||
if spec.Scope == "" {
|
||||
spec.Scope = ExportScopeOrg
|
||||
}
|
||||
if spec.GeneratedAt.IsZero() {
|
||||
spec.GeneratedAt = time.Now().UTC()
|
||||
}
|
||||
meta := ExportMeta{
|
||||
SchemaVersion: ExportSchemaVersion,
|
||||
FirmName: s.firmName,
|
||||
Scope: spec.Scope,
|
||||
GeneratedAt: spec.GeneratedAt,
|
||||
GeneratedByID: spec.ActorID,
|
||||
GeneratedByEml: spec.ActorEmail,
|
||||
GeneratedByLbl: spec.ActorLabel,
|
||||
RowCounts: map[string]int{},
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, &sql.TxOptions{
|
||||
Isolation: sql.LevelRepeatableRead,
|
||||
ReadOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
return meta, fmt.Errorf("backup snapshot tx: %w", err)
|
||||
}
|
||||
// Always rollback — the tx is read-only by construction, the rollback
|
||||
// is just bookkeeping that releases the snapshot.
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
sheets := orgSheetQueries()
|
||||
if err := s.writeBundle(ctx, tx, w, sheets, &meta); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// detectCrossSubtreeFKs scans subtree-resident projects for FKs that
|
||||
// point outside the subtree (today: only projects.counterclaim_of). One
|
||||
// warning row per outbound reference. Best-effort: a query error here
|
||||
@@ -300,13 +350,17 @@ type collectedSheet struct {
|
||||
// xlsx sheet + one JSON branch + one CSV per sheet, packs everything into
|
||||
// the outer zip in sorted file-list order so two runs of the same row
|
||||
// state produce byte-identical bundles.
|
||||
func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []sheetQuery, meta *ExportMeta) error {
|
||||
//
|
||||
// queryer is the executor for sheet queries — typically s.db, but
|
||||
// WriteOrg passes a REPEATABLE READ *sqlx.Tx so the org dump sees a
|
||||
// consistent snapshot across all sheets (design §3.3).
|
||||
func (s *ExportService) writeBundle(ctx context.Context, queryer sqlx.QueryerContext, w io.Writer, sheets []sheetQuery, meta *ExportMeta) error {
|
||||
collectedSheets := make([]collectedSheet, 0, len(sheets))
|
||||
jsonTables := make(map[string][]map[string]string, len(sheets))
|
||||
warnings := []string{}
|
||||
|
||||
for _, sq := range sheets {
|
||||
cols, rowMatrix, dropped, err := s.runSheetQuery(ctx, sq)
|
||||
cols, rowMatrix, dropped, err := s.runSheetQuery(ctx, queryer, sq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export sheet %q: %w", sq.SheetName, err)
|
||||
}
|
||||
@@ -421,11 +475,13 @@ func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []s
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSheetQuery executes one sheetQuery and returns the kept columns,
|
||||
// row matrix (pre-stringified per the design's value-as-string convention),
|
||||
// and the list of columns that were dropped by the PII filter.
|
||||
func (s *ExportService) runSheetQuery(ctx context.Context, sq sheetQuery) (cols []string, rows [][]string, dropped []string, err error) {
|
||||
rs, err := s.db.QueryxContext(ctx, sq.SQL, sq.Args...)
|
||||
// runSheetQuery executes one sheetQuery against the given queryer and
|
||||
// returns the kept columns, row matrix (pre-stringified per the design's
|
||||
// value-as-string convention), and the list of columns that were dropped
|
||||
// by the PII filter. queryer is typically s.db, but WriteOrg passes a
|
||||
// REPEATABLE READ *sqlx.Tx (see writeBundle docs).
|
||||
func (s *ExportService) runSheetQuery(ctx context.Context, queryer sqlx.QueryerContext, sq sheetQuery) (cols []string, rows [][]string, dropped []string, err error) {
|
||||
rs, err := queryer.QueryxContext(ctx, sq.SQL, sq.Args...)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("query: %w", err)
|
||||
}
|
||||
@@ -1470,3 +1526,107 @@ SELECT 'partner_unit_default'::text AS source,
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org-scope sheet registry (Slice 3 / Backup Mode — t-paliad-246).
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Full-schema dump. Bypasses paliad.can_see_project — admin-only,
|
||||
// gated at the handler layer (BackupRunner trusts the caller).
|
||||
//
|
||||
// Sheet ordering: entity sheets first (alphabetical), then ref__*
|
||||
// reference sheets (alphabetical). The xlsx writer iterates the slice
|
||||
// in order; downstream consumers get the same order across runs.
|
||||
//
|
||||
// Hard exclusions (per design §5.2 / m's Q3 decision):
|
||||
//
|
||||
// - paliadin_turns
|
||||
// - paliadin_aichat_conversation
|
||||
//
|
||||
// AI conversation history is the most-sensitive personal data paliad
|
||||
// carries; m's prior Q5 decision in t-paliad-214 made the exclusion
|
||||
// structural. The two tables are absent from the registry — not just
|
||||
// column-level redacted — so a future schema addition cannot
|
||||
// accidentally re-include them.
|
||||
//
|
||||
// Also excluded unconditionally (operational / shadow):
|
||||
//
|
||||
// - *_pre_NNN shadow tables (CREATE TABLE … AS SELECT backups
|
||||
// written by destructive migrations)
|
||||
// - paliad_schema_migrations (operational)
|
||||
// - auth.* (Supabase Auth schema — not ours)
|
||||
//
|
||||
// The PII column deny-regex (piiColumnDenyRegex) catches
|
||||
// secret|token|password|api_key|private_key on every sheet as a
|
||||
// belt-and-braces filter. user_caldav_config.password_encrypted is
|
||||
// explicitly named in DropColumns too.
|
||||
func orgSheetQueries() []sheetQuery {
|
||||
return []sheetQuery{
|
||||
// --- entity sheets (alphabetical) ---
|
||||
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_binding_id`},
|
||||
{SheetName: "appointments", SQL: `SELECT * FROM paliad.appointments ORDER BY id`},
|
||||
{SheetName: "approval_policies", SQL: `SELECT * FROM paliad.approval_policies ORDER BY id`},
|
||||
{SheetName: "approval_requests", SQL: `SELECT * FROM paliad.approval_requests ORDER BY id`},
|
||||
// backups is self-reflexive — including it makes "what backups
|
||||
// have we taken" recoverable from any prior backup. Tiny table.
|
||||
{SheetName: "backups", SQL: `SELECT * FROM paliad.backups ORDER BY started_at, id`},
|
||||
{SheetName: "caldav_sync_log", SQL: `SELECT * FROM paliad.caldav_sync_log ORDER BY occurred_at, id`},
|
||||
{SheetName: "checklist_instances", SQL: `SELECT * FROM paliad.checklist_instances ORDER BY id`},
|
||||
{SheetName: "checklist_shares", SQL: `SELECT * FROM paliad.checklist_shares ORDER BY id`},
|
||||
{SheetName: "checklists", SQL: `SELECT * FROM paliad.checklists ORDER BY id`},
|
||||
{SheetName: "deadline_rule_audit", SQL: `SELECT * FROM paliad.deadline_rule_audit ORDER BY changed_at, id`},
|
||||
{SheetName: "deadlines", SQL: `SELECT * FROM paliad.deadlines ORDER BY id`},
|
||||
// documents: ai_extracted jsonb dropped (verbose AI prompts;
|
||||
// matches the personal/project precedent). Binaries are not in
|
||||
// the export — only metadata.
|
||||
{
|
||||
SheetName: "documents",
|
||||
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
|
||||
FROM paliad.documents
|
||||
ORDER BY id`,
|
||||
},
|
||||
{SheetName: "email_broadcasts", SQL: `SELECT * FROM paliad.email_broadcasts ORDER BY id`},
|
||||
{SheetName: "email_template_versions", SQL: `SELECT * FROM paliad.email_template_versions ORDER BY id`},
|
||||
{SheetName: "email_templates", SQL: `SELECT * FROM paliad.email_templates ORDER BY id`},
|
||||
{SheetName: "firm_dashboard_default", SQL: `SELECT * FROM paliad.firm_dashboard_default ORDER BY id`},
|
||||
{SheetName: "invitations", SQL: `SELECT * FROM paliad.invitations ORDER BY sent_at, id`},
|
||||
{SheetName: "notes", SQL: `SELECT * FROM paliad.notes ORDER BY id`},
|
||||
{SheetName: "parties", SQL: `SELECT * FROM paliad.parties ORDER BY id`},
|
||||
{SheetName: "partner_unit_events", SQL: `SELECT * FROM paliad.partner_unit_events ORDER BY id`},
|
||||
{SheetName: "partner_unit_members", SQL: `SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id`},
|
||||
{SheetName: "partner_units", SQL: `SELECT * FROM paliad.partner_units ORDER BY id`},
|
||||
{SheetName: "policy_audit_log", SQL: `SELECT * FROM paliad.policy_audit_log ORDER BY changed_at, id`},
|
||||
{SheetName: "project_events", SQL: `SELECT * FROM paliad.project_events ORDER BY id`},
|
||||
{SheetName: "project_partner_units", SQL: `SELECT * FROM paliad.project_partner_units ORDER BY project_id, partner_unit_id`},
|
||||
{SheetName: "project_teams", SQL: `SELECT * FROM paliad.project_teams ORDER BY project_id, user_id`},
|
||||
{SheetName: "projects", SQL: `SELECT * FROM paliad.projects ORDER BY id`},
|
||||
{SheetName: "reminder_log", SQL: `SELECT * FROM paliad.reminder_log ORDER BY sent_at, id`},
|
||||
{SheetName: "submission_drafts", SQL: `SELECT * FROM paliad.submission_drafts ORDER BY id`},
|
||||
{SheetName: "system_audit_log", SQL: `SELECT * FROM paliad.system_audit_log ORDER BY created_at, id`},
|
||||
{
|
||||
SheetName: "user_caldav_config",
|
||||
SQL: `SELECT * FROM paliad.user_caldav_config ORDER BY user_id`,
|
||||
DropColumns: []string{"password_encrypted"}, // belt-and-braces; piiColumnDenyRegex also catches it
|
||||
},
|
||||
{SheetName: "user_calendar_bindings", SQL: `SELECT * FROM paliad.user_calendar_bindings ORDER BY user_id, calendar_path`},
|
||||
{SheetName: "user_card_layouts", SQL: `SELECT * FROM paliad.user_card_layouts ORDER BY id`},
|
||||
{SheetName: "user_dashboard_layouts", SQL: `SELECT * FROM paliad.user_dashboard_layouts ORDER BY user_id`},
|
||||
{SheetName: "user_pinned_projects", SQL: `SELECT * FROM paliad.user_pinned_projects ORDER BY user_id, project_id`},
|
||||
{SheetName: "user_views", SQL: `SELECT * FROM paliad.user_views ORDER BY id`},
|
||||
{SheetName: "users", SQL: `SELECT * FROM paliad.users ORDER BY id`},
|
||||
|
||||
// --- reference data (alphabetical, prefixed ref__) ---
|
||||
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
|
||||
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
|
||||
{SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`},
|
||||
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
|
||||
{SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`},
|
||||
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
|
||||
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
|
||||
{SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`},
|
||||
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
|
||||
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
|
||||
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
|
||||
{SheetName: "ref__trigger_events", SQL: `SELECT * FROM paliad.trigger_events ORDER BY id`},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -114,12 +122,18 @@ type TimeSpec struct {
|
||||
type TimeHorizon string
|
||||
|
||||
const (
|
||||
HorizonNext1d TimeHorizon = "next_1d"
|
||||
HorizonNext7d TimeHorizon = "next_7d"
|
||||
HorizonNext14d TimeHorizon = "next_14d"
|
||||
HorizonNext30d TimeHorizon = "next_30d"
|
||||
HorizonNext90d TimeHorizon = "next_90d"
|
||||
HorizonNextAll TimeHorizon = "next_all"
|
||||
HorizonPast1d TimeHorizon = "past_1d"
|
||||
HorizonPast7d TimeHorizon = "past_7d"
|
||||
HorizonPast14d TimeHorizon = "past_14d"
|
||||
HorizonPast30d TimeHorizon = "past_30d"
|
||||
HorizonPast90d TimeHorizon = "past_90d"
|
||||
HorizonPastAll TimeHorizon = "past_all"
|
||||
HorizonAny TimeHorizon = "any"
|
||||
HorizonAll TimeHorizon = "all"
|
||||
HorizonCustom TimeHorizon = "custom"
|
||||
@@ -192,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
|
||||
@@ -279,8 +340,9 @@ func (s *ScopeSpec) validate() error {
|
||||
|
||||
func (t *TimeSpec) validate(scope ScopeSpec) error {
|
||||
switch t.Horizon {
|
||||
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
|
||||
HorizonPast7d, HorizonPast30d, HorizonPast90d, HorizonAny:
|
||||
case HorizonNext1d, HorizonNext7d, HorizonNext14d, HorizonNext30d, HorizonNext90d, HorizonNextAll,
|
||||
HorizonPast1d, HorizonPast7d, HorizonPast14d, HorizonPast30d, HorizonPast90d, HorizonPastAll,
|
||||
HorizonAny:
|
||||
// fine
|
||||
case HorizonAll:
|
||||
// Q26: reject "all" unless scope.projects is explicit. Performance
|
||||
|
||||
@@ -160,6 +160,23 @@ func TestFilterSpec_HorizonCustomAcceptsValidRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-248: the symmetric date-range picker adds six new horizons —
|
||||
// 1d/14d/all on each side. They must round-trip through validate without
|
||||
// requiring scope.explicit (unlike HorizonAll which is a bidirectional-
|
||||
// unbounded substrate scan and stays gated to ScopeExplicit per Q26).
|
||||
func TestFilterSpec_NewSymmetricHorizonsValidate(t *testing.T) {
|
||||
for _, h := range []TimeHorizon{
|
||||
HorizonNext1d, HorizonNext14d, HorizonNextAll,
|
||||
HorizonPast1d, HorizonPast14d, HorizonPastAll,
|
||||
} {
|
||||
s := validBaseSpec()
|
||||
s.Time.Horizon = h
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("horizon %q must validate against a default scope: %v", h, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceDeadline}
|
||||
|
||||
@@ -115,6 +115,16 @@ type UIResponse struct {
|
||||
// note explaining the framing.
|
||||
ContextualNote string `json:"contextualNote,omitempty"`
|
||||
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
|
||||
// TriggerEventLabel / TriggerEventLabelEN: optional caption for the
|
||||
// /tools/verfahrensablauf "Auslösendes Ereignis" field. Populated
|
||||
// from paliad.proceeding_types.trigger_event_label_{de,en} (mig 121).
|
||||
// The frontend prefers this over the proceedingName fallback that
|
||||
// fires when no rule has IsRootEvent=true — UPC Appeal needed it
|
||||
// because all its rules carry a non-zero duration off the trigger
|
||||
// date so no rule is the "anchor". The trigger event for UPC Appeal
|
||||
// is the appealable first-instance decision (m/paliad#81).
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
}
|
||||
|
||||
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
|
||||
@@ -237,14 +247,17 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
|
||||
// Look up proceeding type metadata.
|
||||
var pt struct {
|
||||
ID int `db:"id"`
|
||||
Code string `db:"code"`
|
||||
Name string `db:"name"`
|
||||
NameEN string `db:"name_en"`
|
||||
Jurisdiction *string `db:"jurisdiction"`
|
||||
ID int `db:"id"`
|
||||
Code string `db:"code"`
|
||||
Name string `db:"name"`
|
||||
NameEN string `db:"name_en"`
|
||||
Jurisdiction *string `db:"jurisdiction"`
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en"`
|
||||
}
|
||||
err = s.rules.db.GetContext(ctx, &pt,
|
||||
`SELECT id, code, name, name_en, jurisdiction
|
||||
`SELECT id, code, name, name_en, jurisdiction,
|
||||
trigger_event_label_de, trigger_event_label_en
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true`, proceedingCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -271,7 +284,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
hasSubTrackNote = true
|
||||
// Re-resolve to the parent proceeding for rule lookup.
|
||||
err = s.rules.db.GetContext(ctx, &pt,
|
||||
`SELECT id, code, name, name_en, jurisdiction
|
||||
`SELECT id, code, name, name_en, jurisdiction,
|
||||
trigger_event_label_de, trigger_event_label_en
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true`, route.ParentCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -604,6 +618,17 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
}
|
||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||
// so the trigger-event label rides on `pickedProceeding` (e.g.
|
||||
// upc.ccr.cfi inherits whatever upc.inf.cfi's caption is, not
|
||||
// upc.ccr.cfi's own — which is fine: the sub-track note already
|
||||
// explains the framing).
|
||||
if pickedProceeding.TriggerEventLabelDE != nil {
|
||||
resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE
|
||||
}
|
||||
if pickedProceeding.TriggerEventLabelEN != nil {
|
||||
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
|
||||
}
|
||||
if hasSubTrackNote {
|
||||
resp.ContextualNote = subTrackNote.NoteDE
|
||||
resp.ContextualNoteEN = subTrackNote.NoteEN
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
570
internal/services/submission_draft_service.go
Normal file
570
internal/services/submission_draft_service.go
Normal file
@@ -0,0 +1,570 @@
|
||||
package services
|
||||
|
||||
// Submission draft service — CRUD over paliad.submission_drafts plus
|
||||
// the render+export entry points that combine the variable bag, lawyer
|
||||
// overrides, and template fetch into a .docx or HTML preview
|
||||
// (t-paliad-238 Slice A, design doc
|
||||
// docs/design-submission-page-2026-05-22.md §5.2).
|
||||
//
|
||||
// Each draft is owned by one user; multiple drafts per (project,
|
||||
// submission_code, user_id) are supported via the `name` column. The
|
||||
// override semantics are explicit:
|
||||
//
|
||||
// variables = {"project.case_number": "2 O 999/25"} → use this value
|
||||
// variables = {"project.case_number": ""} → force [KEIN WERT: …]
|
||||
// key absent → fall back to bag
|
||||
//
|
||||
// Visibility flows through ProjectService.GetByID — every read and
|
||||
// write gates on paliad.can_see_project. RLS in the DB enforces the
|
||||
// owner-scoped UPDATE/DELETE constraint independently of the Go layer.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// SubmissionDraft mirrors a row in paliad.submission_drafts.
|
||||
//
|
||||
// ProjectID is nullable since t-paliad-243 — a draft started from the
|
||||
// global /submissions/new picker without picking a project is private
|
||||
// to its creator and carries an empty variable bag (no project /
|
||||
// parties / deadline state to resolve). All callers must check for nil
|
||||
// before treating it as a uuid.
|
||||
type SubmissionDraft struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
SubmissionCode string `db:"submission_code" json:"submission_code"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
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"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// Variables is the decoded overrides map; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
Variables PlaceholderMap `json:"variables"`
|
||||
}
|
||||
|
||||
// SubmissionDraftService handles CRUD on submission_drafts and exposes
|
||||
// the render/preview/export entry points the handler layer calls.
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
vars *SubmissionVarsService
|
||||
renderer *SubmissionRenderer
|
||||
}
|
||||
|
||||
// NewSubmissionDraftService wires the service.
|
||||
func NewSubmissionDraftService(db *sqlx.DB, projects *ProjectService, vars *SubmissionVarsService, renderer *SubmissionRenderer) *SubmissionDraftService {
|
||||
return &SubmissionDraftService{
|
||||
db: db,
|
||||
projects: projects,
|
||||
vars: vars,
|
||||
renderer: renderer,
|
||||
}
|
||||
}
|
||||
|
||||
// DraftPatch carries optional fields for Update. nil pointer = "no
|
||||
// change"; non-nil = "set to this". Variables is replace-semantics —
|
||||
// the lawyer's sidebar sends the full map every save.
|
||||
//
|
||||
// ProjectID uses a two-level pointer (t-paliad-243) so we can encode
|
||||
// the three operations the global drafts flow needs:
|
||||
//
|
||||
// patch.ProjectID == nil → no change
|
||||
// *patch.ProjectID == nil → detach (re-set to NULL)
|
||||
// **patch.ProjectID → attach (assign a project)
|
||||
//
|
||||
// The detach path stays as scope for symmetry with attach even though
|
||||
// the current frontend only exposes attach.
|
||||
type DraftPatch struct {
|
||||
Name *string
|
||||
Variables *PlaceholderMap
|
||||
ProjectID **uuid.UUID
|
||||
}
|
||||
|
||||
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
|
||||
// visible to this user". Maps to 404 in the handler.
|
||||
var ErrSubmissionDraftNotFound = errors.New("submission draft: not found")
|
||||
|
||||
// ErrSubmissionDraftNameTaken is the sentinel for duplicate names per
|
||||
// (project, submission_code, user). Maps to 409 in the handler.
|
||||
var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already taken")
|
||||
|
||||
// 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,
|
||||
variables, last_exported_at, last_exported_sha,
|
||||
created_at, updated_at`
|
||||
|
||||
// List returns every draft for (project, submission_code, user)
|
||||
// ordered by updated_at DESC. Visibility flows through projects.GetByID.
|
||||
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []SubmissionDraft
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+draftColumns+`
|
||||
FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
|
||||
ORDER BY updated_at DESC`,
|
||||
projectID, submissionCode, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list submission drafts: %w", err)
|
||||
}
|
||||
for i := range rows {
|
||||
if err := rows[i].decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DraftWithProject is the row shape for the global /submissions index —
|
||||
// a draft joined with the minimal project metadata the table needs.
|
||||
// Visibility is gated by paliad.can_see_project in the SELECT itself.
|
||||
//
|
||||
// ProjectTitle / ProjectReference are pointer-nullable since
|
||||
// t-paliad-243 — project-less drafts surface in the same list with a
|
||||
// NULL project ref, and the frontend renders them with a dedicated
|
||||
// "kein Projekt" label.
|
||||
type DraftWithProject struct {
|
||||
SubmissionDraft
|
||||
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
|
||||
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
||||
}
|
||||
|
||||
// ListAllForUser returns every draft the user owns across visible
|
||||
// projects PLUS every project-less draft the user owns, ordered by
|
||||
// updated_at DESC. LEFT JOIN on paliad.projects keeps project-less rows
|
||||
// in the result set; the WHERE clause permits project_id IS NULL or a
|
||||
// visible can_see_project hit, so a draft on a project the user no
|
||||
// longer has access to is silently dropped.
|
||||
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,
|
||||
d.variables, d.last_exported_at, d.last_exported_sha,
|
||||
d.created_at, d.updated_at,
|
||||
p.title AS project_title,
|
||||
p.reference AS project_reference
|
||||
FROM paliad.submission_drafts d
|
||||
LEFT JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE d.user_id = $1
|
||||
AND (
|
||||
d.project_id IS NULL
|
||||
OR paliad.can_see_project(d.project_id)
|
||||
)
|
||||
ORDER BY d.updated_at DESC`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list all submission drafts for user: %w", err)
|
||||
}
|
||||
for i := range rows {
|
||||
if err := rows[i].decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Get returns a single draft by id, gated on project visibility AND
|
||||
// owner-only — the caller can only fetch drafts they own. RLS in the
|
||||
// DB enforces this independently; the Go check makes the 404 semantics
|
||||
// explicit at the service boundary.
|
||||
//
|
||||
// A project-less draft (ProjectID == nil) skips the can_see_project
|
||||
// gate — the owner-only constraint is the entire visibility check.
|
||||
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error) {
|
||||
var d SubmissionDraft
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT `+draftColumns+`
|
||||
FROM paliad.submission_drafts
|
||||
WHERE id = $1 AND user_id = $2`,
|
||||
draftID, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionDraftNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission draft: %w", err)
|
||||
}
|
||||
if d.ProjectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *d.ProjectID); err != nil {
|
||||
// Project no longer visible → behave as not-found rather than
|
||||
// leaking the draft's existence. ON DELETE CASCADE keeps this
|
||||
// rare in practice.
|
||||
if errors.Is(err, ErrNotVisible) {
|
||||
return nil, ErrSubmissionDraftNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// EnsureLatest returns the user's most-recently-updated draft for
|
||||
// (project, submission_code). Creates "Entwurf 1" / "Draft 1" if none
|
||||
// exists. Idempotent on repeat calls — once a draft exists, EnsureLatest
|
||||
// always returns the freshest one rather than spawning new rows.
|
||||
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var d SubmissionDraft
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT `+draftColumns+`
|
||||
FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`,
|
||||
projectID, submissionCode, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return s.Create(ctx, userID, &projectID, submissionCode, lang)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure latest submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// Create makes a new draft with an auto-incremented "Entwurf N" name
|
||||
// ("Draft N" for English locale). Lawyer can rename via Update.
|
||||
//
|
||||
// A nil projectID creates a project-less draft (t-paliad-243); the
|
||||
// visibility check is skipped — the caller is the owner and the row is
|
||||
// private to them.
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
if projectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
name, err := s.nextDraftName(ctx, projectID, submissionCode, userID, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
RETURNING `+draftColumns,
|
||||
projectID, submissionCode, userID, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
|
||||
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
|
||||
// suffix if two callers race; the unique constraint on the table is
|
||||
// the final guard.
|
||||
//
|
||||
// A nil projectID scopes the search to the user's project-less drafts
|
||||
// for this submission_code — matches the row-uniqueness contract on
|
||||
// the DB side (project_id, submission_code, user_id, name) where
|
||||
// project_id IS NULL is its own equivalence class.
|
||||
func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID, lang string) (string, error) {
|
||||
prefix := "Entwurf"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "Draft"
|
||||
}
|
||||
var names []string
|
||||
var err error
|
||||
if projectID == nil {
|
||||
err = s.db.SelectContext(ctx, &names,
|
||||
`SELECT name FROM paliad.submission_drafts
|
||||
WHERE project_id IS NULL AND submission_code = $1 AND user_id = $2`,
|
||||
submissionCode, userID)
|
||||
} else {
|
||||
err = s.db.SelectContext(ctx, &names,
|
||||
`SELECT name FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3`,
|
||||
*projectID, submissionCode, userID)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("scan existing draft names: %w", err)
|
||||
}
|
||||
highest := 0
|
||||
for _, n := range names {
|
||||
var idx int
|
||||
if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
|
||||
highest = idx
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s %d", prefix, highest+1), nil
|
||||
}
|
||||
|
||||
// Update patches the draft. Variables is replace-semantics — pass the
|
||||
// full map. Name patches go through a uniqueness check to surface
|
||||
// ErrSubmissionDraftNameTaken cleanly instead of a raw constraint
|
||||
// violation.
|
||||
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error) {
|
||||
existing, err := s.Get(ctx, userID, draftID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setParts := []string{}
|
||||
args := []any{}
|
||||
idx := 1
|
||||
|
||||
if patch.Name != nil {
|
||||
newName := strings.TrimSpace(*patch.Name)
|
||||
if newName == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
if newName != existing.Name {
|
||||
// Pre-check for the unique constraint so we can return a
|
||||
// typed error instead of a raw PG conflict. NULL project_id
|
||||
// is its own equivalence class in the unique index (NULLs
|
||||
// don't collide), so the no-project flow checks `IS NULL`.
|
||||
var dup int
|
||||
var qErr error
|
||||
if existing.ProjectID == nil {
|
||||
qErr = s.db.GetContext(ctx, &dup,
|
||||
`SELECT COUNT(*) FROM paliad.submission_drafts
|
||||
WHERE project_id IS NULL AND submission_code = $1
|
||||
AND user_id = $2 AND name = $3 AND id <> $4`,
|
||||
existing.SubmissionCode, userID, newName, draftID)
|
||||
} else {
|
||||
qErr = s.db.GetContext(ctx, &dup,
|
||||
`SELECT COUNT(*) FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2
|
||||
AND user_id = $3 AND name = $4 AND id <> $5`,
|
||||
*existing.ProjectID, existing.SubmissionCode, userID, newName, draftID)
|
||||
}
|
||||
if qErr != nil {
|
||||
return nil, fmt.Errorf("check name uniqueness: %w", qErr)
|
||||
}
|
||||
if dup > 0 {
|
||||
return nil, ErrSubmissionDraftNameTaken
|
||||
}
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("name = $%d", idx))
|
||||
args = append(args, newName)
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.Variables != nil {
|
||||
raw, err := json.Marshal(*patch.Variables)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal variables: %w", err)
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("variables = $%d::jsonb", idx))
|
||||
args = append(args, string(raw))
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.ProjectID != nil {
|
||||
newPID := *patch.ProjectID // *uuid.UUID — nil means detach
|
||||
if newPID != nil {
|
||||
// Caller must be able to see the project they're attaching
|
||||
// the draft to; same gate as Create.
|
||||
if _, err := s.projects.GetByID(ctx, userID, *newPID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("project_id = $%d", idx))
|
||||
args = append(args, newPID)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
args = append(args, draftID, userID)
|
||||
q := fmt.Sprintf(
|
||||
`UPDATE paliad.submission_drafts
|
||||
SET %s
|
||||
WHERE id = $%d AND user_id = $%d
|
||||
RETURNING %s`,
|
||||
strings.Join(setParts, ", "), idx, idx+1, draftColumns,
|
||||
)
|
||||
|
||||
var d SubmissionDraft
|
||||
err = s.db.GetContext(ctx, &d, q, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionDraftNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// Delete removes the draft. Visibility-gated via Get; the DELETE itself
|
||||
// is owner-scoped (user_id = caller).
|
||||
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error {
|
||||
if _, err := s.Get(ctx, userID, draftID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.submission_drafts WHERE id = $1 AND user_id = $2`,
|
||||
draftID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete submission draft: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkExported updates the last_exported_* columns after a successful
|
||||
// export. Background-context safe.
|
||||
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, templateSHA string) error {
|
||||
var sha any
|
||||
if templateSHA != "" {
|
||||
sha = templateSHA
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.submission_drafts
|
||||
SET last_exported_at = now(),
|
||||
last_exported_sha = $1
|
||||
WHERE id = $2`,
|
||||
sha, draftID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark submission draft exported: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildRenderBag composes the placeholder map for a draft — pulls
|
||||
// project/parties/rule/deadline state from SubmissionVarsService, then
|
||||
// layers the lawyer's overrides on top.
|
||||
//
|
||||
// Override semantics:
|
||||
//
|
||||
// variables[key] = "" → delete the key (force [KEIN WERT: key])
|
||||
// variables[key] = "X" → bag[key] = "X"
|
||||
// key absent → bag[key] unchanged (falls back to resolved value)
|
||||
//
|
||||
// Returns the final PlaceholderMap along with the SubmissionVarsResult
|
||||
// so callers (export, file naming) get the resolved entities too. A
|
||||
// project-less draft (ProjectID == nil, t-paliad-243) skips project /
|
||||
// parties / deadline lookups — the resolved bag carries only the
|
||||
// user-independent variables (firm, today) plus the user.* group; the
|
||||
// lawyer's overrides fill the rest.
|
||||
func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *SubmissionDraft) (PlaceholderMap, *SubmissionVarsResult, error) {
|
||||
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
|
||||
UserID: draft.UserID,
|
||||
ProjectID: draft.ProjectID,
|
||||
SubmissionCode: draft.SubmissionCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
maps.Copy(bag, resolved.Placeholders)
|
||||
for k, v := range draft.Variables {
|
||||
if v == "" {
|
||||
delete(bag, k)
|
||||
continue
|
||||
}
|
||||
bag[k] = v
|
||||
}
|
||||
return bag, resolved, nil
|
||||
}
|
||||
|
||||
// RenderPreview returns the HTML preview of the merged document body
|
||||
// for the draft-editor preview pane. Read-only; emits one <p> per <w:p>
|
||||
// with <strong>/<em> spans for runs flagged bold/italic.
|
||||
func (s *SubmissionDraftService) RenderPreview(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) (string, error) {
|
||||
bag, resolved, err := s.BuildRenderBag(ctx, draft)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.renderer.RenderHTML(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
|
||||
}
|
||||
|
||||
// Export renders the merged .docx for download. Returns the bytes, the
|
||||
// resolved bag (for audit row + file naming), and the variables result
|
||||
// (lang, rule.Name, project.case_number). Callers wire MarkExported and
|
||||
// the audit writes.
|
||||
func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
|
||||
bag, resolved, err := s.BuildRenderBag(ctx, draft)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out, err := s.renderer.Render(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, resolved, nil
|
||||
}
|
||||
|
||||
// RenderProjectSubmission renders the given .docx template with a fresh
|
||||
// variable bag for (user, project, submissionCode). No lawyer overrides
|
||||
// — the output reflects exactly what SubmissionVarsService resolves
|
||||
// from project state. Used by the one-click /api/projects/{id}/
|
||||
// submissions/{code}/generate path which has no saved draft row.
|
||||
//
|
||||
// Returns the merged bytes plus the resolved bag (for audit row + file
|
||||
// naming). Visibility is enforced by SubmissionVarsService.Build via
|
||||
// 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) {
|
||||
pid := projectID
|
||||
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
|
||||
UserID: userID,
|
||||
ProjectID: &pid,
|
||||
SubmissionCode: submissionCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out, err := s.renderer.Render(templateBytes, resolved.Placeholders, DefaultMissingMarker(resolved.Lang))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, resolved, nil
|
||||
}
|
||||
|
||||
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
|
||||
// Called by every fetch path so the caller sees a populated Variables.
|
||||
func (d *SubmissionDraft) decodeVariables() error {
|
||||
if len(d.VariablesRaw) == 0 {
|
||||
d.Variables = PlaceholderMap{}
|
||||
return nil
|
||||
}
|
||||
out := PlaceholderMap{}
|
||||
if err := json.Unmarshal(d.VariablesRaw, &out); err != nil {
|
||||
return fmt.Errorf("decode submission draft variables: %w", err)
|
||||
}
|
||||
d.Variables = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 —
|
||||
// but the import keeps the package compile-time-aware of the dependency
|
||||
// chain that wires us into the bundle.
|
||||
var _ = (*models.User)(nil)
|
||||
531
internal/services/submission_merge.go
Normal file
531
internal/services/submission_merge.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package services
|
||||
|
||||
// Submission template renderer — in-house engine for the submission
|
||||
// draft editor (t-paliad-238, design doc
|
||||
// docs/design-submission-page-2026-05-22.md §3 / §6.2).
|
||||
//
|
||||
// Resurrected from commit 8ea3509 (the original t-paliad-215 Slice 1
|
||||
// "in-house .docx render engine"). Kept in a separate file from the
|
||||
// format-only converter (submission_render.go) so the t-paliad-230
|
||||
// /generate one-click path stays unchanged and the merge engine doesn't
|
||||
// have to share zip-helper names with it.
|
||||
//
|
||||
// Why not lukasjarosch/go-docx: the library's "nested placeholder" guard
|
||||
// treats sibling placeholders inside the same <w:t> run (e.g.
|
||||
// "{{a}} ./. {{b}}") as nested and refuses to replace either. Patent
|
||||
// submissions routinely have multiple placeholders per paragraph (party
|
||||
// blocks especially), so the library is a non-starter. This renderer
|
||||
// handles single-run placeholders (preserving run-level formatting) AND
|
||||
// cross-run placeholders (rewriting the paragraph as one run when Word
|
||||
// has fragmented the placeholder across runs).
|
||||
//
|
||||
// Placeholder grammar: {{[A-Za-z][A-Za-z0-9_.]*}} with optional
|
||||
// whitespace inside braces ({{ project.case_number }} ≡
|
||||
// {{project.case_number}}).
|
||||
//
|
||||
// Missing-value behaviour: when a placeholder has no binding in the
|
||||
// PlaceholderMap, the renderer emits a marker token so the lawyer sees
|
||||
// the gap in Word rather than failing the request.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlaceholderMap is the variable bag built by SubmissionVarsService.
|
||||
// Keys are dotted paths without braces (e.g. "project.case_number").
|
||||
// Values are the substituted text — already locale-aware, pretty-
|
||||
// printed, and sanitised by the caller.
|
||||
type PlaceholderMap map[string]string
|
||||
|
||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||
// in-document marker token. The default in DefaultMissingMarker is
|
||||
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
|
||||
type MissingPlaceholderFn func(key string) string
|
||||
|
||||
// valueWrapperFn wraps a substituted value with a marker the HTML
|
||||
// preview emitter can recognise — used by RenderHTML to turn each
|
||||
// substituted value into a clickable <span class="draft-var" …>
|
||||
// (t-paliad-261, click-variable-in-preview → jump-to-field). nil means
|
||||
// no wrapping; the .docx export path uses nil so its output is
|
||||
// byte-identical to the wrapper-free build. The wrapper is invoked for
|
||||
// both resolved values and missing-marker text so clicking a missing
|
||||
// placeholder still jumps to the corresponding sidebar input.
|
||||
type valueWrapperFn func(key, value string) string
|
||||
|
||||
// Private-Use-Area sentinels for the HTML preview wrap. PUA characters
|
||||
// are valid in XML 1.0 content, never appear in legitimate template
|
||||
// text, pass unchanged through xmlEncode/xmlDecode/htmlEscape, and are
|
||||
// stripped by emitTextWithDraftVars when the preview HTML is assembled.
|
||||
const (
|
||||
previewVarBegin = ""
|
||||
previewVarMid = ""
|
||||
previewVarEnd = ""
|
||||
)
|
||||
|
||||
// htmlPreviewWrapper wraps a substituted value with the PUA sentinels
|
||||
// emitTextWithDraftVars recognises. Used only by RenderHTML; the .docx
|
||||
// Render path uses nil so its output is identical to the pre-261 build.
|
||||
func htmlPreviewWrapper(key, value string) string {
|
||||
return previewVarBegin + key + previewVarMid + value + previewVarEnd
|
||||
}
|
||||
|
||||
// DefaultMissingMarker returns the standard missing-value marker for
|
||||
// the given UI language.
|
||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
prefix := "KEIN WERT"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "NO VALUE"
|
||||
}
|
||||
return func(key string) string {
|
||||
return "[" + prefix + ": " + key + "]"
|
||||
}
|
||||
}
|
||||
|
||||
// placeholderRegex matches a single placeholder. The capture group
|
||||
// extracts the key name without braces or surrounding whitespace.
|
||||
//
|
||||
// Restricted to [A-Za-z][A-Za-z0-9_.]* so that stray "{{" sequences in
|
||||
// legal prose don't get mistaken for placeholders. A genuine placeholder
|
||||
// always starts with an ASCII letter.
|
||||
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
|
||||
|
||||
// SubmissionRenderer renders a .docx template into a .docx output by
|
||||
// substituting {{placeholder}} tokens with values from a PlaceholderMap.
|
||||
// Stateless; safe for concurrent use.
|
||||
type SubmissionRenderer struct{}
|
||||
|
||||
// NewSubmissionRenderer constructs the renderer.
|
||||
func NewSubmissionRenderer() *SubmissionRenderer {
|
||||
return &SubmissionRenderer{}
|
||||
}
|
||||
|
||||
// Render reads the .docx template at templateBytes, substitutes every
|
||||
// placeholder from vars (or emits the missing-marker token), and returns
|
||||
// the merged .docx bytes. Unknown placeholders never fail the render —
|
||||
// the lawyer sees the marker in Word and fixes it.
|
||||
//
|
||||
// Pre-pass: ConvertDotmToDocx is called on the input so a .dotm
|
||||
// template (macro-bearing) is downgraded to a plain .docx before the
|
||||
// merge step runs. Idempotent on inputs that are already plain .docx.
|
||||
func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) ([]byte, error) {
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker("de")
|
||||
}
|
||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: pre-pass convert: %w", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: open zip: %w", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
|
||||
for _, entry := range zr.File {
|
||||
body, err := readMergeZipEntry(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: read %s: %w", entry.Name, err)
|
||||
}
|
||||
if isWordXMLEntry(entry.Name) {
|
||||
body = substituteInDocumentXML(body, vars, missing, nil)
|
||||
}
|
||||
w, err := zw.CreateHeader(&zip.FileHeader{
|
||||
Name: entry.Name,
|
||||
Method: entry.Method,
|
||||
Modified: entry.Modified,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: write header %s: %w", entry.Name, err)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission render: write %s: %w", entry.Name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission render: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// RenderHTML produces a read-only HTML rendering of the merged document
|
||||
// body for the draft-editor preview pane. Walks the SAME placeholder
|
||||
// substitution as Render, then extracts the body text from word/document.xml
|
||||
// and emits semantic HTML — one <p> per <w:p>, with <strong>/<em> spans
|
||||
// for runs that carry <w:b>/<w:i> formatting. Tables, lists, and complex
|
||||
// formatting collapse to plain paragraphs (the preview is a fidelity
|
||||
// guide, not a WYSIWYG editor — final formatting comes from Word at
|
||||
// export).
|
||||
//
|
||||
// Returns escaped HTML safe to inject into the page via dangerouslySet
|
||||
// or innerHTML. The caller is responsible for wrapping in an outer
|
||||
// container; this method emits only the body fragment.
|
||||
func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) (string, error) {
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker("de")
|
||||
}
|
||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("submission render html: pre-pass convert: %w", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("submission render html: open zip: %w", err)
|
||||
}
|
||||
var docXML []byte
|
||||
for _, entry := range zr.File {
|
||||
if entry.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
docXML, err = readMergeZipEntry(entry)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("submission render html: read document.xml: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
if docXML == nil {
|
||||
return "", fmt.Errorf("submission render html: word/document.xml missing")
|
||||
}
|
||||
merged := substituteInDocumentXML(docXML, vars, missing, htmlPreviewWrapper)
|
||||
return docXMLToHTML(merged), nil
|
||||
}
|
||||
|
||||
// isWordXMLEntry returns true for the .docx parts that contain
|
||||
// substitutable text. We touch document.xml plus header*.xml and
|
||||
// footer*.xml (templates may put firm letterhead in a header) but
|
||||
// skip styles, theme, settings, comments, footnotes — none of which
|
||||
// should carry merge placeholders in a well-formed template.
|
||||
func isWordXMLEntry(name string) bool {
|
||||
switch {
|
||||
case name == "word/document.xml":
|
||||
return true
|
||||
case strings.HasPrefix(name, "word/header") && strings.HasSuffix(name, ".xml"):
|
||||
return true
|
||||
case strings.HasPrefix(name, "word/footer") && strings.HasSuffix(name, ".xml"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readMergeZipEntry slurps a zip entry's bytes. Named distinctly from
|
||||
// the helper in submission_render.go (readZipFile) to keep this file
|
||||
// self-contained — the two are functionally identical.
|
||||
func readMergeZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// substituteInDocumentXML walks document XML and replaces every
|
||||
// {{placeholder}} occurrence inside <w:t> text nodes. Handles both
|
||||
// single-run placeholders (the common case for freshly authored
|
||||
// templates) and cross-run placeholders (where Word's autocorrect or
|
||||
// manual editing has split a placeholder across runs).
|
||||
//
|
||||
// Two-pass strategy:
|
||||
//
|
||||
// 1. Pass 1: replace placeholders that fit entirely within one
|
||||
// <w:t>…</w:t>. This is the 99% case and preserves all run-level
|
||||
// formatting (bold, italic, font runs).
|
||||
// 2. Pass 2: for paragraphs that still contain orphan "{{" or "}}"
|
||||
// markers after pass 1, merge the text of every <w:t> inside the
|
||||
// paragraph, run the replacement on the merged text, and rewrite
|
||||
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
|
||||
// the formatting properties of the first run.
|
||||
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||
replaced := substituteInTextNodes(body, vars, missing, wrap)
|
||||
if !needsCrossRunMerge(replaced) {
|
||||
return replaced
|
||||
}
|
||||
return substituteAcrossRuns(replaced, vars, missing, wrap)
|
||||
}
|
||||
|
||||
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
|
||||
// the contents.
|
||||
var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
|
||||
|
||||
// substituteInTextNodes runs the placeholder replacement inside each
|
||||
// <w:t> text node independently. Format-preserving for single-run
|
||||
// placeholders.
|
||||
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
|
||||
sub := wTextNodeRegex.FindSubmatch(match)
|
||||
attrs := string(sub[1])
|
||||
contents := xmlDecode(string(sub[2]))
|
||||
replaced := replacePlaceholders(contents, vars, missing, wrap)
|
||||
if replaced == contents {
|
||||
return match
|
||||
}
|
||||
if !strings.Contains(attrs, "xml:space") &&
|
||||
(strings.HasPrefix(replaced, " ") || strings.HasSuffix(replaced, " ")) {
|
||||
attrs += ` xml:space="preserve"`
|
||||
}
|
||||
return []byte(`<w:t` + attrs + `>` + xmlEncode(replaced) + `</w:t>`)
|
||||
})
|
||||
}
|
||||
|
||||
// needsCrossRunMerge returns true when the body still contains an
|
||||
// unmatched "{{" or "}}" inside any <w:t> after pass 1.
|
||||
func needsCrossRunMerge(body []byte) bool {
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(body, -1) {
|
||||
t := string(m[2])
|
||||
if strings.Contains(t, "{{") || strings.Contains(t, "}}") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// wParagraphRegex matches one <w:p>…</w:p> paragraph block. Greedy
|
||||
// inner-content match is safe — <w:p> elements do not nest.
|
||||
var wParagraphRegex = regexp.MustCompile(`(?s)<w:p\b[^>]*>.*?</w:p>`)
|
||||
|
||||
// wRunPropsRegex pulls the first <w:rPr>…</w:rPr> block from a paragraph.
|
||||
var wRunPropsRegex = regexp.MustCompile(`(?s)<w:rPr>.*?</w:rPr>`)
|
||||
|
||||
// wParagraphPropsRegex pulls the optional <w:pPr>…</w:pPr>.
|
||||
var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
|
||||
|
||||
// substituteAcrossRuns is pass 2: concatenate every text node in a
|
||||
// fragmented-placeholder paragraph, run replacement, rewrite as one run.
|
||||
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
|
||||
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
|
||||
if len(textNodes) == 0 {
|
||||
return para
|
||||
}
|
||||
var merged strings.Builder
|
||||
for _, m := range textNodes {
|
||||
merged.WriteString(xmlDecode(string(m[2])))
|
||||
}
|
||||
original := merged.String()
|
||||
if !strings.Contains(original, "{{") {
|
||||
return para
|
||||
}
|
||||
replaced := replacePlaceholders(original, vars, missing, wrap)
|
||||
if replaced == original {
|
||||
return para
|
||||
}
|
||||
pPr := wParagraphPropsRegex.Find(para)
|
||||
rPr := wRunPropsRegex.Find(para)
|
||||
var rebuilt bytes.Buffer
|
||||
rebuilt.WriteString(`<w:p>`)
|
||||
if pPr != nil {
|
||||
rebuilt.Write(pPr)
|
||||
}
|
||||
rebuilt.WriteString(`<w:r>`)
|
||||
if rPr != nil {
|
||||
rebuilt.Write(rPr)
|
||||
}
|
||||
rebuilt.WriteString(`<w:t xml:space="preserve">`)
|
||||
rebuilt.WriteString(xmlEncode(replaced))
|
||||
rebuilt.WriteString(`</w:t></w:r></w:p>`)
|
||||
return rebuilt.Bytes()
|
||||
})
|
||||
}
|
||||
|
||||
// replacePlaceholders performs the actual substitution on a plain
|
||||
// string. Unbound placeholders render the missing marker. When wrap is
|
||||
// non-nil, both the resolved value AND the missing-marker text are
|
||||
// passed through wrap(key, value) — the HTML preview path uses this to
|
||||
// emit clickable spans around every substituted placeholder, including
|
||||
// missing ones (clicking a missing marker jumps to the corresponding
|
||||
// sidebar input).
|
||||
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) string {
|
||||
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
|
||||
sub := placeholderRegex.FindStringSubmatch(match)
|
||||
if len(sub) < 2 {
|
||||
return match
|
||||
}
|
||||
key := sub[1]
|
||||
var value string
|
||||
if v, ok := vars[key]; ok {
|
||||
value = v
|
||||
} else {
|
||||
value = missing(key)
|
||||
}
|
||||
if wrap != nil {
|
||||
return wrap(key, value)
|
||||
}
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
// xmlDecode reverses the five standard XML entities Word emits in
|
||||
// <w:t> content.
|
||||
func xmlDecode(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
|
||||
}
|
||||
|
||||
// xmlEncode escapes for safe insertion back into <w:t> content. & first
|
||||
// to avoid double-encoding the entity prefixes.
|
||||
func xmlEncode(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
|
||||
}
|
||||
|
||||
// docXMLToHTML walks the post-merge document XML and emits HTML for
|
||||
// the preview pane. One <p> per <w:p>; <strong>/<em> spans for runs
|
||||
// carrying <w:b>/<w:i>. Tables/lists/images collapse to text. Output
|
||||
// is HTML-escaped except for the structural <p>/<strong>/<em> tags
|
||||
// this function emits.
|
||||
func docXMLToHTML(docXML []byte) string {
|
||||
paragraphs := wParagraphRegex.FindAll(docXML, -1)
|
||||
var out bytes.Buffer
|
||||
for _, para := range paragraphs {
|
||||
out.WriteString("<p>")
|
||||
out.WriteString(paragraphToHTML(para))
|
||||
out.WriteString("</p>\n")
|
||||
}
|
||||
if out.Len() == 0 {
|
||||
return "<p></p>"
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// wRunRegex matches one <w:r>…</w:r> run. Greedy match safe — <w:r>
|
||||
// elements do not nest.
|
||||
var wRunRegex = regexp.MustCompile(`(?s)<w:r\b[^>]*>.*?</w:r>`)
|
||||
|
||||
// wBoldRegex / wItalicRegex detect the bold/italic flags inside a run's
|
||||
// <w:rPr>. Word emits <w:b/> or <w:b w:val="true"/>; matching the open
|
||||
// tag covers both forms.
|
||||
var (
|
||||
wBoldRegex = regexp.MustCompile(`<w:b\b[^>]*/?>`)
|
||||
wItalicRegex = regexp.MustCompile(`<w:i\b[^>]*/?>`)
|
||||
)
|
||||
|
||||
// paragraphToHTML extracts the text from each <w:r> inside a paragraph,
|
||||
// wraps runs flagged bold/italic with the corresponding HTML tags, and
|
||||
// HTML-escapes the text content.
|
||||
func paragraphToHTML(para []byte) string {
|
||||
runs := wRunRegex.FindAll(para, -1)
|
||||
if len(runs) == 0 {
|
||||
// Empty paragraph (line break).
|
||||
return ""
|
||||
}
|
||||
var out bytes.Buffer
|
||||
for _, run := range runs {
|
||||
text := extractRunText(run)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
// Check for bold/italic on the run's <w:rPr>.
|
||||
rPr := wRunPropsRegex.Find(run)
|
||||
bold := rPr != nil && wBoldRegex.Match(rPr) && !isFalseFlag(rPr, wBoldRegex)
|
||||
italic := rPr != nil && wItalicRegex.Match(rPr) && !isFalseFlag(rPr, wItalicRegex)
|
||||
|
||||
if bold {
|
||||
out.WriteString("<strong>")
|
||||
}
|
||||
if italic {
|
||||
out.WriteString("<em>")
|
||||
}
|
||||
out.WriteString(emitTextWithDraftVars(text))
|
||||
if italic {
|
||||
out.WriteString("</em>")
|
||||
}
|
||||
if bold {
|
||||
out.WriteString("</strong>")
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// emitTextWithDraftVars HTML-escapes run text while converting any
|
||||
// preview-only sentinels emitted by htmlPreviewWrapper into
|
||||
// <span class="draft-var" data-var="<key>">…</span>. The key is
|
||||
// restricted to [A-Za-z][A-Za-z0-9_.]* by placeholderRegex, so no
|
||||
// attribute-escaping is needed on the key; the value is HTML-escaped
|
||||
// normally. Sentinel-free text (the Render path output, or template
|
||||
// text outside placeholders) is passed straight through htmlEscape, so
|
||||
// callers that never invoked wrap see byte-identical HTML.
|
||||
//
|
||||
// t-paliad-261: makes substituted variables clickable in the preview
|
||||
// pane so the user can jump to the matching input in the sidebar.
|
||||
func emitTextWithDraftVars(text string) string {
|
||||
if !strings.Contains(text, previewVarBegin) {
|
||||
return htmlEscape(text)
|
||||
}
|
||||
var out strings.Builder
|
||||
rest := text
|
||||
for {
|
||||
i := strings.Index(rest, previewVarBegin)
|
||||
if i < 0 {
|
||||
out.WriteString(htmlEscape(rest))
|
||||
return out.String()
|
||||
}
|
||||
out.WriteString(htmlEscape(rest[:i]))
|
||||
body := rest[i+len(previewVarBegin):]
|
||||
mid := strings.Index(body, previewVarMid)
|
||||
end := strings.Index(body, previewVarEnd)
|
||||
if mid < 0 || end < 0 || mid > end {
|
||||
// Malformed sentinel — emit the marker as plain escaped
|
||||
// text and continue past it so the rest of the run still
|
||||
// renders.
|
||||
out.WriteString(htmlEscape(previewVarBegin))
|
||||
rest = body
|
||||
continue
|
||||
}
|
||||
key := body[:mid]
|
||||
value := body[mid+len(previewVarMid) : end]
|
||||
out.WriteString(`<span class="draft-var" data-var="`)
|
||||
out.WriteString(key)
|
||||
out.WriteString(`">`)
|
||||
out.WriteString(htmlEscape(value))
|
||||
out.WriteString(`</span>`)
|
||||
rest = body[end+len(previewVarEnd):]
|
||||
}
|
||||
}
|
||||
|
||||
// extractRunText concatenates every <w:t> inside a run, XML-decoding
|
||||
// the content as it goes.
|
||||
func extractRunText(run []byte) string {
|
||||
var out strings.Builder
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(run, -1) {
|
||||
out.WriteString(xmlDecode(string(m[2])))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// isFalseFlag returns true if the matched tag explicitly carries
|
||||
// w:val="false" or w:val="0" — Word's way of turning off an inherited
|
||||
// format. The default match (just `<w:b/>` or `<w:b w:val="true"/>`)
|
||||
// is "on".
|
||||
func isFalseFlag(rPr []byte, rx *regexp.Regexp) bool {
|
||||
match := rx.Find(rPr)
|
||||
if match == nil {
|
||||
return false
|
||||
}
|
||||
s := string(match)
|
||||
return strings.Contains(s, `w:val="false"`) || strings.Contains(s, `w:val="0"`)
|
||||
}
|
||||
|
||||
// htmlEscape escapes the five HTML-significant characters for safe
|
||||
// insertion into the preview pane.
|
||||
func htmlEscape(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
|
||||
}
|
||||
353
internal/services/submission_merge_test.go
Normal file
353
internal/services/submission_merge_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package services
|
||||
|
||||
// Submission merge-engine tests — resurrected from the original
|
||||
// t-paliad-215 Slice 1 (commit 8ea3509) + Slice 2 (commit 1765d5e).
|
||||
// Adapted: helper names suffixed with "Merge" so they don't collide
|
||||
// with the convert tests in submission_render_test.go (minimalDOTM,
|
||||
// unzipEntries) that test the format-only ConvertDotmToDocx path.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minimalMergeDOCX builds a tiny .docx zip with one document.xml that
|
||||
// contains the given body. Just enough to exercise the merge engine.
|
||||
func minimalMergeDOCX(t *testing.T, documentBody string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create document.xml: %v", err)
|
||||
}
|
||||
if _, err := io.WriteString(w, documentBody); err != nil {
|
||||
t.Fatalf("write document.xml: %v", err)
|
||||
}
|
||||
w2, err := zw.Create("[Content_Types].xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create content types: %v", err)
|
||||
}
|
||||
// Use a docx-compatible content type so the convert pre-pass treats
|
||||
// the input as already-clean (no .dotm rewrites needed).
|
||||
body := `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
||||
`<Override PartName="/word/document.xml" ContentType="` + docxMainContentType + `"/></Types>`
|
||||
if _, err := io.WriteString(w2, body); err != nil {
|
||||
t.Fatalf("write content types: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// readMergeDocumentXML pulls word/document.xml out of a rendered .docx.
|
||||
func readMergeDocumentXML(t *testing.T, b []byte) string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
t.Fatalf("open rendered zip: %v", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open document.xml: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
body, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("read document.xml: %v", err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
t.Fatal("rendered .docx had no word/document.xml")
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestRender_SingleRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, ">HLC<") {
|
||||
t.Errorf("expected HLC in body, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_MultiplePlaceholdersPerRun(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
"parties.claimant.name": "Acme Inc.",
|
||||
"parties.claimant.representative": "Kanzlei Müller",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "Acme Inc.") || !strings.Contains(body, "Kanzlei Müller") {
|
||||
t.Errorf("expected both party values, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_MissingMarker(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
|
||||
t.Errorf("expected KEIN WERT marker, got %q", body)
|
||||
}
|
||||
outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en"))
|
||||
if err != nil {
|
||||
t.Fatalf("render en: %v", err)
|
||||
}
|
||||
bodyEN := readMergeDocumentXML(t, outEN)
|
||||
if !strings.Contains(bodyEN, "[NO VALUE: project.case_number]") {
|
||||
t.Errorf("expected NO VALUE marker, got %q", bodyEN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_CrossRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "7 O 1234/26") {
|
||||
t.Errorf("expected case number after cross-run merge, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("orphan placeholder marker remained: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_XMLEscaping(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "Müller & Söhne <GmbH> "Special"") {
|
||||
t.Errorf("expected escaped value, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholderRegex_Boundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
matches []string
|
||||
}{
|
||||
{"plain text", nil},
|
||||
{"{{foo}}", []string{"{{foo}}"}},
|
||||
{"{{ foo }}", []string{"{{ foo }}"}},
|
||||
{"{{foo.bar}}", []string{"{{foo.bar}}"}},
|
||||
{"{{ foo.bar_baz }}", []string{"{{ foo.bar_baz }}"}},
|
||||
{"{{1bad}}", nil},
|
||||
{"{{ foo }} and {{ bar }}", []string{"{{ foo }}", "{{ bar }}"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := placeholderRegex.FindAllString(tc.in, -1)
|
||||
if len(got) != len(tc.matches) {
|
||||
t.Fatalf("got %d matches, want %d (in=%q)", len(got), len(tc.matches), tc.in)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.matches[i] {
|
||||
t.Errorf("match %d: got %q, want %q", i, got[i], tc.matches[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalSourcePretty(t *testing.T) {
|
||||
tests := []struct {
|
||||
src, lang, want string
|
||||
}{
|
||||
{"DE.ZPO.276.1", "de", "§ 276 Abs. 1 ZPO"},
|
||||
{"DE.ZPO.276.1", "en", "Section 276(1) ZPO"},
|
||||
{"DE.ZPO.253", "de", "§ 253 ZPO"},
|
||||
{"DE.ZPO.253", "en", "Section 253 ZPO"},
|
||||
{"UPC.RoP.23.1", "de", "Regel 23.1 VerfO UPC"},
|
||||
{"UPC.RoP.23.1", "en", "Rule 23.1 RoP UPC"},
|
||||
{"UPC.RoP.198", "de", "Regel 198 VerfO UPC"},
|
||||
{"DE.PatG.83", "de", "§ 83 PatG"},
|
||||
{"EPC.123", "de", "Art. 123 EPÜ"},
|
||||
{"EPC.123", "en", "Art. 123 EPC"},
|
||||
{"FOO.BAR.123", "de", "FOO.BAR.123"},
|
||||
{"", "de", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.src+"/"+tc.lang, func(t *testing.T) {
|
||||
got := legalSourcePretty(tc.src, tc.lang)
|
||||
if got != tc.want {
|
||||
t.Errorf("legalSourcePretty(%q, %q) = %q, want %q", tc.src, tc.lang, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOurSideTranslations(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, wantDE, wantEN string
|
||||
}{
|
||||
{"claimant", "Klägerin", "Claimant"},
|
||||
{"defendant", "Beklagte", "Defendant"},
|
||||
{"court", "Gericht", "Court"},
|
||||
{"both", "Klägerin und Beklagte", "Claimant and Defendant"},
|
||||
{"", "", ""},
|
||||
{"unknown", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := ourSideDE(tc.in); got != tc.wantDE {
|
||||
t.Errorf("ourSideDE(%q) = %q, want %q", tc.in, got, tc.wantDE)
|
||||
}
|
||||
if got := ourSideEN(tc.in); got != tc.wantEN {
|
||||
t.Errorf("ourSideEN(%q) = %q, want %q", tc.in, got, tc.wantEN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatentNumberUPC(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
|
||||
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
|
||||
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
|
||||
{"EP 1 234 567", "EP 1 234 567"},
|
||||
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
|
||||
{"", ""},
|
||||
{"WO/2023/123456", "WO/2023/123456"},
|
||||
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := patentNumberUPC(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderHTML_ExtractsParagraphsAndFormatting verifies the preview
|
||||
// HTML emitter walks <w:p> / <w:r> / <w:t> correctly and carries
|
||||
// bold/italic through to <strong>/<em>. Substituted placeholders are
|
||||
// wrapped in <span class="draft-var" data-var="…"> so the client can
|
||||
// make them clickable (t-paliad-261).
|
||||
func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
|
||||
doc := `<w:document><w:body>` +
|
||||
`<w:p><w:r><w:t>Hello {{firm.name}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Bold line</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:rPr><w:i/></w:rPr><w:t>Italic line</w:t></w:r></w:p>` +
|
||||
`</w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render html: %v", err)
|
||||
}
|
||||
if !strings.Contains(html, `<p>Hello <span class="draft-var" data-var="firm.name">HLC</span></p>`) {
|
||||
t.Errorf("expected merged paragraph with draft-var span, got %q", html)
|
||||
}
|
||||
if !strings.Contains(html, "<strong>Bold line</strong>") {
|
||||
t.Errorf("expected bold span, got %q", html)
|
||||
}
|
||||
if !strings.Contains(html, "<em>Italic line</em>") {
|
||||
t.Errorf("expected italic span, got %q", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderHTML_EscapesContent confirms the preview emitter HTML-escapes
|
||||
// special characters in placeholder values even inside the draft-var
|
||||
// span wrapper.
|
||||
func TestRenderHTML_EscapesContent(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{
|
||||
"user.display_name": `M&S <Inc> "X"`,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render html: %v", err)
|
||||
}
|
||||
want := `<span class="draft-var" data-var="user.display_name">M&S <Inc> "X"</span>`
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("expected escaped value inside draft-var span, got %q", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderHTML_WrapsMissingMarker confirms that an unbound placeholder
|
||||
// is still rendered as a clickable draft-var span so the user can click
|
||||
// the [KEIN WERT: …] marker in the preview and jump to the field.
|
||||
func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render html: %v", err)
|
||||
}
|
||||
want := `<span class="draft-var" data-var="project.case_number">[KEIN WERT: project.case_number]</span>`
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("expected missing marker wrapped in draft-var span, got %q", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_DocxOutputUnchangedByPreviewWrap asserts the hard rule from
|
||||
// t-paliad-261: the .docx export path must NOT carry the preview-only
|
||||
// draft-var sentinels or any draft-var span markup. Renders the same
|
||||
// template through Render (.docx) and asserts the merged document.xml
|
||||
// has only the resolved value, not a wrapped one.
|
||||
func TestRender_DocxOutputUnchangedByPreviewWrap(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render docx: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, `<w:t>HLC</w:t>`) {
|
||||
t.Errorf("expected raw resolved value in .docx, got %q", body)
|
||||
}
|
||||
// PUA sentinels and any span markup must NOT appear in the .docx.
|
||||
for _, forbidden := range []string{"draft-var", "data-var", previewVarBegin, previewVarMid, previewVarEnd} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("docx output unexpectedly contains %q: %q", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
554
internal/services/submission_vars.go
Normal file
554
internal/services/submission_vars.go
Normal file
@@ -0,0 +1,554 @@
|
||||
package services
|
||||
|
||||
// Submission variable bag — builds the PlaceholderMap that
|
||||
// SubmissionRenderer fills into a template (t-paliad-215, design doc
|
||||
// docs/design-submission-generator-2026-05-19.md §6.2).
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Visibility: caller passes userID; ProjectService.GetByID enforces
|
||||
// paliad.can_see_project — unauthorised callers get the standard
|
||||
// ErrNotFound before any variable construction runs.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// SubmissionVarsService assembles the placeholder map.
|
||||
type SubmissionVarsService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
parties *PartyService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewSubmissionVarsService wires the service.
|
||||
func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *PartyService, users *UserService) *SubmissionVarsService {
|
||||
return &SubmissionVarsService{
|
||||
db: db,
|
||||
projects: projects,
|
||||
parties: parties,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
// SubmissionVarsContext is the input bundle that produces a render.
|
||||
//
|
||||
// ProjectID is optional since t-paliad-243 — a global Schriftsatz draft
|
||||
// started from /submissions/new without picking a project carries
|
||||
// nil here and the project / parties / deadline lookups are skipped.
|
||||
type SubmissionVarsContext struct {
|
||||
UserID uuid.UUID
|
||||
ProjectID *uuid.UUID
|
||||
SubmissionCode string
|
||||
}
|
||||
|
||||
// SubmissionVarsResult bundles the placeholder map with the lookup
|
||||
// values the handler needs for the audit row + file naming
|
||||
// (rule.Name, project.case_number, etc.).
|
||||
type SubmissionVarsResult struct {
|
||||
Placeholders PlaceholderMap
|
||||
|
||||
// Resolved entities for audit + naming.
|
||||
User *models.User
|
||||
Project *models.Project
|
||||
Rule *models.DeadlineRule
|
||||
ProceedingType *models.ProceedingType
|
||||
Parties []models.Party
|
||||
NextDeadline *models.Deadline
|
||||
|
||||
// Lang is the user's UI language used to pick locale-aware values
|
||||
// during the build. Returned so the renderer can use the matching
|
||||
// missing-marker function.
|
||||
Lang string
|
||||
}
|
||||
|
||||
// ErrSubmissionRuleNotFound is returned when no published deadline_rule
|
||||
// matches the requested submission_code. Maps to 404 in the handler.
|
||||
var ErrSubmissionRuleNotFound = errors.New("submission generator: no rule found for submission_code")
|
||||
|
||||
// Build resolves every entity and assembles the placeholder map. A nil
|
||||
// ProjectID skips project / parties / deadline lookups — the resolved
|
||||
// bag carries only firm.*, today.*, user.* and rule.* in that case;
|
||||
// every other placeholder falls through to the lawyer's overrides via
|
||||
// SubmissionDraftService.BuildRenderBag.
|
||||
func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsContext) (*SubmissionVarsResult, error) {
|
||||
if s.projects == nil || s.users == nil {
|
||||
return nil, fmt.Errorf("submission vars: required services not wired")
|
||||
}
|
||||
|
||||
user, err := s.users.GetByID(ctx, in.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
|
||||
rule, err := s.loadPublishedRule(ctx, in.SubmissionCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lang := user.Lang
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
addFirmVars(bag)
|
||||
addTodayVars(bag, time.Now())
|
||||
addUserVars(bag, user)
|
||||
addRuleVars(bag, rule, lang)
|
||||
|
||||
out := &SubmissionVarsResult{
|
||||
Placeholders: bag,
|
||||
User: user,
|
||||
Rule: rule,
|
||||
Lang: lang,
|
||||
}
|
||||
|
||||
if in.ProjectID == nil {
|
||||
// Project-less draft (t-paliad-243): no project / parties /
|
||||
// deadline state to resolve. The lawyer's overrides will fill
|
||||
// the placeholder map; missing keys render as
|
||||
// [KEIN WERT: …] / [NO VALUE: …] in the preview.
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Visibility gate — GetByID returns ErrNotFound when the user
|
||||
// can't see the project, which is exactly the 404 the handler
|
||||
// wants to propagate.
|
||||
project, err := s.projects.GetByID(ctx, in.UserID, *in.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pt, err := s.loadProceedingType(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parties, err := s.parties.ListForProject(ctx, in.UserID, *in.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := s.nextOpenDeadline(ctx, *in.ProjectID, rule.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addProjectVars(bag, project, pt, lang)
|
||||
addPartyVars(bag, parties)
|
||||
addDeadlineVars(bag, next, project, lang)
|
||||
|
||||
out.Project = project
|
||||
out.ProceedingType = pt
|
||||
out.Parties = parties
|
||||
out.NextDeadline = next
|
||||
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.
|
||||
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
}
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order
|
||||
LIMIT 1`, submissionCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load rule by submission_code %q: %w", submissionCode, err)
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
// loadProceedingType fetches the proceeding type row for the project's
|
||||
// proceeding_type_id. Tolerates a nil id (returns nil, nil) so projects
|
||||
// without a bound proceeding still render a meaningful template — the
|
||||
// {{project.proceeding.*}} placeholders just resolve to the missing
|
||||
// marker.
|
||||
func (s *SubmissionVarsService) loadProceedingType(ctx context.Context, id *int) (*models.ProceedingType, error) {
|
||||
if id == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var pt models.ProceedingType
|
||||
err := s.db.GetContext(ctx, &pt,
|
||||
`SELECT `+proceedingTypeColumns+`
|
||||
FROM paliad.proceeding_types
|
||||
WHERE id = $1`, *id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load proceeding type %d: %w", *id, err)
|
||||
}
|
||||
return &pt, nil
|
||||
}
|
||||
|
||||
// nextOpenDeadline finds the earliest pending paliad.deadlines row on
|
||||
// the given project that maps to the chosen rule. Returns (nil, nil)
|
||||
// when no matching deadline exists — common when the lawyer is drafting
|
||||
// the submission before the system has computed its deadline row.
|
||||
func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID, ruleID uuid.UUID) (*models.Deadline, error) {
|
||||
var d models.Deadline
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at,
|
||||
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at
|
||||
FROM paliad.deadlines
|
||||
WHERE project_id = $1
|
||||
AND rule_id = $2
|
||||
AND status = 'pending'
|
||||
ORDER BY due_date ASC
|
||||
LIMIT 1`, projectID, ruleID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load next deadline (project=%s rule=%s): %w", projectID, ruleID, err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// addFirmVars populates the firm.* namespace.
|
||||
func addFirmVars(bag PlaceholderMap) {
|
||||
bag["firm.name"] = branding.Name
|
||||
// firm.signature_block is reserved for Phase 2; emit empty so
|
||||
// templates that already reference it don't render the missing
|
||||
// marker (less noisy for the lawyer).
|
||||
bag["firm.signature_block"] = ""
|
||||
}
|
||||
|
||||
// addTodayVars populates today.* in both DE and EN long forms. ISO
|
||||
// short form is the default {{today}}.
|
||||
func addTodayVars(bag PlaceholderMap, now time.Time) {
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
if loc != nil {
|
||||
now = now.In(loc)
|
||||
}
|
||||
bag["today"] = now.Format("2006-01-02")
|
||||
bag["today.iso"] = now.Format("2006-01-02")
|
||||
bag["today.long_de"] = formatLongDateDE(now)
|
||||
bag["today.long_en"] = formatLongDateEN(now)
|
||||
}
|
||||
|
||||
// addUserVars populates user.*.
|
||||
func addUserVars(bag PlaceholderMap, u *models.User) {
|
||||
bag["user.display_name"] = u.DisplayName
|
||||
bag["user.email"] = u.Email
|
||||
bag["user.office"] = u.Office
|
||||
}
|
||||
|
||||
// addProjectVars populates project.* — title / case_number / court /
|
||||
// patent_number / dates / our_side / proceeding metadata.
|
||||
func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
|
||||
bag["project.title"] = p.Title
|
||||
bag["project.reference"] = derefString(p.Reference)
|
||||
bag["project.case_number"] = derefString(p.CaseNumber)
|
||||
bag["project.court"] = derefString(p.Court)
|
||||
bag["project.patent_number"] = derefString(p.PatentNumber)
|
||||
// project.patent_number_upc is the UPC-brief convention — kind code
|
||||
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
|
||||
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
|
||||
// kind code is present so the lawyer's draft never sees a worse
|
||||
// number than the source value.
|
||||
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
|
||||
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
|
||||
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
|
||||
bag["project.our_side"] = derefString(p.OurSide)
|
||||
bag["project.our_side_de"] = ourSideDE(derefString(p.OurSide))
|
||||
bag["project.our_side_en"] = ourSideEN(derefString(p.OurSide))
|
||||
bag["project.instance_level"] = derefString(p.InstanceLevel)
|
||||
bag["project.client_number"] = derefString(p.ClientNumber)
|
||||
bag["project.matter_number"] = derefString(p.MatterNumber)
|
||||
if pt != nil {
|
||||
bag["project.proceeding.code"] = pt.Code
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["project.proceeding.name"] = pt.NameEN
|
||||
} else {
|
||||
bag["project.proceeding.name"] = pt.Name
|
||||
}
|
||||
bag["project.proceeding.name_de"] = pt.Name
|
||||
bag["project.proceeding.name_en"] = pt.NameEN
|
||||
}
|
||||
}
|
||||
|
||||
// addPartyVars populates parties.* using the first row of each role.
|
||||
// Multi-claimant / multi-defendant suits use the first row in Slice 1
|
||||
// per design §13.6; expanded grouping is Phase 2.
|
||||
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
||||
var claimant, defendant, other *models.Party
|
||||
for i := range parties {
|
||||
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
|
||||
switch role {
|
||||
case "claimant", "kläger", "klaeger":
|
||||
if claimant == nil {
|
||||
claimant = &parties[i]
|
||||
}
|
||||
case "defendant", "beklagter", "beklagte":
|
||||
if defendant == nil {
|
||||
defendant = &parties[i]
|
||||
}
|
||||
default:
|
||||
if other == nil {
|
||||
other = &parties[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if claimant != nil {
|
||||
bag["parties.claimant.name"] = claimant.Name
|
||||
bag["parties.claimant.representative"] = derefString(claimant.Representative)
|
||||
}
|
||||
if defendant != nil {
|
||||
bag["parties.defendant.name"] = defendant.Name
|
||||
bag["parties.defendant.representative"] = derefString(defendant.Representative)
|
||||
}
|
||||
if other != nil {
|
||||
bag["parties.other.name"] = other.Name
|
||||
bag["parties.other.representative"] = derefString(other.Representative)
|
||||
}
|
||||
}
|
||||
|
||||
// addRuleVars populates rule.* — submission_code, name(_en),
|
||||
// legal_source (+ pretty form), primary_party, event_type.
|
||||
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
|
||||
bag["rule.submission_code"] = derefString(r.SubmissionCode)
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["rule.name"] = r.NameEN
|
||||
} else {
|
||||
bag["rule.name"] = r.Name
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// addDeadlineVars populates deadline.* from the next pending row. When
|
||||
// no row exists the values fall through to the missing marker — the
|
||||
// lawyer sees [KEIN WERT: deadline.due_date] in Word and knows to fix.
|
||||
func addDeadlineVars(bag PlaceholderMap, d *models.Deadline, p *models.Project, lang string) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
bag["deadline.due_date"] = d.DueDate.Format("2006-01-02")
|
||||
bag["deadline.due_date_long_de"] = formatLongDateDE(d.DueDate)
|
||||
bag["deadline.due_date_long_en"] = formatLongDateEN(d.DueDate)
|
||||
if d.OriginalDueDate != nil {
|
||||
bag["deadline.original_due_date"] = d.OriginalDueDate.Format("2006-01-02")
|
||||
}
|
||||
// computed_from carries the human-readable anchor description
|
||||
// (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen"). Notes is
|
||||
// the closest existing field — the calculator stores anchor
|
||||
// metadata there. If empty we leave the placeholder unresolved.
|
||||
if d.Notes != nil && strings.TrimSpace(*d.Notes) != "" {
|
||||
bag["deadline.computed_from"] = strings.TrimSpace(*d.Notes)
|
||||
}
|
||||
bag["deadline.title"] = d.Title
|
||||
bag["deadline.source"] = d.Source
|
||||
_ = p // reserved for future shape decisions where the deadline
|
||||
// var depends on project context.
|
||||
_ = lang
|
||||
}
|
||||
|
||||
// derefString returns *s or "" when s is nil.
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// formatDatePtr formats a *time.Time, returning "" for nil.
|
||||
func formatDatePtr(t *time.Time, layout string) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format(layout)
|
||||
}
|
||||
|
||||
// ourSideDE returns the German legal-prose form of an our_side value.
|
||||
func ourSideDE(side string) string {
|
||||
switch strings.ToLower(side) {
|
||||
case "claimant":
|
||||
return "Klägerin"
|
||||
case "defendant":
|
||||
return "Beklagte"
|
||||
case "court":
|
||||
return "Gericht"
|
||||
case "both":
|
||||
return "Klägerin und Beklagte"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ourSideEN returns the English legal-prose form of an our_side value.
|
||||
func ourSideEN(side string) string {
|
||||
switch strings.ToLower(side) {
|
||||
case "claimant":
|
||||
return "Claimant"
|
||||
case "defendant":
|
||||
return "Defendant"
|
||||
case "court":
|
||||
return "Court"
|
||||
case "both":
|
||||
return "Claimant and Defendant"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatLongDateDE renders a date in the German long form
|
||||
// ("19. Mai 2026"). Pure function for unit testing.
|
||||
func formatLongDateDE(t time.Time) string {
|
||||
months := []string{
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
}
|
||||
idx := int(t.Month()) - 1
|
||||
if idx < 0 || idx >= len(months) {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
return fmt.Sprintf("%d. %s %d", t.Day(), months[idx], t.Year())
|
||||
}
|
||||
|
||||
// formatLongDateEN renders a date in the English long form
|
||||
// ("19 May 2026").
|
||||
func formatLongDateEN(t time.Time) string {
|
||||
return t.Format("2 January 2006")
|
||||
}
|
||||
|
||||
// legalSourcePretty rewrites the shorthand stored on deadline_rules
|
||||
// (DE.ZPO.276.1, UPC.RoP.23.1, …) into the form a lawyer would type
|
||||
// in a brief ("§ 276 Abs. 1 ZPO", "Rule 23.1 RoP UPC"). Unknown
|
||||
// prefixes pass through unchanged — preferring the raw shorthand over
|
||||
// an incorrect prettification.
|
||||
//
|
||||
// Lang controls the language of connective words (Abs / Section,
|
||||
// Regel / Rule, …). The pretty table covers the prefixes used by the
|
||||
// 254 published rules in the corpus today; new prefixes default to
|
||||
// pass-through and a follow-up CL extends the table.
|
||||
func legalSourcePretty(src, lang string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
en := strings.EqualFold(lang, "en")
|
||||
|
||||
switch {
|
||||
case len(parts) == 4 && parts[0] == "DE" && parts[1] == "ZPO":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s(%s) ZPO", parts[2], parts[3])
|
||||
}
|
||||
return fmt.Sprintf("§ %s Abs. %s ZPO", parts[2], parts[3])
|
||||
case len(parts) == 3 && parts[0] == "DE" && parts[1] == "ZPO":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s ZPO", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("§ %s ZPO", parts[2])
|
||||
case len(parts) == 4 && parts[0] == "UPC" && parts[1] == "RoP":
|
||||
if en {
|
||||
return fmt.Sprintf("Rule %s.%s RoP UPC", parts[2], parts[3])
|
||||
}
|
||||
return fmt.Sprintf("Regel %s.%s VerfO UPC", parts[2], parts[3])
|
||||
case len(parts) == 3 && parts[0] == "UPC" && parts[1] == "RoP":
|
||||
if en {
|
||||
return fmt.Sprintf("Rule %s RoP UPC", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("Regel %s VerfO UPC", parts[2])
|
||||
case len(parts) >= 3 && parts[0] == "DE" && parts[1] == "PatG":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s PatG", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("§ %s PatG", parts[2])
|
||||
case len(parts) == 2 && parts[0] == "EPC":
|
||||
if en {
|
||||
return fmt.Sprintf("Art. %s EPC", parts[1])
|
||||
}
|
||||
return fmt.Sprintf("Art. %s EPÜ", parts[1])
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// patentNumberKindCodeRegex matches a trailing kind code on a patent
|
||||
// number: a whitespace-separated single uppercase letter followed by
|
||||
// a single digit (B1, A1, A2, B2, B9, C1, T2, U1, …). Capturing
|
||||
// groups split the base from the kind code so the formatter can
|
||||
// parenthesise the kind without touching the rest of the number.
|
||||
var patentNumberKindCodeRegex = regexp.MustCompile(`^(.*?)\s+([A-Z]\d)$`)
|
||||
|
||||
// patentNumberUPC reformats a patent number from the DE convention
|
||||
// ("EP 1 234 567 B1") to the UPC-brief convention
|
||||
// ("EP 1 234 567 (B1)"). The kind code is parenthesised; everything
|
||||
// else is preserved verbatim. Numbers without a recognised trailing
|
||||
// kind code pass through unchanged so a lawyer's draft never sees a
|
||||
// number worse than the source value.
|
||||
//
|
||||
// Recognised inputs:
|
||||
//
|
||||
// "EP 1 234 567 B1" → "EP 1 234 567 (B1)"
|
||||
// "EP 4 056 049 A1" → "EP 4 056 049 (A1)"
|
||||
// "DE 10 2020 123 456 A1" → "DE 10 2020 123 456 (A1)"
|
||||
// " EP 1 234 567 B1 " → "EP 1 234 567 (B1)" (trimmed)
|
||||
//
|
||||
// Pass-through:
|
||||
//
|
||||
// "EP 1 234 567" → "EP 1 234 567"
|
||||
// "WO/2023/123456" → "WO/2023/123456" (no kind code shape)
|
||||
// "" → ""
|
||||
//
|
||||
// Pure function; unit-tested in submission_vars_test.go.
|
||||
func patentNumberUPC(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if m := patentNumberKindCodeRegex.FindStringSubmatch(s); m != nil {
|
||||
base := strings.TrimSpace(m[1])
|
||||
kind := m[2]
|
||||
if base == "" {
|
||||
return s
|
||||
}
|
||||
return base + " (" + kind + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -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,19 +157,35 @@ 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 {
|
||||
now = now.UTC()
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
tomorrow := day.AddDate(0, 0, 1)
|
||||
switch ts.Horizon {
|
||||
case HorizonNext1d:
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonNext7d:
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 7)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonNext14d:
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 14)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonNext30d:
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 30)
|
||||
@@ -169,18 +194,30 @@ func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 90)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonNextAll:
|
||||
// One-sided unbounded — from today onwards, no upper bound.
|
||||
// Distinct from HorizonAll (bidirectional unbounded) and
|
||||
// HorizonAny (no time filter at all).
|
||||
from := day
|
||||
return viewSpecBounds{from: &from}
|
||||
case HorizonPast1d:
|
||||
from := day.AddDate(0, 0, -1)
|
||||
return viewSpecBounds{from: &from, to: &tomorrow}
|
||||
case HorizonPast7d:
|
||||
from := day.AddDate(0, 0, -7)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
return viewSpecBounds{from: &from, to: &tomorrow}
|
||||
case HorizonPast14d:
|
||||
from := day.AddDate(0, 0, -14)
|
||||
return viewSpecBounds{from: &from, to: &tomorrow}
|
||||
case HorizonPast30d:
|
||||
from := day.AddDate(0, 0, -30)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
return viewSpecBounds{from: &from, to: &tomorrow}
|
||||
case HorizonPast90d:
|
||||
from := day.AddDate(0, 0, -90)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
return viewSpecBounds{from: &from, to: &tomorrow}
|
||||
case HorizonPastAll:
|
||||
// One-sided unbounded — up to and including today, no lower bound.
|
||||
return viewSpecBounds{to: &tomorrow}
|
||||
case HorizonAny, HorizonAll:
|
||||
return viewSpecBounds{}
|
||||
case HorizonCustom:
|
||||
@@ -345,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}
|
||||
@@ -366,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,
|
||||
@@ -520,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
|
||||
@@ -646,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
|
||||
|
||||
123
internal/services/view_service_bounds_test.go
Normal file
123
internal/services/view_service_bounds_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package services
|
||||
|
||||
// Pure tests for computeViewSpecBounds — t-paliad-248. Covers every
|
||||
// TimeHorizon constant in the symmetric date-range fan, including the
|
||||
// six new ones added when the picker shipped (next_1d / next_14d /
|
||||
// next_all / past_1d / past_14d / past_all).
|
||||
//
|
||||
// Anchored against a fixed `now` so the assertions never drift with the
|
||||
// wall clock. Each case asserts the bounds shape (open-ended vs.
|
||||
// closed) and the exact offsets from the anchor day.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComputeViewSpecBounds_Horizons(t *testing.T) {
|
||||
// Anchor: 2026-05-25 14:37:00 UTC. computeViewSpecBounds normalises
|
||||
// to startOfDay UTC, so the wall-clock time within the day is
|
||||
// irrelevant.
|
||||
now := time.Date(2026, 5, 25, 14, 37, 0, 0, time.UTC)
|
||||
day := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
tomorrow := day.AddDate(0, 0, 1)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
horizon TimeHorizon
|
||||
wantFrom *time.Time
|
||||
wantTo *time.Time
|
||||
}{
|
||||
// Future fan.
|
||||
{"next_1d", HorizonNext1d, &day, tptr(day.AddDate(0, 0, 1))},
|
||||
{"next_7d", HorizonNext7d, &day, tptr(day.AddDate(0, 0, 7))},
|
||||
{"next_14d", HorizonNext14d, &day, tptr(day.AddDate(0, 0, 14))},
|
||||
{"next_30d", HorizonNext30d, &day, tptr(day.AddDate(0, 0, 30))},
|
||||
{"next_90d", HorizonNext90d, &day, tptr(day.AddDate(0, 0, 90))},
|
||||
// One-sided unbounded: from today, no upper bound.
|
||||
{"next_all", HorizonNextAll, &day, nil},
|
||||
|
||||
// Past fan — upper bound is tomorrow (exclusive end-of-today).
|
||||
{"past_1d", HorizonPast1d, tptr(day.AddDate(0, 0, -1)), &tomorrow},
|
||||
{"past_7d", HorizonPast7d, tptr(day.AddDate(0, 0, -7)), &tomorrow},
|
||||
{"past_14d", HorizonPast14d, tptr(day.AddDate(0, 0, -14)), &tomorrow},
|
||||
{"past_30d", HorizonPast30d, tptr(day.AddDate(0, 0, -30)), &tomorrow},
|
||||
{"past_90d", HorizonPast90d, tptr(day.AddDate(0, 0, -90)), &tomorrow},
|
||||
// One-sided unbounded: no lower bound, up to and including today.
|
||||
{"past_all", HorizonPastAll, nil, &tomorrow},
|
||||
|
||||
// Bidirectional unbounded — both nil.
|
||||
{"any", HorizonAny, nil, nil},
|
||||
{"all", HorizonAll, nil, nil},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := computeViewSpecBounds(now, TimeSpec{Horizon: tc.horizon})
|
||||
assertBound(t, "from", got.from, tc.wantFrom)
|
||||
assertBound(t, "to", got.to, tc.wantTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeViewSpecBounds_NewHorizonsAreOneSided documents the
|
||||
// semantic distinction between next_all / past_all (one-sided
|
||||
// unbounded, with one bound nil and the other set) and the existing
|
||||
// HorizonAll / HorizonAny (both bounds nil).
|
||||
func TestComputeViewSpecBounds_NewHorizonsAreOneSided(t *testing.T) {
|
||||
now := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
nextAll := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonNextAll})
|
||||
if nextAll.from == nil {
|
||||
t.Fatalf("HorizonNextAll: from must be set (today), got nil")
|
||||
}
|
||||
if nextAll.to != nil {
|
||||
t.Fatalf("HorizonNextAll: to must be nil (no upper bound), got %v", *nextAll.to)
|
||||
}
|
||||
|
||||
pastAll := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonPastAll})
|
||||
if pastAll.from != nil {
|
||||
t.Fatalf("HorizonPastAll: from must be nil (no lower bound), got %v", *pastAll.from)
|
||||
}
|
||||
if pastAll.to == nil {
|
||||
t.Fatalf("HorizonPastAll: to must be set (tomorrow), got nil")
|
||||
}
|
||||
|
||||
any := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonAny})
|
||||
if any.from != nil || any.to != nil {
|
||||
t.Fatalf("HorizonAny: both bounds must be nil, got from=%v to=%v", any.from, any.to)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeViewSpecBounds_CustomRoundTrips makes sure the custom
|
||||
// horizon passes through the caller-supplied from/to verbatim — no
|
||||
// normalisation, no clamping.
|
||||
func TestComputeViewSpecBounds_CustomRoundTrips(t *testing.T) {
|
||||
now := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
from := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
got := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonCustom, From: &from, To: &to})
|
||||
if got.from == nil || !got.from.Equal(from) {
|
||||
t.Fatalf("custom from: want %v, got %v", from, got.from)
|
||||
}
|
||||
if got.to == nil || !got.to.Equal(to) {
|
||||
t.Fatalf("custom to: want %v, got %v", to, got.to)
|
||||
}
|
||||
}
|
||||
|
||||
func tptr(t time.Time) *time.Time { return &t }
|
||||
|
||||
func assertBound(t *testing.T, name string, got *time.Time, want *time.Time) {
|
||||
t.Helper()
|
||||
switch {
|
||||
case got == nil && want == nil:
|
||||
return
|
||||
case got == nil:
|
||||
t.Fatalf("%s: want %v, got nil", name, *want)
|
||||
case want == nil:
|
||||
t.Fatalf("%s: want nil, got %v", name, *got)
|
||||
case !got.Equal(*want):
|
||||
t.Fatalf("%s: want %v, got %v", name, *want, *got)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
346
scripts/gen-demo-submission-template/main.go
Normal file
346
scripts/gen-demo-submission-template/main.go
Normal file
@@ -0,0 +1,346 @@
|
||||
// Demo submission template generator (t-paliad-241).
|
||||
//
|
||||
// One-shot authoring tool that emits a minimal but Word-compatible
|
||||
// .docx file exercising every placeholder SubmissionVarsService
|
||||
// resolves. Drop the output into m/mWorkRepo at
|
||||
//
|
||||
// 6 - material/Templates/Word/Paliad/HLC/de.inf.lg.erwidg.docx
|
||||
//
|
||||
// so paliad's submission-draft editor (t-paliad-238 Slice A) can fetch
|
||||
// it via the per-submission_code fallback chain wired into
|
||||
// handlers/files.go. The structure is a fake Klageerwiderung skeleton
|
||||
// in German — fake legal prose, real placeholder tokens.
|
||||
//
|
||||
// Why a generator instead of authoring in Word: the per-placeholder
|
||||
// docx grammar is `{{[A-Za-z][A-Za-z0-9_.]*}}` and Word's autocorrect
|
||||
// happily fragments such tokens across <w:r> runs ({{ → "{", "{",
|
||||
// project.case_number, "}", "}"). A programmatic emitter writes each
|
||||
// placeholder as a single run so the renderer's pass-1 substitution
|
||||
// (format-preserving) catches it cleanly. The merge engine handles
|
||||
// cross-run cases too (pass 2) but pass 1 is the cheaper path.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./scripts/gen-demo-submission-template -out /tmp/de.inf.lg.erwidg.docx
|
||||
//
|
||||
// Output is deterministic so re-generating to the same path produces a
|
||||
// byte-identical file (modulo zip mtime — we pin those to a fixed UTC
|
||||
// timestamp so the bytes are reproducible).
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "de.inf.lg.erwidg.docx", "output .docx path")
|
||||
flag.Parse()
|
||||
|
||||
docx, err := buildDocx()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-demo-submission-template:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-demo-submission-template: write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
|
||||
}
|
||||
|
||||
// fixedTime is the zip mtime stamp baked into every entry so the output
|
||||
// is byte-reproducible.
|
||||
var fixedTime = time.Date(2026, 5, 23, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// buildDocx assembles the four-part .docx zip Word needs to open the
|
||||
// file cleanly: Content_Types, root rels, document.xml, and document
|
||||
// rels. Everything else (styles, theme, fonts) is optional — Word
|
||||
// supplies sane defaults when absent.
|
||||
func buildDocx() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
add := func(name, body string) error {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
Modified: fixedTime,
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
return fmt.Errorf("write %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("_rels/.rels", rootRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/styles.xml", stylesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/document.xml", buildDocumentXML()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
</Types>`
|
||||
|
||||
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
// stylesXML provides minimal Heading1 + Heading2 paragraph styles so
|
||||
// the section headings render with visual weight. Body text falls
|
||||
// through to Word's Normal style.
|
||||
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:style w:type="paragraph" w:styleId="Heading1">
|
||||
<w:name w:val="heading 1"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2">
|
||||
<w:name w:val="heading 2"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
||||
<w:name w:val="Normal"/>
|
||||
</w:style>
|
||||
</w:styles>`
|
||||
|
||||
// Document body — a fake Klageerwiderung skeleton with every placeholder
|
||||
// SubmissionVarsService resolves embedded in natural positions. Each
|
||||
// placeholder is in its own run so pass-1 substitution catches it without
|
||||
// fragmentation worries. The DEMO marker in the header makes it obvious
|
||||
// this is not approved firm content.
|
||||
//
|
||||
// Structure mirrors a real submission:
|
||||
//
|
||||
// 1. Firm letterhead + author block (firm.*, user.*, today.*)
|
||||
// 2. Court caption (project.*, project.proceeding.*)
|
||||
// 3. Parties block (parties.*)
|
||||
// 4. Submission title + legal source (rule.*)
|
||||
// 5. Deadline (deadline.*)
|
||||
// 6. Boilerplate body + signature
|
||||
//
|
||||
// Order matches what a lawyer drafting a real Klageerwiderung would put
|
||||
// at the top of the document, so when the lawyer customises this
|
||||
// template later they don't have to restructure.
|
||||
func buildDocumentXML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||
b.WriteString(`<w:body>`)
|
||||
|
||||
demoBanner(&b)
|
||||
|
||||
heading1(&b, "{{firm.name}} — Patentstreitsachen")
|
||||
plain(&b, "Bearbeiter: {{user.display_name}}")
|
||||
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
|
||||
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
|
||||
|
||||
heading1(&b, "{{project.court}}")
|
||||
plain(&b, "Aktenzeichen: {{project.case_number}}")
|
||||
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
|
||||
plain(&b, "Instanz: {{project.instance_level}}")
|
||||
|
||||
heading2(&b, "In der Patentstreitsache")
|
||||
plain(&b, "{{parties.claimant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.claimant.representative}}")
|
||||
bold(&b, "— Klägerin —")
|
||||
plain(&b, "")
|
||||
plain(&b, "gegen")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{parties.defendant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.defendant.representative}}")
|
||||
bold(&b, "— Beklagte —")
|
||||
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
|
||||
|
||||
heading2(&b, "Betreff")
|
||||
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
|
||||
plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
|
||||
plain(&b, "Projekttitel: {{project.title}}")
|
||||
plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
|
||||
plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
|
||||
plain(&b, "Internes Aktenzeichen: {{project.reference}}")
|
||||
|
||||
heading1(&b, "{{rule.name}}")
|
||||
plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
|
||||
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
||||
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||
|
||||
heading2(&b, "Frist")
|
||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
||||
|
||||
heading2(&b, "I. Anträge")
|
||||
plain(&b, "Die Beklagte beantragt,")
|
||||
plain(&b, "")
|
||||
plain(&b, "1. die Klage abzuweisen;")
|
||||
plain(&b, "2. der Klägerin die Kosten des Rechtsstreits aufzuerlegen.")
|
||||
|
||||
heading2(&b, "II. Sachverhalt")
|
||||
plain(&b, "[DEMO-Platzhalter] Hier folgt der Sachvortrag der Beklagten zum Streitpatent {{project.patent_number}} und zu den von der Klägerin geltend gemachten Ansprüchen.")
|
||||
|
||||
heading2(&b, "III. Rechtsausführungen")
|
||||
plain(&b, "[DEMO-Platzhalter] Die Beklagte tritt der Klage aus den nachfolgenden Gründen entgegen.")
|
||||
|
||||
heading2(&b, "Schlussformel")
|
||||
plain(&b, "{{today.long_de}}")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{user.display_name}}")
|
||||
plain(&b, "{{firm.name}}")
|
||||
plainOptional(&b, "{{firm.signature_block}}")
|
||||
|
||||
// English-locale exercise — lets the lawyer verify the EN long-form
|
||||
// date and EN proceeding name resolve correctly when the user's
|
||||
// preference is en. Also exercises the bare {{today}} alias
|
||||
// (identical to {{today.iso}}; included so every key the variable
|
||||
// bag carries appears at least once in this demo template).
|
||||
heading2(&b, "Locale-aware variants (DEMO)")
|
||||
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
|
||||
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
|
||||
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
||||
plain(&b, "Today (bare alias): {{today}}")
|
||||
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// demoBanner writes a clearly-marked DEMO header so the file can't be
|
||||
// mistaken for approved firm content (HLC branding compliance has not
|
||||
// reviewed this — it's a developer-authored placeholder fixture).
|
||||
func demoBanner(b *strings.Builder) {
|
||||
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">DEMO — interne Vorlage (nicht freigegeben)</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
// heading1 emits a styled "Heading 1" paragraph with placeholder runs
|
||||
// emitted intact (one run per placeholder so pass-1 substitution works).
|
||||
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
|
||||
|
||||
// heading2 emits a "Heading 2" paragraph.
|
||||
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
|
||||
|
||||
// plain emits a Normal-style paragraph.
|
||||
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
|
||||
|
||||
// plainOptional is a Normal paragraph rendered as italic so the lawyer
|
||||
// recognises rows that contain placeholders which may be empty
|
||||
// (parties.other.*, deadline.original_due_date, firm.signature_block).
|
||||
// Visual cue only; the merge engine still substitutes the same way.
|
||||
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
|
||||
|
||||
// bold emits a Normal paragraph with bold run formatting.
|
||||
func bold(b *strings.Builder, text string) {
|
||||
b.WriteString(`<w:p>`)
|
||||
b.WriteString(`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(text))
|
||||
b.WriteString(`</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
// paragraph splits text on placeholder boundaries and emits one <w:r>
|
||||
// per segment. Each placeholder occupies a dedicated run so the
|
||||
// renderer's pass-1 substitution (format-preserving, single-run) hits
|
||||
// the placeholder without the cross-run fallback. Italic runs are
|
||||
// flagged via the italic argument.
|
||||
func paragraph(b *strings.Builder, style, text string, italic bool) {
|
||||
b.WriteString(`<w:p>`)
|
||||
if style != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(style)
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
for _, seg := range splitOnPlaceholders(text) {
|
||||
b.WriteString(`<w:r>`)
|
||||
if italic {
|
||||
b.WriteString(`<w:rPr><w:i/></w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(seg))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
}
|
||||
|
||||
// splitOnPlaceholders returns the input split into alternating text /
|
||||
// placeholder segments while keeping each placeholder intact in its own
|
||||
// segment. Empty input yields a single empty segment so the paragraph
|
||||
// still emits a (visible) blank line.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xmlEscape handles the five XML-significant characters for <w:t>
|
||||
// content. Whitespace is preserved by the xml:space="preserve" attr we
|
||||
// always emit on text runs.
|
||||
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
|
||||
}
|
||||
303
scripts/gen-skeleton-submission-template/main.go
Normal file
303
scripts/gen-skeleton-submission-template/main.go
Normal file
@@ -0,0 +1,303 @@
|
||||
// Universal-skeleton submission template generator (t-paliad-259).
|
||||
//
|
||||
// One-shot authoring tool that emits a minimal but Word-compatible
|
||||
// .docx file exercising every placeholder SubmissionVarsService
|
||||
// resolves — without baking in any submission_code-specific prose.
|
||||
//
|
||||
// Drop the output into m/mWorkRepo at
|
||||
//
|
||||
// 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx
|
||||
//
|
||||
// so paliad's submission generator picks it up via the fallback chain
|
||||
// slotted between the per-submission_code template and the bare
|
||||
// universal HL Patents Style .dotm. Any submission_code that has no
|
||||
// per-firm template still gets a draft populated with variables
|
||||
// instead of the macro-only letterhead.
|
||||
//
|
||||
// Why a separate file from de.inf.lg.erwidg.docx: that one is a
|
||||
// Klageerwiderung skeleton (DE LG, "I. Anträge / II. Sachverhalt /
|
||||
// III. Rechtsausführungen"). For a UPC SoC, an EPO opposition, a DPMA
|
||||
// appeal, that body structure is wrong. The universal skeleton drops
|
||||
// the structure and leaves a single neutral body block the lawyer
|
||||
// replaces — every variable still resolves regardless of code.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./scripts/gen-skeleton-submission-template -out /tmp/_skeleton.docx
|
||||
//
|
||||
// Output is byte-reproducible (zip mtimes pinned to a fixed UTC
|
||||
// timestamp).
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "_skeleton.docx", "output .docx path")
|
||||
flag.Parse()
|
||||
|
||||
docx, err := buildDocx()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template: write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
|
||||
}
|
||||
|
||||
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func buildDocx() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
add := func(name, body string) error {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
Modified: fixedTime,
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
return fmt.Errorf("write %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("_rels/.rels", rootRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/styles.xml", stylesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/document.xml", buildDocumentXML()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
</Types>`
|
||||
|
||||
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:style w:type="paragraph" w:styleId="Heading1">
|
||||
<w:name w:val="heading 1"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2">
|
||||
<w:name w:val="heading 2"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
||||
<w:name w:val="Normal"/>
|
||||
</w:style>
|
||||
</w:styles>`
|
||||
|
||||
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
|
||||
// case caption + parties + submission heading + deadline + a single
|
||||
// neutral body block. Mirrors the variable bag from SubmissionVarsService
|
||||
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
|
||||
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
|
||||
// structure. A lawyer customising this template for a UPC SoC, EPO
|
||||
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
|
||||
// renames the party labels — every placeholder still resolves regardless
|
||||
// of the submission_code chosen.
|
||||
//
|
||||
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
|
||||
// (format-preserving, single-run) substitution catches it. The
|
||||
// DEMO/SKELETON banner makes it obvious this is a starter template and
|
||||
// not approved firm content.
|
||||
func buildDocumentXML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||
b.WriteString(`<w:body>`)
|
||||
|
||||
skeletonBanner(&b)
|
||||
|
||||
heading1(&b, "{{firm.name}}")
|
||||
plain(&b, "Bearbeiter: {{user.display_name}}")
|
||||
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
|
||||
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
|
||||
plainOptional(&b, "{{firm.signature_block}}")
|
||||
|
||||
heading1(&b, "{{project.court}}")
|
||||
plain(&b, "Aktenzeichen: {{project.case_number}}")
|
||||
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
|
||||
plain(&b, "Instanz: {{project.instance_level}}")
|
||||
|
||||
heading2(&b, "In der Sache")
|
||||
plain(&b, "{{parties.claimant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.claimant.representative}}")
|
||||
bold(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
|
||||
plain(&b, "")
|
||||
plain(&b, "gegen")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{parties.defendant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.defendant.representative}}")
|
||||
bold(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
|
||||
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
|
||||
|
||||
heading2(&b, "Betreff")
|
||||
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
|
||||
plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
|
||||
plain(&b, "Projekttitel: {{project.title}}")
|
||||
plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
|
||||
plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
|
||||
plain(&b, "Internes Aktenzeichen: {{project.reference}}")
|
||||
|
||||
heading1(&b, "{{rule.name}}")
|
||||
plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
|
||||
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
||||
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||
|
||||
heading2(&b, "Frist")
|
||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
||||
|
||||
heading2(&b, "Schriftsatztext")
|
||||
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
|
||||
plain(&b, "")
|
||||
plain(&b, "[Body of the submission goes here. This skeleton template carries no pre-baked structure — fill in according to submission type ({{rule.name_en}}).]")
|
||||
|
||||
heading2(&b, "Schlussformel")
|
||||
plain(&b, "{{today.long_de}}")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{user.display_name}}")
|
||||
plain(&b, "{{firm.name}}")
|
||||
|
||||
// Locale-aware verification block — exercises every EN/DE alias the
|
||||
// variable bag carries (today.long_en, deadline.due_date_long_en,
|
||||
// project.our_side_en, project.proceeding.name_en, rule.name_en) and
|
||||
// the bare {{today}} alias. A lawyer customising the template can
|
||||
// delete this block; the renderer round-trips it cleanly today.
|
||||
heading2(&b, "Locale-aware variants (SKELETON)")
|
||||
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
|
||||
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
|
||||
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
||||
plain(&b, "Today (bare alias): {{today}}")
|
||||
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func skeletonBanner(b *strings.Builder) {
|
||||
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — universelle Vorlage (Schriftsatz-Typ-unabhängig, nicht freigegeben)</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
|
||||
|
||||
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
|
||||
|
||||
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
|
||||
|
||||
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
|
||||
|
||||
func bold(b *strings.Builder, text string) {
|
||||
b.WriteString(`<w:p>`)
|
||||
b.WriteString(`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(text))
|
||||
b.WriteString(`</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
func paragraph(b *strings.Builder, style, text string, italic bool) {
|
||||
b.WriteString(`<w:p>`)
|
||||
if style != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(style)
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
for _, seg := range splitOnPlaceholders(text) {
|
||||
b.WriteString(`<w:r>`)
|
||||
if italic {
|
||||
b.WriteString(`<w:rPr><w:i/></w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(seg))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
}
|
||||
|
||||
func splitOnPlaceholders(s string) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
var out []string
|
||||
for {
|
||||
open := strings.Index(s, "{{")
|
||||
if open < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
close := strings.Index(s[open:], "}}")
|
||||
if close < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
end := open + close + 2
|
||||
if open > 0 {
|
||||
out = append(out, s[:open])
|
||||
}
|
||||
out = append(out, s[open:end])
|
||||
s = s[end:]
|
||||
if s == "" {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
568
scripts/seed-example-projects/main.go
Normal file
568
scripts/seed-example-projects/main.go
Normal file
@@ -0,0 +1,568 @@
|
||||
// Seed Example Projects (t-paliad-256 / m/paliad#87).
|
||||
//
|
||||
// Re-runnable test-data reset:
|
||||
//
|
||||
// 1. Wipes every row in paliad.projects (FK CASCADE handles the
|
||||
// dependent rows: deadlines, appointments, parties, notes,
|
||||
// project_events, project_teams, submission_drafts, approval_*,
|
||||
// project_partner_units, user_pinned_projects, documents,
|
||||
// user_calendar_bindings).
|
||||
//
|
||||
// 2. Inserts a small but realistic example tree (3 clients, 4
|
||||
// litigations, 4 patents, 8 cases — 19 projects total) that
|
||||
// exercises the auto-derived chain code: Client.Litigation.Patent.Case
|
||||
// → e.g. SIEMENS.HUAW.789.INF.CFI.
|
||||
//
|
||||
// 3. Re-reads the projects and prints each row's chain code so the
|
||||
// operator can eyeball the result without bouncing to SQL.
|
||||
//
|
||||
// Reference tables (proceeding_types, deadline_rules, event_types,
|
||||
// gerichte, checklists templates, firms, profiles) are untouched.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// DATABASE_URL='postgres://...' go run ./scripts/seed-example-projects
|
||||
//
|
||||
// One transaction wraps both wipe and seed so the DB is never in a
|
||||
// half-wiped state. Re-running drops the previous example tree and
|
||||
// reseeds fresh UUIDs — handy when project-code semantics change.
|
||||
//
|
||||
// Owner: m (matthias.siebels@hoganlovells.com). The script looks the
|
||||
// auth user up by email so it works on any environment where that
|
||||
// account exists; on a brand-new DB it falls back to NULL created_by.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// ownerEmail is the auth.users email the seed assigns as created_by.
|
||||
// Living in code (not a flag) because the example tree is m-owned by
|
||||
// convention; flip if the example data ever needs a service-account
|
||||
// owner.
|
||||
const ownerEmail = "matthias.siebels@hoganlovells.com"
|
||||
|
||||
// Proceeding-type IDs used by the seed. Resolved by code (not pinned
|
||||
// to integer IDs in source) to survive DB renumbering. Loaded once at
|
||||
// startup; missing codes fail fast with a clear message.
|
||||
var proceedingCodes = []string{
|
||||
"upc.inf.cfi",
|
||||
"upc.ccr.cfi",
|
||||
"upc.apl.merits",
|
||||
"de.inf.lg",
|
||||
"epa.opp.opd",
|
||||
"de.null.bpatg",
|
||||
"dpma.opp.dpma",
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := flag.String("dsn", os.Getenv("DATABASE_URL"), "Postgres DSN (defaults to $DATABASE_URL)")
|
||||
dryRun := flag.Bool("dry-run", false, "print intended actions, roll back transaction")
|
||||
flag.Parse()
|
||||
|
||||
if *dsn == "" {
|
||||
fmt.Fprintln(os.Stderr, "seed-example-projects: DATABASE_URL not set and -dsn empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db, err := sqlx.Connect("postgres", *dsn)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "connect:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := run(ctx, db, *dryRun); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "seed-example-projects:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, db *sqlx.DB, dryRun bool) error {
|
||||
ownerID, err := lookupOwner(ctx, db, ownerEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup owner: %w", err)
|
||||
}
|
||||
if ownerID == uuid.Nil {
|
||||
fmt.Printf("note: %s not found in auth.users — created_by will be NULL\n", ownerEmail)
|
||||
} else {
|
||||
fmt.Printf("owner resolved: %s = %s\n", ownerEmail, ownerID)
|
||||
}
|
||||
|
||||
procIDs, err := lookupProceedingTypes(ctx, db, proceedingCodes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup proceeding_types: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }() // no-op if Commit ran first
|
||||
|
||||
if err := wipe(ctx, tx); err != nil {
|
||||
return fmt.Errorf("wipe: %w", err)
|
||||
}
|
||||
|
||||
tree, err := seed(ctx, tx, ownerID, procIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed: %w", err)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("\n--- DRY RUN — rolling back ---")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
fmt.Println("seed committed.")
|
||||
|
||||
if err := report(ctx, db, tree); err != nil {
|
||||
return fmt.Errorf("report: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupOwner(ctx context.Context, db *sqlx.DB, email string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := db.GetContext(ctx, &id, `SELECT id FROM auth.users WHERE email = $1`, email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func lookupProceedingTypes(ctx context.Context, db *sqlx.DB, codes []string) (map[string]int, error) {
|
||||
rows, err := db.QueryxContext(ctx,
|
||||
`SELECT id, code FROM paliad.proceeding_types WHERE code = ANY($1)`,
|
||||
pgTextArray(codes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]int, len(codes))
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var code string
|
||||
if err := rows.Scan(&id, &code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[code] = id
|
||||
}
|
||||
for _, c := range codes {
|
||||
if _, ok := out[c]; !ok {
|
||||
return nil, fmt.Errorf("proceeding_types row missing for code=%q", c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pgTextArray is the lib/pq array adapter, repackaged inline so the
|
||||
// script doesn't need a separate util import.
|
||||
func pgTextArray(xs []string) any {
|
||||
type arr = []string
|
||||
return arr(xs)
|
||||
}
|
||||
|
||||
// wipe deletes every paliad.projects row. FK CASCADE handles the
|
||||
// dependent tables (verified live 2026-05-25 against information_schema:
|
||||
// appointments, approval_requests, approval_policies, deadlines,
|
||||
// documents, notes, parties, project_events, project_partner_units,
|
||||
// project_teams, submission_drafts, user_pinned_projects,
|
||||
// user_calendar_bindings, checklist_shares all cascade; projects.
|
||||
// counterclaim_of and checklist_instances SET NULL; policy_audit_log
|
||||
// SET NULL).
|
||||
//
|
||||
// Reference tables (proceeding_types, deadline_rules, event_types,
|
||||
// gerichte, checklists, firms, partner_units, profiles) are not
|
||||
// referenced from this delete.
|
||||
func wipe(ctx context.Context, tx *sqlx.Tx) error {
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM paliad.projects`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
fmt.Printf("wiped: %d project rows (FK CASCADE handled dependents)\n", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// seededNode is one row of the seed result, kept so we can print the
|
||||
// chain code after commit without re-querying for IDs.
|
||||
type seededNode struct {
|
||||
id uuid.UUID
|
||||
title string
|
||||
}
|
||||
|
||||
// seed inserts the example tree. Order matters because parent_id FKs
|
||||
// must already exist — clients first, then litigations under them, then
|
||||
// patents, then cases (with the CCR case referencing its sibling
|
||||
// Klage case via counterclaim_of).
|
||||
func seed(ctx context.Context, tx *sqlx.Tx, ownerID uuid.UUID, procIDs map[string]int) ([]seededNode, error) {
|
||||
var nodes []seededNode
|
||||
|
||||
insertProject := func(p projectInsert) (uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
var createdBy any
|
||||
if ownerID != uuid.Nil {
|
||||
createdBy = ownerID
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO paliad.projects (
|
||||
id, type, parent_id, title, reference, description, status,
|
||||
created_by, industry, country, client_number, matter_number,
|
||||
patent_number, filing_date, grant_date,
|
||||
court, case_number, proceeding_type_id,
|
||||
our_side, opponent_code, instance_level, counterclaim_of
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, 'active',
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, $14,
|
||||
$15, $16, $17,
|
||||
$18, $19, $20, $21
|
||||
)`,
|
||||
id, p.Type, nullUUID(p.ParentID), p.Title, nullStr(p.Reference), nullStr(p.Description),
|
||||
createdBy, nullStr(p.Industry), nullStr(p.Country), nullStr(p.ClientNumber), nullStr(p.MatterNumber),
|
||||
nullStr(p.PatentNumber), nullDate(p.FilingDate), nullDate(p.GrantDate),
|
||||
nullStr(p.Court), nullStr(p.CaseNumber), nullInt(p.ProceedingTypeID),
|
||||
nullStr(p.OurSide), nullStr(p.OpponentCode), nullStr(p.InstanceLevel), nullUUID(p.CounterclaimOf),
|
||||
)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert %s %q: %w", p.Type, p.Title, err)
|
||||
}
|
||||
nodes = append(nodes, seededNode{id: id, title: p.Title})
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// --- Client 1: Siemens AG ----------------------------------------
|
||||
siemens, err := insertProject(projectInsert{
|
||||
Type: "client", Title: "Siemens AG", Reference: "SIEMENS",
|
||||
Industry: "Telekommunikation / Industrieelektronik", Country: "DE",
|
||||
Description: "Beispiel-Mandant — Telekommunikation & Halbleiter.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensHuawei, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: siemens,
|
||||
Title: "Siemens ./. Huawei Technologies", OpponentCode: "HUAW",
|
||||
Description: "Patentstreit Mobilfunk-Standardpatent.", OurSide: "claimant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensHuaweiPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: siemensHuawei,
|
||||
Title: "EP3456789 — Funkkommunikationssystem mit Mehrfachantenne",
|
||||
PatentNumber: "EP3456789",
|
||||
FilingDate: "2018-03-12", GrantDate: "2022-11-09",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
upcInfCFI, err := insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensHuaweiPatent,
|
||||
Title: "UPC CFI München — Klage Siemens ./. Huawei (EP3456789)",
|
||||
Court: "UPC Lokalkammer München",
|
||||
CaseNumber: "UPC_CFI_123/2026",
|
||||
ProceedingTypeID: procIDs["upc.inf.cfi"],
|
||||
OurSide: "claimant",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensHuaweiPatent,
|
||||
Title: "UPC CFI München — Widerklage Huawei ./. Siemens (EP3456789)",
|
||||
Court: "UPC Lokalkammer München",
|
||||
CaseNumber: "UPC_CFI_123/2026 (CCR)",
|
||||
ProceedingTypeID: procIDs["upc.ccr.cfi"],
|
||||
OurSide: "defendant", // we're respondent on the CCR
|
||||
InstanceLevel: "first",
|
||||
CounterclaimOf: upcInfCFI,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensHuaweiPatent,
|
||||
Title: "UPC Berufungsgericht — Berufung Huawei (EP3456789)",
|
||||
Court: "UPC Court of Appeal",
|
||||
CaseNumber: "UPC_CoA_45/2027",
|
||||
ProceedingTypeID: procIDs["upc.apl.merits"],
|
||||
OurSide: "respondent",
|
||||
InstanceLevel: "appeal",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensBosch, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: siemens,
|
||||
Title: "Siemens ./. Robert Bosch GmbH", OpponentCode: "BOSCH",
|
||||
Description: "Sensorik / autonomes Fahren.", OurSide: "claimant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensBoschPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: siemensBosch,
|
||||
Title: "EP1111222 — Sensoreinrichtung für autonomes Fahren",
|
||||
PatentNumber: "EP1111222",
|
||||
FilingDate: "2017-06-21", GrantDate: "2021-08-04",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensBoschPatent,
|
||||
Title: "LG München I — Klage Siemens ./. Bosch (EP1111222)",
|
||||
Court: "Landgericht München I",
|
||||
CaseNumber: "7 O 12345/26",
|
||||
ProceedingTypeID: procIDs["de.inf.lg"],
|
||||
OurSide: "claimant",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Client 2: Bayer AG ------------------------------------------
|
||||
bayer, err := insertProject(projectInsert{
|
||||
Type: "client", Title: "Bayer AG", Reference: "BAYER",
|
||||
Industry: "Pharma / Life Sciences", Country: "DE",
|
||||
Description: "Beispiel-Mandant — pharmazeutische Wirkstoffe.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bayerNova, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: bayer,
|
||||
Title: "Bayer ./. Novartis Pharma", OpponentCode: "NOVA",
|
||||
Description: "Wirkstoffverbindung X — Einspruch + Nichtigkeit.", OurSide: "claimant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bayerNovaPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: bayerNova,
|
||||
Title: "EP2222333 — Wirkstoffverbindung X",
|
||||
PatentNumber: "EP2222333",
|
||||
FilingDate: "2015-09-30", GrantDate: "2020-04-22",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: bayerNovaPatent,
|
||||
Title: "EPA Einspruch — Novartis ./. EP2222333",
|
||||
Court: "Europäisches Patentamt — Einspruchsabteilung",
|
||||
CaseNumber: "OPP-2026-0042",
|
||||
ProceedingTypeID: procIDs["epa.opp.opd"],
|
||||
OurSide: "respondent", // Bayer is patent owner defending the patent
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: bayerNovaPatent,
|
||||
Title: "BPatG — Nichtigkeitsklage Novartis ./. EP2222333",
|
||||
Court: "Bundespatentgericht",
|
||||
CaseNumber: "5 Ni 12/26",
|
||||
ProceedingTypeID: procIDs["de.null.bpatg"],
|
||||
OurSide: "respondent",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Client 3: Beispiel AG (intentionally sparse) ----------------
|
||||
// Demonstrates the empty-segment skip in BuildProjectCode — the
|
||||
// case row has a proceeding_type set so the tail is present, but
|
||||
// no instance_level / our_side, and the patent's number is national
|
||||
// (DE) so the last-3-digits segment shows DE-style behaviour.
|
||||
beispiel, err := insertProject(projectInsert{
|
||||
Type: "client", Title: "Beispiel AG", Reference: "BEISPL",
|
||||
Industry: "Unspezifiziert", Country: "DE",
|
||||
Description: "Sparse-Beispiel — zeigt, wie fehlende Segmente übersprungen werden.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
beispielWtb, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: beispiel,
|
||||
Title: "Beispiel ./. Wettbewerber GmbH", OpponentCode: "WTB",
|
||||
Description: "Demo-Litigation ohne große Detailtiefe.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
beispielWtbPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: beispielWtb,
|
||||
Title: "DE10987654 — Demo-Erfindung",
|
||||
PatentNumber: "DE10987654",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: beispielWtbPatent,
|
||||
Title: "DPMA Einspruch — Wettbewerber ./. DE10987654",
|
||||
Court: "Deutsches Patent- und Markenamt",
|
||||
CaseNumber: "DPMA-EIN-987/26",
|
||||
ProceedingTypeID: procIDs["dpma.opp.dpma"],
|
||||
OurSide: "respondent",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Printf("seeded: %d projects\n", len(nodes))
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// projectInsert is the typed input for one insertProject call. Pointer
|
||||
// fields are kept as plain strings here and converted via nullStr at
|
||||
// bind time; keeps the call sites readable.
|
||||
type projectInsert struct {
|
||||
Type string
|
||||
ParentID uuid.UUID
|
||||
Title string
|
||||
Reference string
|
||||
Description string
|
||||
Industry string
|
||||
Country string
|
||||
ClientNumber string
|
||||
MatterNumber string
|
||||
PatentNumber string
|
||||
FilingDate string // YYYY-MM-DD
|
||||
GrantDate string
|
||||
Court string
|
||||
CaseNumber string
|
||||
ProceedingTypeID int
|
||||
OurSide string
|
||||
OpponentCode string
|
||||
InstanceLevel string
|
||||
CounterclaimOf uuid.UUID
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullInt(i int) any {
|
||||
if i == 0 {
|
||||
return nil
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func nullUUID(u uuid.UUID) any {
|
||||
if u == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func nullDate(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// reportRow is one row of the post-seed report — only the fields the
|
||||
// printout needs.
|
||||
type reportRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Type string `db:"type"`
|
||||
Title string `db:"title"`
|
||||
Path string `db:"path"`
|
||||
}
|
||||
|
||||
// report prints the seeded tree with the auto-derived chain code for
|
||||
// each row. Uses services.BuildProjectCode so the script verifies the
|
||||
// same helper the live app uses (catches drift if the algorithm
|
||||
// changes).
|
||||
func report(ctx context.Context, db *sqlx.DB, _ []seededNode) error {
|
||||
var rows []reportRow
|
||||
err := db.SelectContext(ctx, &rows, `
|
||||
SELECT id, type, title, path
|
||||
FROM paliad.projects
|
||||
ORDER BY path
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\nresulting chain codes:")
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "TYPE\tTITLE\tCODE")
|
||||
for _, r := range rows {
|
||||
code, err := services.BuildProjectCode(ctx, db, r.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build code for %s: %w", r.ID, err)
|
||||
}
|
||||
indent := strings.Repeat(" ", pathDepth(r.Path)-1)
|
||||
fmt.Fprintf(tw, "%s\t%s%s\t%s\n", r.Type, indent, r.Title, code)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func pathDepth(p string) int {
|
||||
if p == "" {
|
||||
return 1
|
||||
}
|
||||
d := 1
|
||||
for _, c := range p {
|
||||
if c == '.' {
|
||||
d++
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
Reference in New Issue
Block a user