Compare commits
18 Commits
mai/atlas/
...
mai/pasteu
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c840c0366 | |||
| 1844df3ae6 | |||
| 0f3c30a647 | |||
| 2c2b93bc7c | |||
| 661d87273c | |||
| ed3c5d1f32 | |||
| be570c2fd0 | |||
| 58692513a8 | |||
| 702f786771 | |||
| 93c664c865 | |||
| 6506d7d862 | |||
| 73f49c46ed | |||
| c80723fc85 | |||
| 1ed75c56e3 | |||
| 2e6427dca6 | |||
| 7945bfb364 | |||
| bfb38aab41 | |||
| 9fe06094a8 |
@@ -246,6 +246,10 @@ func main() {
|
||||
// SSoT. Drives Verfahrensablauf + Mode B result-view conditional
|
||||
// rendering and per-rule selection state (`rule:<uuid>` keys).
|
||||
ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc),
|
||||
// t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder.
|
||||
// CRUD over the new normalised scenarios + scenario_proceedings
|
||||
// + scenario_events + scenario_shares tables.
|
||||
ScenarioBuilder: services.NewScenarioBuilderService(pool),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
|
||||
685
docs/plans/prd-procedures-litigation-planner-2026-05-27.md
Normal file
685
docs/plans/prd-procedures-litigation-planner-2026-05-27.md
Normal file
@@ -0,0 +1,685 @@
|
||||
# PRD — Procedures: Litigation Builder (m/paliad#153)
|
||||
|
||||
**Task:** t-paliad-339
|
||||
**Gitea:** m/paliad#153
|
||||
**Inventor:** edison (shift-1, Opus)
|
||||
**Date:** 2026-05-27
|
||||
**Branch:** `mai/edison/inventor-prd-columnar`
|
||||
**Status:** Draft — DESIGN READY FOR REVIEW. Coder gate held.
|
||||
|
||||
**Builds on (read before extending this PRD):**
|
||||
|
||||
- `docs/design-procedures-workflow-tracker-2026-05-27.md` — atlas's reverted tracker design (m/paliad#152). The anchor+scope idea did not land; understand *why* before re-proposing.
|
||||
- `docs/design-unified-procedural-events-tool-2026-05-27.md` — cronus's U0-U4 catalog, currently live on main @ `ed3c5d1` post-revert. Visual baseline for filter strip + tab control.
|
||||
- `docs/design-deadline-system-revision-2026-05-27.md` — atlas Phase 2 model layer (scenario_flags SSoT, view-mode toggle, per-rule selection chips). Model layer is locked; this PRD is purely surface + new persistence tables.
|
||||
- `docs/design-fristenrechner-overhaul-2026-05-26.md` — cronus 2026-05-26 inventor-pass (Mode A + B + result, shipped via t-paliad-322).
|
||||
|
||||
**Predecessor takeaway (atlas's debrief on #152):**
|
||||
|
||||
> "When the architecture is novel, default to grilling m in prose FIRST. The doc rewrites cost a commit; the bigger cost would have been wasting m's question-batch on the wrong architecture."
|
||||
|
||||
Followed here. This PRD captures the architecture m chose through **20 chip-picker decisions across 5 batches**, not an inventor-first strawman.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises
|
||||
|
||||
### §0.1 What is `/tools/procedures` today (live, post-revert)
|
||||
|
||||
The current page is cronus's 4-tab catalog (U0-U4, shipped via m/paliad#151):
|
||||
|
||||
- Sticky filter strip (search box + 4 chip rows: Forum / Verfahren / Ereignisart / Partei).
|
||||
- 4 solid tabs: `Verfahren wählen` / `Direkt suchen` / `Geführt` / `Aus Akte`.
|
||||
- Default-active tab = "Verfahren wählen" renders `VerfahrensablaufBody` (the legacy Verfahrensablauf wizard: proceeding picker → perspective + date → 3-step wizard → result in 3-column "Spalten" or single-column "Zeitstrahl").
|
||||
- Other 3 tab panels are stubs (search/wizard/akte never wired in U0-U3).
|
||||
|
||||
m's blocking feedback (verbatim, 2026-05-27 22:18):
|
||||
|
||||
> I like to keep our current columnar layout with proactive / court / reactive. And it is good if we can select which side we want to simulate. […] There are basically three main approaches I see to this: Get an overview over proceedings, play around with options, build Scenarios. Another one where something specific happened and we just want to know what deadlines we need to note […]. A third one from a specific proceeding / case file where things take place / have taken place.
|
||||
|
||||
And the architecture-shifting follow-ups (2026-05-27 22:35-22:36, mid-grilling):
|
||||
|
||||
> I would prefer to have an interface where not every constellation is in the URL by the way. That seems limiting.
|
||||
> We could just have a litigation builder. Sometimes we build a full scenario with multiple instances etc, sometimes we just want the next step.
|
||||
> we should have ways to save these "litigation constellations" where we save which proceedings we have and which state they are in, which submissions were or were not filed. A small Scenario DB could work, dont you think?
|
||||
|
||||
These three statements upgraded the brief from "redesign a catalog" to "build a Litigation Builder backed by a Scenario DB". The PRD below is shaped by them.
|
||||
|
||||
### §0.2 Locked constraints (m's words, brief in #153)
|
||||
|
||||
- Columnar layout: `proaktiv | court | reaktiv` (perspective-flippable).
|
||||
- Three approaches as entry modes: overview/scenarios, event-triggered, case-file driven.
|
||||
- Filtering across all dimensions + text search.
|
||||
- Optional follow-ups: toggleable, highlightable, with display-count setting.
|
||||
- Modular *where it actually helps* (m: "I don't know — generally does not super apply here." — drop modular as a load-bearing goal).
|
||||
- UPC v1, expand later.
|
||||
|
||||
### §0.3 Live data the builder works against
|
||||
|
||||
Verified 2026-05-27 against `paliad.sequencing_rules` (231 published / 242 total):
|
||||
|
||||
- 110 chained (`parent_id` not null).
|
||||
- 78 trigger-rooted, 4 spawns (cross-PT), 47 court-set, 18 conditional.
|
||||
- ~46 proceeding types total (UPC 35 / DE 5 / EPA 3 / DPMA 3). v1 focuses on UPC.
|
||||
- `paliad.proceeding_types.kind` discriminator (atlas's t-paliad-324) filters non-proceeding rows (phases/side_actions/meta) from the picker.
|
||||
- `paliad.deadlines` carries both `procedural_event_id` and `sequencing_rule_id` → Akte actuals overlay is a direct join.
|
||||
- `paliad.projects.scenario_flags` jsonb (atlas P0) is the SSoT for project-level scenario state; the new `paliad.scenario_proceedings.scenario_flags` mirrors this shape per-proceeding-per-scenario.
|
||||
|
||||
### §0.4 Scope (in / out)
|
||||
|
||||
**In:**
|
||||
|
||||
- Replace `/tools/procedures` with the Litigation Builder.
|
||||
- New `paliad.scenarios` + `paliad.scenario_proceedings` + `paliad.scenario_events` + `paliad.scenario_shares` tables.
|
||||
- Promote-to-project flow (scenario → `paliad.projects` row).
|
||||
- Bidirectional link from `/projects/{id}` (button: "Im Builder öffnen" — exports project state to a builder session).
|
||||
|
||||
**Out (deferred or owned elsewhere):**
|
||||
|
||||
- Calculator (`pkg/litigationplanner.CalculateRule`) — working.
|
||||
- Editorial backfill (curie's t-paliad-333 owns the 7 compound rules + R.109).
|
||||
- `/admin/procedural-events` (editor surface; different audience).
|
||||
- `/projects/{id}` Verlauf / SmartTimeline (per-Akte actuals; sister tool).
|
||||
- youpc.org / Outlook / PDF export.
|
||||
- Multi-jurisdiction expansion (DE/EPA/DPMA) — UPC v1 first.
|
||||
- Cross-proceeding peer triggers (UPC-inf judgment → EPA opp choice deadline) — v1.1.
|
||||
- Multi-user concurrent editing on the same scenario (out of scope; sharing is read-only).
|
||||
|
||||
---
|
||||
|
||||
## §1 Goals
|
||||
|
||||
1. **One canvas, three entry modes.** Unify the 3 approaches into a single Litigation Builder surface. The entry modes (`Übersicht / Ereignis / Aus Akte`) shape the *initial* state of the canvas; once the user is working, the canvas itself is what they interact with.
|
||||
2. **Persisted constellations.** A user can save a "litigation constellation" — multiple parallel proceedings with their flags, filed/skipped/planned event states, dates, and notes — as a named scenario. Scenarios live in the DB (not the URL).
|
||||
3. **Auto-save by default.** No "unsaved changes" modals. The active scenario auto-persists. Anonymous scratch scenarios convert to named ones when the user clicks "Benennen".
|
||||
4. **Promote-to-project.** A scenario can be turned into a real `paliad.projects` row via a 3-step wizard. Procedural shape, placeholder parties, notes, and filed-state all carry over; the user fleshes out client-bound metadata during the wizard.
|
||||
5. **Share read-only with the team.** Each scenario is private by default; explicit "An Team teilen" grants named HLC users read-only access. Original owner stays sole editor.
|
||||
6. **Columnar geometry restored.** The current "Spalten" view (claimant | court | defendant) returns as the canonical render — but now per-proceeding-triplet within a scenario, with perspective ("our side") flippable per proceeding so `proaktiv | court | reaktiv` reads correctly across multi-proceeding constellations.
|
||||
7. **Per-event-card optional horizon.** Each event card on the canvas can dial in how many optional follow-ups to surface. Cards are the unit of optional-display control.
|
||||
|
||||
---
|
||||
|
||||
## §2 User journeys
|
||||
|
||||
### §2.1 Journey A — Cold-open builder ("Übersicht / Scenarios")
|
||||
|
||||
**Persona:** Dr. Becker, senior partner. Friday afternoon. New UPC matter not yet committed; she's briefing a client on Monday on the full procedural shape.
|
||||
|
||||
1. Opens `/tools/procedures`. No `?scenario` param. Cold-open canvas: empty workbench with a "Neues Szenario starten" CTA and a short list of her 5 most-recent scenarios.
|
||||
2. Clicks the CTA → inline picker (Forum chip row → Verfahren chip row → `Hinzufügen`). Picks UPC + `upc.inf.cfi`.
|
||||
3. Canvas now renders one proceeding triplet (`proaktiv | court | reaktiv`). Default perspective is empty (no party selected) — both sides render equally; the perspective radio in the page header sits unset.
|
||||
4. She picks defendant perspective at the page header → triplet flips. The defendant column becomes `proaktiv` (her side); claimant becomes `reaktiv`.
|
||||
5. She adds a second proceeding via `+ Verfahren hinzufügen` at the bottom: EPA `epa.opp.opd`. New triplet stacks below the first. New triplet's perspective defaults to "patentee" inheriting from her client's role across the two; she flips per-proceeding via the triplet header.
|
||||
6. She turns on `with_ccr` on the UPC inf triplet's per-proceeding flag strip. The CCR child triplet auto-expands inline below the parent at the spawn node.
|
||||
7. Auto-save kicks in (debounced 500ms). The page header shows "Gespeichert in Scratch · Benennen".
|
||||
8. She clicks "Benennen", enters "Becker — UPC + EPA defensive". Side panel "Meine Szenarien" updates.
|
||||
9. On Monday she opens the scenario from her recent list, walks the client through it, hits "Als Projekt anlegen" (when the client commits). 3-step wizard fires (§5.4).
|
||||
|
||||
### §2.2 Journey B — Event-triggered lookup ("Ereignis")
|
||||
|
||||
**Persona:** Sandra, paralegal. Today: a Hinweisbeschluss arrived on a CMS queue. She doesn't know yet which Akte it belongs to.
|
||||
|
||||
1. Opens `/tools/procedures`. Picks "Ereignis" entry mode at the top.
|
||||
2. Page-header search box auto-focuses. She types "Hinweis" → universal search drops down: `5 Ereignisse · 1 Szenario · 0 Akten`. Picks the event `upc.inf.cfi.cmo_review` (Antrag CMO-Überprüfung).
|
||||
3. Canvas renders one triplet of `upc.inf.cfi` with the Hinweisbeschluss event card auto-anchored (lime band + `━━ DU BIST HIER ━━` divider above the next-coming events).
|
||||
4. She reads the follow-ups: "Antrag auf CMO-Überprüfung (claimant, R.333.2 · 1 Monat)" and 2 optional follow-ups. The Stichtag input in the page header defaults to today; she leaves it.
|
||||
5. She doesn't save anything — this was a quick lookup. Scratch scenario auto-persists but she doesn't name it; it'll fall off her recent list after a while.
|
||||
6. Later she identifies the matter (HL-2024-001), switches to "Aus Akte" mode, and continues there.
|
||||
|
||||
### §2.3 Journey C — Case-file driven ("Aus Akte")
|
||||
|
||||
**Persona:** Anna, senior associate. Working on HL-2024-001 (UPC infringement). The client just confirmed they want to file a CCR.
|
||||
|
||||
1. Opens `/tools/procedures`. Page-header Akte picker shows recent projects; she picks HL-2024-001.
|
||||
2. Page header auto-fills: proceeding = `upc.inf.cfi`, perspective = defendant (from `projects.our_side`), scenario_flags = `{with_ccr: false}` (current state).
|
||||
3. Builder loads: one `upc.inf.cfi` triplet, perspective-flipped. Event cards overlay actuals from `paliad.deadlines` — `Klageerhebung` is filed (2026-01-15), `Klageerwiderung` is planned (2026-04-01, computed), others are planned.
|
||||
4. She turns on `with_ccr` on the triplet's flag strip. The CCR child triplet expands inline. **Crucially:** the scenario is *project-backed* — the flag write also patches `projects.scenario_flags` (via existing `PATCH /api/projects/{id}/scenario-flags` from atlas P0). When she walks away, the project's deadlines + flags reflect the builder's state.
|
||||
5. She marks the `Widerklage auf Nichtigkeit` event card as "filed" with today's date. Builder writes a `paliad.deadlines` row with `status='done'` + `completed_at=today`, audit_reason "via Litigation Builder". Project's Verlauf reflects this.
|
||||
6. The CCR child triplet's `Antrag Patentänderung (R.30)` event card surfaces. She marks it "planned" and ticks the per-card optional horizon to "+2" → 2 more optional R.30-adjacent rules surface.
|
||||
7. Exit: she closes the tab. Project state persists in `paliad.projects` + `paliad.deadlines` as before; the scenario row tracks the builder-session view (so when she returns, the canvas state is restored — including her per-card optional-horizon picks).
|
||||
|
||||
### §2.4 Journey D — Promote scratch to a real project
|
||||
|
||||
**Persona:** Dr. Becker, follow-up from Journey A. The client committed; she wants to convert the scenario into a real matter.
|
||||
|
||||
1. With "Becker — UPC + EPA defensive" loaded, she clicks "Als Projekt anlegen" in the page header.
|
||||
2. **Wizard step 1: Bestätigen.** Read-only summary of what's about to be promoted: 2 proceedings (UPC inf + EPA opp), CCR child, 3 scenario flags set, 0 events filed, 5 events planned, 2 notes. "Weiter".
|
||||
3. **Wizard step 2: Parteien ergänzen.** Each proceeding's parties section shows whatever placeholder names she sketched in the scenario ("Klg X" / "Bekl Y"). She edits each into the real names. (Per m's Q11 pick — full carry — placeholder strings come in; the wizard's job is to clean them.)
|
||||
4. **Wizard step 3: Akte-Metadaten.** Case number, client, litigation parent project (optional), our_side (auto-set from the scenario's primary triplet), team selection. "Anlegen".
|
||||
5. New `paliad.projects` row written with `origin_scenario_id = <scenario.id>`. Scenario row's `status` flips to `promoted`, `promoted_project_id` points back. Builder navigates to `/projects/<new-id>`.
|
||||
6. The scenario stays read-only in her "Meine Szenarien" list under "Promoted", reachable for historical reference (cf. "this is what we planned at briefing time").
|
||||
|
||||
### §2.5 Journey E — Share a scenario with a colleague
|
||||
|
||||
**Persona:** Anna shares the HL-2024-001 builder session with Dr. Becker (her supervising partner) for review before committing to the CCR strategy.
|
||||
|
||||
1. Anna opens the scenario, clicks "Teilen" in the page header.
|
||||
2. Side panel slides in with a user-picker (HLC user search). She picks "Dr. Becker", clicks "Schreibgeschützt teilen".
|
||||
3. `paliad.scenario_shares` row written. Anna remains sole editor.
|
||||
4. Dr. Becker opens the tool. Her side panel "Meine Szenarien" has a new bucket "Geteilt mit mir"; Anna's scenario is listed. She opens it: canvas renders the same view but every mutating affordance (add proceeding, flag toggle, file/skip, promote, share) is disabled. Watermark: "Geteilt von Anna · schreibgeschützt".
|
||||
5. Becker reads, drops Anna a note via existing comment infrastructure (out of scope — separate ticket). Decision made out-of-band. Anna proceeds.
|
||||
|
||||
---
|
||||
|
||||
## §3 The canvas shape
|
||||
|
||||
### §3.1 ASCII sketch
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Paliad · Verfahren & Fristen — Litigation Builder [Mein Konto ▾] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Szenario: [Becker — UPC + EPA def. ▼] Gespeichert ✓ · [Benennen] [Teilen] [Als Projekt] │
|
||||
│ Akte: [— ohne — ▼] Stichtag: [2026-04-01] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Filter: [🔍 Klageerwiderung, Hinweis, HL-2024… ] │
|
||||
│ Forum [● UPC] [DE] [EPA] [DPMA] Verfahren [● upc.inf.cfi …] │
|
||||
│ Partei [Klg] [● Bekl] Ereignisart [filing] [hearing] [decision] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Einstieg: [ Übersicht ● ][ Ereignis ○ ][ Aus Akte ○ ] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─ upc.inf.cfi · Verletzungsverfahren UPC Bekl-Sicht [▾] [Detailgrad: Gewählt ▾]│
|
||||
│ │ Optionen: ☑ with_ccr ☐ with_amend ☐ with_cci [─][×] │
|
||||
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
|
||||
│ │ Proaktiv (Bekl) │ Gericht │ Reaktiv (Klg) │
|
||||
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||
│ │ │ Klageerw. │ │ │ │ Klageerh. │ │
|
||||
│ │ │ R.23 │ │ │ │ R.13 │ │
|
||||
│ │ │ planned │ │ │ │ filed │ │
|
||||
│ │ │ 2026-04-01 │ │ │ │ 2026-01-15 │ │
|
||||
│ │ │ +3 Optionen ▾│ │ │ │ │ │
|
||||
│ │ └─────────────┘ │ │ └─────────────┘ │
|
||||
│ │ │ ┌──────────────┐ │ │
|
||||
│ │ │ │ Mündl. Verh. │ │ │
|
||||
│ │ │ │ planned │ │ │
|
||||
│ │ │ │ [Gericht] │ │ │
|
||||
│ │ │ └──────────────┘ │ │
|
||||
│ │ ━━━━━━━━━ DU BIST HIER (Klageerwiderung) ━━━━━━━━━ │
|
||||
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌── (spawn child) upc.ccr.cfi · Widerklage auf Nichtigkeit Klg-Sicht [▾] ───────┐│
|
||||
│ │ Optionen: ☐ with_amend [─][×]││
|
||||
│ ├────────────────────┬─────────────────┬───────────────────────────────────────┐ ││
|
||||
│ │ Proaktiv (Klg) │ Gericht │ Reaktiv (Bekl) │ ││
|
||||
│ │ ┌─────────────┐ │ │ │ ││
|
||||
│ │ │ CCR-Antrag │ │ │ │ ││
|
||||
│ │ │ R.49 │ │ │ │ ││
|
||||
│ │ │ planned │ │ │ │ ││
|
||||
│ │ └─────────────┘ │ │ │ ││
|
||||
│ └────────────────────┴─────────────────┴───────────────────────────────────────┘ ││
|
||||
│ │
|
||||
│ ┌─ epa.opp.opd · Einspruchsverfahren EPA PatInh-Sicht [▾] [Detailgrad: Gewählt ▾]│
|
||||
│ │ Optionen: (keine flags für EPA Opp) [─][×] │
|
||||
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
|
||||
│ │ Proaktiv │ EPA │ Reaktiv (Einsprechende) │
|
||||
│ │ ┌─────────────┐ │ │ │
|
||||
│ │ │ Erwiderung │ │ │ │
|
||||
│ │ │ R.79(1) EPÜ │ │ │ │
|
||||
│ │ │ planned │ │ │ │
|
||||
│ │ └─────────────┘ │ │ │
|
||||
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [ + Verfahren hinzufügen ] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Side panel (collapsible, right-edge):
|
||||
┌──── Meine Szenarien ────┐
|
||||
│ ● Aktiv │
|
||||
│ ▸ Becker — UPC+EPA def │ ← current
|
||||
│ ▸ Test-CCR-Patent-X │
|
||||
│ ○ Geteilt mit mir │
|
||||
│ ▸ Becker UPC ply │
|
||||
│ ○ Promoted │
|
||||
│ ▸ HL-2023-118 │
|
||||
│ ○ Archiviert (3) │
|
||||
│ [+ Neues Szenario] │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### §3.2 What each element does
|
||||
|
||||
| Element | Read | Write | Persists in |
|
||||
|---|---|---|---|
|
||||
| Page-header scenario picker | Current `scenarios.id` + `name` | Switch scenarios | URL `?scenario=<id>` + DB |
|
||||
| `[Benennen]` button | Anonymous → named | `scenarios.name`, `status='active'` | DB |
|
||||
| `[Teilen]` button | — | `scenario_shares` row(s) | DB |
|
||||
| `[Als Projekt]` button | — | Opens promote wizard | (wizard → DB on commit) |
|
||||
| Akte picker | User's projects | Loads project state into builder | URL `?project=<id>` + DB |
|
||||
| Stichtag input | Scenario-level default | `scenarios.stichtag` | DB |
|
||||
| Filter strip (search + chips) | Free-text + dimension filters | UI state | URL `?q`, `?forum`, … per-mode |
|
||||
| Einstieg mode radio | Current entry mode | Resets filter strip on change | URL `?mode=` |
|
||||
| Triplet header (jurisdiction badge + name + perspective + Detailgrad) | `scenario_proceedings.{primary_party, detailgrad}` | Edit | DB |
|
||||
| Triplet flag strip | `scenario_proceedings.scenario_flags` | Toggle flags | DB |
|
||||
| Event card (state, date, notes, optional-horizon) | `scenario_events.*` | Edit per-card | DB |
|
||||
| `+ Verfahren hinzufügen` | — | New `scenario_proceedings` row | DB |
|
||||
| Side panel | User's scenarios + shared scenarios | Switch + create + archive | DB |
|
||||
|
||||
### §3.3 Columns: `proaktiv | court | reaktiv`
|
||||
|
||||
The 3-column layout returns as the canonical desktop shape. Per m's locked constraint (and brief #153), it is a **stance grouping**, not a sequence anchor — time flows top-to-bottom (chronological), columns express *who is acting*.
|
||||
|
||||
- **Proaktiv**: the column for events the active perspective's party initiates (their `primary_party` matches the event's `primary_party`).
|
||||
- **Court**: court-set events (`is_court_set=true`), neutral column.
|
||||
- **Reaktiv**: the column for events the opposing party initiates.
|
||||
|
||||
The perspective is per-proceeding (per-triplet, via `scenario_proceedings.primary_party`). When no perspective is set (`null`), both party columns render equally with their natural party labels (Klg / Bekl), not Proaktiv / Reaktiv. This means kontextfrei browsing reads as "claimant column | court | defendant column" until the user picks a side.
|
||||
|
||||
This addresses m's reverted-design bug #3 verbatim: "Proaktiv/Gericht/Reaktiv columns are a stance grouping, not a sequence anchor." Time = vertical. Stance = horizontal. The triplet is the unit; multiple proceedings stack vertically.
|
||||
|
||||
### §3.4 Event card anatomy
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Klageerwiderung │ ← event name (procedural_event.name)
|
||||
│ R.23 │ ← rule code
|
||||
│ planned │ ← state: planned / filed / skipped
|
||||
│ 2026-04-01 │ ← date (computed for planned, actual for filed)
|
||||
│ +3 Optionen ▾ │ ← per-card optional horizon (only when card has optionals)
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
State machine (m's Q10 pick — 3-state):
|
||||
|
||||
- `planned` (default): future event, date is computed from anchor + duration_value + duration_unit. Click → choose `filed` or `skipped`.
|
||||
- `filed`: past event, `actual_date` is set (defaults to computed, user can override). Visual: ✓ checkmark, slightly muted "past" tone.
|
||||
- `skipped`: user chose not to file. Visual: strikethrough text + optional `skip_reason` (textarea). Optional rules are commonly skipped without rationale; mandatory rules with `skipped` state flag the scenario as "non-standard" but don't block.
|
||||
|
||||
No `overdue` state — user does the date arithmetic by eye against today. (Mandatory cards rendered in red when `actual_date < today AND state=planned` is a **render hint**, not a stored state.)
|
||||
|
||||
**Per-card optional horizon (m's Q4 pick).** Each card with children at `priority IN ('optional','recommended-skip-by-default')` carries a chip `+N Optionen ▾`. Default N=0 (hidden). Clicking opens an inline list of the optional children with `+`/`-` controls to surface/hide them on the canvas. Per-card horizon persists as `scenario_events.horizon_optional int`.
|
||||
|
||||
Filed-state cards persist the date in `scenario_events.actual_date date`. The card's notes field (textarea, lazy-loaded) lives in `scenario_events.notes text`.
|
||||
|
||||
### §3.5 Court-set events
|
||||
|
||||
`is_court_set=true` rules don't compute a date until the court picks one. Card renders with `[Gericht]` badge in place of the date and a small "Datum eintragen" affordance. Clicking `filed` opens a date picker (date is required for `filed` state when `is_court_set=true` — the user is asserting "the court set this date").
|
||||
|
||||
Downstream events that anchor on a court-set event render their dates as `[abhängig von <event>]` until the court date is filed, then auto-recompute.
|
||||
|
||||
### §3.6 Spawn (child) proceedings
|
||||
|
||||
When a triplet has a `with_<flag>` enabled and the flag's gating rule has `is_spawn=true`, the child proceeding (e.g. `upc.ccr.cfi` for `with_ccr` on `upc.inf.cfi`) renders inline as a child triplet **immediately below the parent triplet** in the canvas stack — visually nested via the spawn note in the parent triplet's header band.
|
||||
|
||||
`scenario_proceedings.parent_scenario_proceeding_id` FK self-references for the nesting; `scenario_proceedings.spawn_anchor_event_id` points at the gating sequencing_rule so the UI knows where in the parent the spawn happened.
|
||||
|
||||
The child triplet has its own perspective, scenario flags, Stichtag override, Detailgrad. It can itself spawn (depth N supported; today's data is 2-deep at most).
|
||||
|
||||
Cross-proceeding peer triggers (`upc.inf judgment → epa.opp choice deadline`) are **out of scope for v1** (m's Q14 pick). v1 ships independent triplets stacked vertically; the user mentally tracks cross-dependencies. A future `scenario_event_links` table is the path to peer triggers in v1.1.
|
||||
|
||||
---
|
||||
|
||||
## §4 Hard decisions table — m's 20 picks
|
||||
|
||||
| # | Topic | Pick | Locks |
|
||||
|---|---|---|---|
|
||||
| Q1 | Modular meaning | "doesn't super apply" — drop modular as a load-bearing goal | §0.2 |
|
||||
| Q2 | Tab state semantics | Shared anchor + Akte across modes; filters reset per mode | §3.1, §3.2, §6 |
|
||||
| Q3 | Case-file integration | Page-header Akte picker, persistent across modes | §3.1, §3.2, §2.3 |
|
||||
| Q4 | Optional-display horizon | Per-event-card | §3.4 |
|
||||
| Q5 | Builder shape | Unified builder, 3 entry modes (cold-open / event-triggered / Akte) | §0, §1, §2, §3 |
|
||||
| Q6 | Scenario↔project relationship | Separate `paliad.scenarios` table + promote-to-project action | §5, §2.4 |
|
||||
| Q7 | Scenario contents | Multi-proceeding constellation per scenario | §3, §5 |
|
||||
| Q8 | Save model | Auto-save active scenario + "Meine Szenarien" list | §1, §3, §6.4 |
|
||||
| Q9 | Multi-proceeding render | Vertical stacked column-triplets | §3 |
|
||||
| Q10 | Per-event state | 3-state: planned / filed / skipped (no `overdue` state) | §3.4 |
|
||||
| Q11 | Promote-to-project carry | Everything (incl. placeholder parties + free-form notes) | §2.4, §5.4 |
|
||||
| Q12 | Sharing model | Private by default + explicit team-share (read-only) | §1, §5, §2.5 |
|
||||
| Q13 | Scenario flags placement | Per-proceeding (each triplet owns its `scenario_flags`) | §5.1 |
|
||||
| Q14 | Cross-proceeding peer triggers | Out of scope for v1 (defer to v1.1) | §3.6, §7 |
|
||||
| Q15 | Perspective scope | Per-proceeding (each triplet has its own `primary_party`) | §3.3, §5.1 |
|
||||
| Q16 | Add-proceeding flow | `+ Verfahren hinzufügen` button below the last triplet, inline picker | §3, §3.1 |
|
||||
| Q17 | Cold-open canvas | Empty canvas + "Neues Szenario" CTA + recent-list | §2.1, §3 |
|
||||
| Q18 | Search scope | Universal: events + scenarios + Akten, scoped by result type | §3.1, §6 |
|
||||
| Q19 | Promote-to-project flow | 3-step wizard (Bestätigen → Parteien ergänzen → Akte-Metadaten) | §2.4, §5.4 |
|
||||
| Q20 | Mobile treatment | Desktop v1, mobile basic-read (mutating actions prompt "Auf größerem Bildschirm öffnen") | §3, §7 |
|
||||
|
||||
### §4.1 Divergences from inventor recommendations
|
||||
|
||||
Three picks diverged from my recommendation. Captured here so future readers (m, the coder) see the *current* design, not the strawman.
|
||||
|
||||
- **Q1 — Modular.** Inventor recommended "plug-in widgets". m: "I don't know — generally does not super apply here." Modular is dropped as a goal; the natural decomposition (BuilderCanvas → ProceedingTriplet → EventCard → ScenarioListPanel → PromoteWizard) is documented in §6.2 as build hygiene, not as a load-bearing constraint.
|
||||
- **Q10 — Event state.** Inventor recommended 4-state (planned / filed / skipped / overdue). m picked 3-state — no `overdue` enum. Rationale (interpreted): `overdue` is derived from `date < today AND state=planned`, not stored; this avoids stale state when the date is edited.
|
||||
- **Q11 — Promote carry.** Inventor recommended carrying procedural shape + flags + filed-state + notes but **not** placeholder parties/case_number/billing. m picked "everything carries" — placeholder parties come in. Mitigation: Q19's 3-step wizard's step 2 (Parteien ergänzen) gives the user a chance to clean placeholders before commit, so the safety net m wanted on Q11 is folded into Q19.
|
||||
|
||||
### §4.2 Inventor picks not formally asked
|
||||
|
||||
A few decisions are inventor-set because they're either: (a) implementation details that don't change the architecture, or (b) clean defaults that match existing patterns. Listed here so they're visible; m can flag any.
|
||||
|
||||
- **Detailgrad ("Gewählt" / "Alle Optionen") scope**: per-proceeding (matches today's Verfahrensablauf pattern). State in `scenario_proceedings.detailgrad`.
|
||||
- **Akte picker shape**: flat dropdown sorted by recently-viewed first, with a typeahead filter for case numbers/names. Same shape as today's project picker on /agenda.
|
||||
- **Notes**: per-event-card (textarea on each card, lazy-loaded). Scenario-level notes also exist (`scenarios.notes text`) for cross-cutting commentary.
|
||||
- **Read-only shared state UI**: every mutating affordance is disabled (greyed, no click handlers). Watermark "Geteilt von <X> · schreibgeschützt" at the top of the canvas. No "Fork to my workspace" affordance in v1.
|
||||
- **URL contract**: minimal, view-state only — `?scenario=<id>&mode=<entry>&event=<sequencing_rule_id>` (deep-link to a specific anchor). Filter pills + chip state get URL params *per active entry mode* but explicitly NOT the constellation data (per m's "not every constellation in URL" guidance). The constellation lives in `paliad.scenario_*` tables.
|
||||
- **Auto-save granularity**: debounced 500ms on every change. Indicator near scenario name: `Gespeichert ✓` (last successful save < 5s ago), `Speichert…` (in flight), `Letzte Speicherung fehlgeschlagen — erneut versuchen` (on error).
|
||||
- **Soft delete**: archived scenarios stay in DB with `status='archived'`. No hard delete in v1.
|
||||
- **Audit**: no audit log on scenario edits (they're exploratory). Audit on promote-to-project goes via the existing `projects.audit_log`.
|
||||
- **Concurrent editing**: single-editor model. Owner is sole editor; shares are read-only. No locking / merge conflict UI needed in v1.
|
||||
- **Bilingual**: German primary, English via existing `i18n.ts`. Scenario names: user-chosen, any language. Skip reasons + notes: free-text, any language.
|
||||
|
||||
---
|
||||
|
||||
## §5 Data model deltas
|
||||
|
||||
All new tables live in `paliad.*` schema, alongside existing `paliad.projects` / `paliad.deadlines` / `paliad.sequencing_rules`.
|
||||
|
||||
### §5.1 New tables
|
||||
|
||||
```sql
|
||||
-- Scenario header. One row per saved scenario (named or scratch).
|
||||
CREATE TABLE paliad.scenarios (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL DEFAULT 'Unbenanntes Szenario',
|
||||
status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','archived','promoted')),
|
||||
origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
|
||||
-- set when scenario was exported from a project
|
||||
promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
|
||||
-- set when scenario was promoted to a project
|
||||
stichtag date NULL,
|
||||
-- scenario-level default Stichtag; per-triplet overrides take precedence
|
||||
notes text NULL,
|
||||
-- free-form scenario-level commentary
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX scenarios_owner_status_idx ON paliad.scenarios(owner_id, status);
|
||||
CREATE INDEX scenarios_updated_idx ON paliad.scenarios(owner_id, updated_at DESC);
|
||||
|
||||
-- One row per proceeding inside a scenario. Multiple per scenario for
|
||||
-- multi-proceeding constellations. parent_scenario_proceeding_id self-refs
|
||||
-- for spawned children (CCR child of UPC inf etc.).
|
||||
CREATE TABLE paliad.scenario_proceedings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
|
||||
proceeding_type_id uuid NOT NULL REFERENCES paliad.proceeding_types(id),
|
||||
primary_party text NULL
|
||||
CHECK (primary_party IN ('claimant','defendant')),
|
||||
-- per-proceeding perspective; null = no perspective picked yet
|
||||
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- per-proceeding flags: {with_ccr: true, with_amend: false, …}
|
||||
parent_scenario_proceeding_id uuid NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
|
||||
-- self-ref for spawned children (CCR child of UPC inf etc.)
|
||||
spawn_anchor_event_id uuid NULL REFERENCES paliad.sequencing_rules(id),
|
||||
-- which rule of the parent caused this spawn (for UI placement)
|
||||
ordinal int NOT NULL DEFAULT 0,
|
||||
-- stack order on canvas (top to bottom)
|
||||
stichtag date NULL,
|
||||
-- per-proceeding Stichtag override; falls back to scenarios.stichtag
|
||||
detailgrad text NOT NULL DEFAULT 'selected'
|
||||
CHECK (detailgrad IN ('selected','all_options')),
|
||||
appeal_target text NULL,
|
||||
-- applies_to_target for appeal proceedings; null for non-appeal triplets
|
||||
collapsed boolean NOT NULL DEFAULT false,
|
||||
-- user-collapsed triplet header (UI state)
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_proceedings_scenario_idx ON paliad.scenario_proceedings(scenario_id, ordinal);
|
||||
CREATE INDEX scenario_proceedings_parent_idx ON paliad.scenario_proceedings(parent_scenario_proceeding_id);
|
||||
|
||||
-- One row per event card on the canvas. Captures the card's state +
|
||||
-- per-card attributes (filed date, skip reason, notes, optional horizon).
|
||||
-- Most cards are sequencing-rule-backed; free-form events have a null
|
||||
-- sequencing_rule_id and a non-null procedural_event_id (or text label).
|
||||
CREATE TABLE paliad.scenario_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_proceeding_id uuid NOT NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
|
||||
sequencing_rule_id uuid NULL REFERENCES paliad.sequencing_rules(id),
|
||||
procedural_event_id uuid NULL REFERENCES paliad.procedural_events(id),
|
||||
-- one of {sequencing_rule_id, procedural_event_id, custom_label} must be set
|
||||
custom_label text NULL,
|
||||
-- free-form event name when neither sequencing_rule nor procedural_event apply
|
||||
state text NOT NULL DEFAULT 'planned'
|
||||
CHECK (state IN ('planned','filed','skipped')),
|
||||
actual_date date NULL,
|
||||
-- set when state='filed'; can also be set for state='planned' (court-set override)
|
||||
skip_reason text NULL,
|
||||
-- optional rationale when state='skipped'
|
||||
notes text NULL,
|
||||
-- per-card free-form
|
||||
horizon_optional int NOT NULL DEFAULT 0,
|
||||
-- per-card "show N more optionals" affordance
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (scenario_proceeding_id, sequencing_rule_id) WHERE sequencing_rule_id IS NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_events_proceeding_idx ON paliad.scenario_events(scenario_proceeding_id);
|
||||
|
||||
-- Read-only team shares. Owner is sole editor; shares grant view-only.
|
||||
CREATE TABLE paliad.scenario_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
|
||||
shared_with_user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid NOT NULL REFERENCES auth.users(id),
|
||||
UNIQUE (scenario_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_shares_user_idx ON paliad.scenario_shares(shared_with_user_id);
|
||||
```
|
||||
|
||||
### §5.2 Additions to existing tables
|
||||
|
||||
```sql
|
||||
-- One nullable FK on paliad.projects to track which scenario spawned this
|
||||
-- project (set on promote-to-project). Auditable origin trail.
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN origin_scenario_id uuid NULL
|
||||
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX projects_origin_scenario_idx ON paliad.projects(origin_scenario_id)
|
||||
WHERE origin_scenario_id IS NOT NULL;
|
||||
```
|
||||
|
||||
No other changes to existing schema. `paliad.deadlines` continues to be the authoritative source for project-bound actuals; the builder writes to `paliad.deadlines` (not `scenario_events`) when working in Akte mode against a project-backed scenario.
|
||||
|
||||
### §5.3 RLS
|
||||
|
||||
Same pattern as existing `paliad.projects`:
|
||||
|
||||
- `scenarios` readable by `owner_id` OR by users with a matching `scenario_shares.shared_with_user_id` row.
|
||||
- `scenarios` writable only by `owner_id` (and only when `status != 'promoted'`).
|
||||
- `scenario_proceedings` + `scenario_events` cascade from scenario visibility.
|
||||
- `scenario_shares` readable by `shared_with_user_id` or `created_by`; writable only by the scenario owner.
|
||||
|
||||
Helper function `paliad.can_see_scenario(scenario_id)` mirrors the existing `paliad.can_see_project(project_id)` shape.
|
||||
|
||||
### §5.4 Promote-to-project: data flow
|
||||
|
||||
```
|
||||
[Wizard step 1: Bestätigen]
|
||||
Read: scenarios + scenario_proceedings + scenario_events
|
||||
Action: none (read-only summary)
|
||||
|
||||
[Wizard step 2: Parteien ergänzen]
|
||||
Read: scenario_proceedings.scenario_flags (for hints about placeholder party names)
|
||||
Action: builds an in-memory parties payload (per proceeding, per role)
|
||||
|
||||
[Wizard step 3: Akte-Metadaten]
|
||||
Read: user's clients + litigations + project tree (existing /projects API)
|
||||
Action: builds an in-memory project metadata payload
|
||||
|
||||
[Commit]
|
||||
Transaction:
|
||||
1. INSERT into paliad.projects (carrying step-2 + step-3 payloads, + scenario notes)
|
||||
SET origin_scenario_id = <scenario.id>
|
||||
2. INSERT into paliad.project_parties from step-2 payload
|
||||
3. For each scenario_proceeding (depth-first, parent before child):
|
||||
a. INSERT scenario_flags as projects.scenario_flags (parent-level only;
|
||||
children become sub-projects via parent_project_id)
|
||||
b. For each filed scenario_event: INSERT paliad.deadlines row with
|
||||
status='done', completed_at=actual_date, audit_reason='via Litigation Builder promotion'
|
||||
c. For each planned scenario_event: INSERT paliad.deadlines row with
|
||||
status='open', due_date=computed (or actual_date override)
|
||||
d. Skipped events: not inserted (no deadline row)
|
||||
4. UPDATE paliad.scenarios SET status='promoted', promoted_project_id=<new>
|
||||
5. Navigate to /projects/<new>
|
||||
```
|
||||
|
||||
The deadlines write uses existing `POST /api/projects/{id}/deadlines/bulk` semantics under the hood — no new bulk-deadline-from-scenario endpoint needed.
|
||||
|
||||
---
|
||||
|
||||
## §6 Modular boundaries (light)
|
||||
|
||||
m said modular "doesn't super apply" — dropped as a load-bearing goal. The natural decomposition below is build-hygiene documentation, not a constraint the coder must enforce.
|
||||
|
||||
### §6.1 Front-end components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|---|---|---|
|
||||
| `BuilderCanvas` | `frontend/src/components/BuilderCanvas.tsx` | Root render of the builder. Receives the active scenario, renders triplet stack + cold-open empty state |
|
||||
| `ProceedingTriplet` | `frontend/src/components/ProceedingTriplet.tsx` | One proceeding's render: header strip (jurisdiction + name + perspective + Detailgrad + collapse + remove) + flag strip + 3 columns + spawn child triplets recursively |
|
||||
| `EventCard` | `frontend/src/components/EventCard.tsx` | One card in a column lane. State / date / optional-horizon / notes affordances |
|
||||
| `ScenarioFlagsStrip` | `frontend/src/components/ScenarioFlagsStrip.tsx` | Per-triplet flag toggles. Reads scenario_flag_catalog, applies to scenario_proceedings.scenario_flags |
|
||||
| `AddProceedingPicker` | `frontend/src/components/AddProceedingPicker.tsx` | Inline picker triggered by `+ Verfahren hinzufügen`. Forum chip row → Verfahren chip row → `Hinzufügen` |
|
||||
| `ScenarioListPanel` | `frontend/src/components/ScenarioListPanel.tsx` | Side panel: Aktiv / Geteilt / Promoted / Archiviert buckets + new-scenario CTA |
|
||||
| `PromoteToProjectWizard` | `frontend/src/components/PromoteToProjectWizard.tsx` | 3-step modal: Bestätigen / Parteien / Metadaten |
|
||||
| `PageHeaderControls` | `frontend/src/components/PageHeaderControls.tsx` | Scenario picker + Benennen/Teilen/Promote buttons + Akte picker + Stichtag input |
|
||||
| `EntryModeChrome` | `frontend/src/components/EntryModeChrome.tsx` | Cold-open / event-triggered / Akte mode radio; ephemeral UI affordance that fades into canvas state |
|
||||
|
||||
### §6.2 Client TS files
|
||||
|
||||
Mirror the React-ish component split:
|
||||
|
||||
- `frontend/src/client/builder.ts` — root orchestrator (auto-save loop, URL state, mode routing, scenario fetch)
|
||||
- `frontend/src/client/builder-scenario.ts` — scenario CRUD against `/api/scenarios`
|
||||
- `frontend/src/client/builder-event-card.ts` — per-card state machine + optional-horizon control
|
||||
- `frontend/src/client/builder-promote-wizard.ts` — 3-step wizard state machine
|
||||
- `frontend/src/client/builder-search.ts` — universal search (events + scenarios + Akten)
|
||||
- `frontend/src/client/builder-shares.ts` — share-with-team UI
|
||||
|
||||
### §6.3 Backend services + routes
|
||||
|
||||
| Service | File | Endpoints |
|
||||
|---|---|---|
|
||||
| `ScenarioService` | `internal/services/scenario_service.go` | List / Get / Create / Update / Archive / Promote |
|
||||
| `ScenarioProceedingService` | `internal/services/scenario_proceeding_service.go` | Add / Remove / Update (flags, perspective, ordinal, detailgrad) |
|
||||
| `ScenarioEventService` | `internal/services/scenario_event_service.go` | List / Update state / Set date / Set notes / Set horizon |
|
||||
| `ScenarioShareService` | `internal/services/scenario_share_service.go` | List / Add / Remove shares |
|
||||
| `ScenarioPromoteService` | `internal/services/scenario_promote_service.go` | Wizard-driven transactional promote |
|
||||
|
||||
Routes (added under existing API namespace):
|
||||
|
||||
```
|
||||
GET /api/scenarios — list user's scenarios (filtered by status)
|
||||
POST /api/scenarios — create new scenario
|
||||
GET /api/scenarios/{id} — get scenario + proceedings + events (deep)
|
||||
PATCH /api/scenarios/{id} — update name / stichtag / notes / status
|
||||
DELETE /api/scenarios/{id} — archive (soft delete; status='archived')
|
||||
POST /api/scenarios/{id}/proceedings — add proceeding to scenario
|
||||
PATCH /api/scenarios/{id}/proceedings/{pid} — update flags / perspective / ordinal / detailgrad
|
||||
DELETE /api/scenarios/{id}/proceedings/{pid} — remove proceeding (cascades to events)
|
||||
PATCH /api/scenarios/{id}/events/{eid} — update state / date / notes / horizon
|
||||
POST /api/scenarios/{id}/shares — share with user (read-only)
|
||||
DELETE /api/scenarios/{id}/shares/{sid} — revoke share
|
||||
POST /api/scenarios/{id}/promote — promote to project (3-step wizard payload)
|
||||
POST /api/scenarios/from-project/{project_id} — export project to a new scenario (what-if)
|
||||
GET /api/search — universal search (events + scenarios + Akten)
|
||||
```
|
||||
|
||||
Existing endpoints used unchanged:
|
||||
|
||||
- `GET /api/tools/fristenrechner/search?kind=events` — for the events corpus.
|
||||
- `GET /api/projects` — Akte picker source.
|
||||
- `POST /api/projects/{id}/deadlines/bulk` — promotion writes deadlines through this.
|
||||
- `PATCH /api/projects/{id}/scenario-flags` — Akte-mode flag sync.
|
||||
|
||||
---
|
||||
|
||||
## §7 Migration plan from current live shape
|
||||
|
||||
Current live (`/tools/procedures` on main @ `ed3c5d1`) = cronus's U0-U4 4-tab catalog. Migration is a 6-slice train, every slice ships visibly. No feature flag (m's pattern preference per #152 Q7).
|
||||
|
||||
### §7.1 Slice train
|
||||
|
||||
| Slice | What ships | DB | Visible to user |
|
||||
|---|---|---|---|
|
||||
| **B0 — Scenario DB foundation** | New tables (scenarios + scenario_proceedings + scenario_events + scenario_shares) + RLS + minimal API (list / create / get). Scenarios writable from a developer-only test route at first. | Mig #N (new tables + RLS + `paliad.projects.origin_scenario_id`) | No user-visible change. |
|
||||
| **B1 — Builder shell + cold-open mode** | New `/tools/procedures` page replaces the 4-tab catalog. Renders: page header (scenario picker + Akte picker + Stichtag + search), entry-mode radio (cold-open active), filter strip, empty canvas + "Neues Szenario starten" CTA + recent list. Add-proceeding picker works; first triplet renders with the existing Verfahrensablauf-core calc. Auto-save active scenario. Side panel "Meine Szenarien" with Aktiv bucket only. | — | New page visible. Single triplet works end-to-end. |
|
||||
| **B2 — Multi-triplet + spawn nesting + per-event state** | Vertical multi-triplet stack with `+ Verfahren hinzufügen`. Per-triplet perspective + flag strip. Spawn child triplets render inline. Event cards get the 3-state machine (planned/filed/skipped) + date editor + per-card optional horizon chip. Page-header Stichtag drives default dates. | — | Full scenario builder works without Akte integration. |
|
||||
| **B3 — Event-triggered mode + universal search** | "Ereignis" entry mode wires the search box to land on a single-triplet anchored view (scratch scenario). Universal search returns events + scenarios + Akten with type-scoped result groups. Filter pills (forum/proc/party/kind) reset on mode switch. | — | Event lookup works. |
|
||||
| **B4 — Akte mode + project-backed scenarios** | "Aus Akte" entry mode + page-header Akte picker. Loads project state into the builder (proceeding + perspective + scenario_flags + deadlines actuals). Akte-backed scenarios write through to `paliad.deadlines` + `paliad.projects.scenario_flags`; non-Akte scenarios write to `paliad.scenario_events`. Cross-surface scenario-flag-changed event listener reused from #152 T3. | — | Akte integration works end-to-end. |
|
||||
| **B5 — Share + Promote-to-project wizard** | "Teilen" button + user picker + share row. "Geteilt mit mir" bucket in side panel. "Als Projekt anlegen" opens the 3-step wizard (Bestätigen → Parteien ergänzen → Akte-Metadaten). Successful commit creates project + cascades deadlines + sets `origin_scenario_id`, navigates to /projects/{id}. "Promoted" bucket in side panel. | — | Sharing + promotion work. |
|
||||
| **B6 — Mobile basic-read + cleanup + i18n polish** | Mobile (<640px) shows scenarios + cards read-only; mutating affordances prompt "Auf größerem Bildschirm öffnen". Cleanup: delete dead U0-U4 catalog code (4-tab control, legacy `verfahrensablauf.ts`, etc.). All i18n keys finalised (DE + EN). | — | Mobile works; codebase cleaner. |
|
||||
|
||||
### §7.2 Why this train shape
|
||||
|
||||
- **B0 is DB-only**. The schema can land independently and be exercised via test routes / Supabase MCP before any UI sees it. Keeps mig risk isolated.
|
||||
- **B1-B2 are the MVP**. After B2, a user can build and save a multi-proceeding scenario fully kontextfrei. That alone replaces 60% of today's catalog use.
|
||||
- **B3 adds the lookup path**. After B3, "what's next after Klageerwiderung?" works without saving.
|
||||
- **B4 makes it real**. Akte integration is the load-bearing piece for daily use; ships once the foundation is stable.
|
||||
- **B5 unlocks team value**. Sharing + promotion are the difference between "personal tool" and "team tool". Ship after the core works.
|
||||
- **B6 is cleanup**. Mobile read + dead code removal land last to avoid coupling to in-flight features.
|
||||
|
||||
### §7.3 What stays unchanged
|
||||
|
||||
- URL `/tools/procedures` keeps it (the new builder lives there).
|
||||
- Sidebar entry "Verfahren & Fristen" keeps it.
|
||||
- cmd-K palette keeps it.
|
||||
- `/tools/fristenrechner` + `/tools/verfahrensablauf` legacy redirects (from cronus's U4) stay alive: 301 → `/tools/procedures` (the builder).
|
||||
- `pkg/litigationplanner.CalculateRule` — untouched.
|
||||
- `/admin/procedural-events` — untouched.
|
||||
- `/projects/{id}` Verlauf — untouched (new "Im Builder öffnen" button is the only addition).
|
||||
|
||||
### §7.4 Cleanup at B6
|
||||
|
||||
Dead code to delete (verify with grep before deletion):
|
||||
|
||||
- `frontend/src/components/VerfahrensablaufBody.tsx` (replaced by ProceedingTriplet)
|
||||
- `frontend/src/client/verfahrensablauf.ts` (replaced by builder.ts orchestration)
|
||||
- `frontend/src/client/views/verfahrensablauf-state.ts` (replaced by scenario-backed state)
|
||||
- `frontend/src/client/views/verfahrensablauf-state.test.ts`
|
||||
- `frontend/src/client/verfahrensablauf-detail-mode.ts` (replaced by per-triplet Detailgrad)
|
||||
- Existing scratch tab content in `frontend/src/client/procedures.ts` (4-tab toggling logic, mode routing)
|
||||
|
||||
**Kept**:
|
||||
|
||||
- `frontend/src/client/views/verfahrensablauf-core.ts` (calculation engine; reused by EventCard + ProceedingTriplet)
|
||||
- Legacy URL redirects in Go (`/tools/fristenrechner` + `/tools/verfahrensablauf` → `/tools/procedures`)
|
||||
|
||||
---
|
||||
|
||||
## §8 Open follow-ups (out of scope for v1)
|
||||
|
||||
Tracked for v1.1 / future tickets:
|
||||
|
||||
- **Cross-proceeding peer triggers** (UPC-inf judgment → EPA opp choice deadline). New `paliad.scenario_event_links` table. UI: trigger-picker chip on event cards.
|
||||
- **DE / EPA / DPMA full expansion**. v1 supports EPA + DPMA proceedings at the data layer (calc engine handles them), but the spawn flags and CCR-style nestings are UPC-specific. Other jurisdictions get proper coverage in v1.1.
|
||||
- **Scenario versioning / snapshots**. m's Q8 alternative ("versioned snapshots") deferred. Add when scenarios start driving client briefings.
|
||||
- **Multi-user concurrent editing**. Out of scope. Single-editor model with read-only shares is sufficient until usage shows otherwise.
|
||||
- **Fork-a-shared-scenario**. Read-only sharing in v1 doesn't expose "fork into my workspace". Add when team usage demands it.
|
||||
- **Comments on scenarios / event cards**. Out of scope (separate ticket).
|
||||
- **PDF export of a scenario for client briefings**. Out of scope.
|
||||
- **Mobile-parity edits**. v1.1 — full mobile interaction loop.
|
||||
- **Audit log on scenario edits**. Out of scope (exploratory data).
|
||||
- **Cross-scenario comparison view**. ("Compare planned vs actual" lives on the project page via promote-then-compare; explicit comparison tool is v2.)
|
||||
|
||||
---
|
||||
|
||||
## §9 Synthesis links
|
||||
|
||||
- **mBrian**: file as `[synthesis]` linked `triggered_by` t-paliad-339; `related_to` atlas's reverted tracker design, cronus's unified-procedural-events-tool design, atlas's deadline-system-revision.
|
||||
- **Cross-refs in this repo**: `docs/design-procedures-workflow-tracker-2026-05-27.md` (atlas, reverted), `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus, live), `docs/design-deadline-system-revision-2026-05-27.md` (atlas Phase 2), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26).
|
||||
- **Gitea**: m/paliad#153 (this PRD), m/paliad#152 (atlas's tracker, reverted), m/paliad#151 (cronus U0-U4 shipped), m/paliad#149 (atlas Phase 2 in flight).
|
||||
- **Coder phase** (deferred per inventor SKILL): runs after m ratifies this PRD. Slice ordering per §7.1. NOT edison (parked at DESIGN READY FOR REVIEW). NOT atlas (just-rejected tracker → framing bias). NOT cronus (parked on Fristenrechner inventor branch). A pattern-fluent Sonnet coder picks up B0 first.
|
||||
|
||||
---
|
||||
|
||||
## §10 Coder hand-off notes
|
||||
|
||||
(Pre-emptive — for whoever picks up B0.)
|
||||
|
||||
- **Migration number**: check `internal/db/migrations/` for the max slot at coder shift start. Two recent migrations (curie's t-paliad-336, ritchie's t-paliad-149 P0) are in flight; coordinate via paliadin/head before claiming a slot.
|
||||
- **Akte integration nuance**: when the builder is in Akte mode and the scenario is project-backed, writes flow to `paliad.deadlines` / `paliad.projects.scenario_flags` instead of `paliad.scenario_*` tables — the scenario row itself just records the canvas view-state (which triplets are visible, ordinal, collapsed state, per-card horizon). This dual-write rule is the load-bearing complexity of B4; design tests for it explicitly.
|
||||
- **Auto-save throttling**: 500ms debounce per change. Avoid PATCH-per-keystroke on notes textareas (use blur-trigger + 2s debounce there).
|
||||
- **Search performance**: universal search (events + scenarios + Akten) needs to stay snappy. Events corpus is ~3000 rows; scenarios/Akten are per-user. Use existing trgm indexes; avoid joining across all three for ranking.
|
||||
- **B5 transactional promotion**: do the wizard's commit in a single Postgres transaction. If any of (project insert / parties / deadlines / scenario status update) fails, roll back atomically. No partial promotions.
|
||||
- **Mobile rendering**: B6 is meant to be cheap. Column-triplet → CSS grid that collapses to single-column at `@media (max-width: 640px)`. Mutating affordances get `pointer-events: none` + a click-handler that surfaces the "Auf größerem Bildschirm öffnen" toast — keeps the desktop interaction code paths unchanged.
|
||||
- **i18n keys**: every user-facing string gets `data-i18n` from B1. Don't accumulate i18n debt across slices.
|
||||
@@ -0,0 +1,94 @@
|
||||
-- 157_scenario_builder_foundation — down
|
||||
--
|
||||
-- Rolls back mig 157 in reverse order. Down files are reference material
|
||||
-- (not auto-applied); operator recovery path is:
|
||||
--
|
||||
-- psql ... < 157_scenario_builder_foundation.down.sql
|
||||
-- DELETE FROM paliad.applied_migrations WHERE version = 157;
|
||||
--
|
||||
-- This restores the legacy paliad.scenarios shape from mig 145 — the
|
||||
-- builder columns and the three sibling tables are dropped wholesale.
|
||||
-- Any builder data in the dropped tables is lost (the tables CASCADE to
|
||||
-- their children, and DROP TABLE doesn't keep a backup).
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 157 rollback: tear down Scenario builder foundation (t-paliad-340)',
|
||||
true
|
||||
);
|
||||
|
||||
-- 8. updated_at triggers
|
||||
DROP TRIGGER IF EXISTS scenario_events_touch_updated_at_trg ON paliad.scenario_events;
|
||||
DROP TRIGGER IF EXISTS scenario_proceedings_touch_updated_at_trg ON paliad.scenario_proceedings;
|
||||
|
||||
-- 7. RLS — drop new policies + restore legacy four
|
||||
DROP POLICY IF EXISTS scenario_shares_mutate ON paliad.scenario_shares;
|
||||
DROP POLICY IF EXISTS scenario_shares_select ON paliad.scenario_shares;
|
||||
DROP POLICY IF EXISTS scenario_events_mutate ON paliad.scenario_events;
|
||||
DROP POLICY IF EXISTS scenario_events_select ON paliad.scenario_events;
|
||||
DROP POLICY IF EXISTS scenario_proceedings_mutate ON paliad.scenario_proceedings;
|
||||
DROP POLICY IF EXISTS scenario_proceedings_select ON paliad.scenario_proceedings;
|
||||
DROP POLICY IF EXISTS scenarios_owner_mutate ON paliad.scenarios;
|
||||
DROP POLICY IF EXISTS scenarios_select ON paliad.scenarios;
|
||||
|
||||
-- Restore the four mig-145 policies verbatim.
|
||||
CREATE POLICY scenarios_project_select ON paliad.scenarios
|
||||
FOR SELECT
|
||||
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY scenarios_project_mutate ON paliad.scenarios
|
||||
FOR ALL
|
||||
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
|
||||
FOR SELECT
|
||||
USING (project_id IS NULL AND created_by = auth.uid());
|
||||
|
||||
CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios
|
||||
FOR ALL
|
||||
USING (project_id IS NULL AND created_by = auth.uid())
|
||||
WITH CHECK (project_id IS NULL AND created_by = auth.uid());
|
||||
|
||||
-- 6. helper function
|
||||
DROP FUNCTION IF EXISTS paliad.can_see_scenario(uuid);
|
||||
|
||||
-- 5. paliad.projects.origin_scenario_id
|
||||
DROP INDEX IF EXISTS paliad.projects_origin_scenario_idx;
|
||||
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS origin_scenario_id;
|
||||
|
||||
-- 4. paliad.scenario_shares
|
||||
DROP TABLE IF EXISTS paliad.scenario_shares;
|
||||
|
||||
-- 3. paliad.scenario_events
|
||||
DROP TABLE IF EXISTS paliad.scenario_events;
|
||||
|
||||
-- 2. paliad.scenario_proceedings
|
||||
DROP TABLE IF EXISTS paliad.scenario_proceedings;
|
||||
|
||||
-- 1. paliad.scenarios — restore mig-145 shape
|
||||
DROP INDEX IF EXISTS paliad.scenarios_updated_idx;
|
||||
DROP INDEX IF EXISTS paliad.scenarios_owner_status_idx;
|
||||
|
||||
-- Restore the unique constraint mig 145 had.
|
||||
ALTER TABLE paliad.scenarios
|
||||
ADD CONSTRAINT scenarios_unique_per_scope
|
||||
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name);
|
||||
|
||||
-- spec was NOT NULL in mig 145. Restore that — but only after backfilling
|
||||
-- any NULL specs the builder might have created (none in legacy paths;
|
||||
-- only builder rows have NULL spec, and those are dropped together with
|
||||
-- the builder schema if a real rollback is needed).
|
||||
UPDATE paliad.scenarios SET spec = '{}'::jsonb WHERE spec IS NULL;
|
||||
ALTER TABLE paliad.scenarios ALTER COLUMN spec SET NOT NULL;
|
||||
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS notes;
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS stichtag;
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS promoted_project_id;
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS origin_project_id;
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS status;
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS owner_id;
|
||||
|
||||
COMMIT;
|
||||
500
internal/db/migrations/157_scenario_builder_foundation.up.sql
Normal file
500
internal/db/migrations/157_scenario_builder_foundation.up.sql
Normal file
@@ -0,0 +1,500 @@
|
||||
-- 157_scenario_builder_foundation — t-paliad-340 / m/paliad#153 B0
|
||||
--
|
||||
-- Schema foundation for the Litigation Builder (PRD
|
||||
-- docs/plans/prd-procedures-litigation-planner-2026-05-27.md §5.1 + §5.2).
|
||||
-- Phase B0 of the 7-slice train described in PRD §7.1. DB-only — no UI
|
||||
-- depends on these tables yet; B1 wires the builder shell on top.
|
||||
--
|
||||
-- What this migration adds:
|
||||
--
|
||||
-- 1. Six new columns on paliad.scenarios for the builder shape:
|
||||
-- owner_id, status, origin_project_id, promoted_project_id,
|
||||
-- stichtag, notes.
|
||||
-- Two relaxations on existing columns:
|
||||
-- - spec NOT NULL → NULL (the builder normalises spec contents
|
||||
-- into scenario_proceedings / scenario_events; new rows skip
|
||||
-- spec entirely. Legacy callers from mig 145 still provide it
|
||||
-- explicitly, so they keep inserting valid rows.)
|
||||
-- - DROP CONSTRAINT scenarios_unique_per_scope (the builder
|
||||
-- allows multiple "Unbenanntes Szenario" + multiple scratch
|
||||
-- scenarios per user — uniqueness on (project_id, created_by,
|
||||
-- name) blocks that. The legacy service treated the constraint
|
||||
-- as UX collision avoidance, not correctness.)
|
||||
--
|
||||
-- 2. Three new tables for the normalised builder shape:
|
||||
-- - paliad.scenario_proceedings (one row per proceeding in a
|
||||
-- scenario; multi-proceeding constellations + spawned children)
|
||||
-- - paliad.scenario_events (one row per event card on the
|
||||
-- canvas; planned / filed / skipped state + actual_date + notes
|
||||
-- + per-card optional horizon)
|
||||
-- - paliad.scenario_shares (read-only team shares; owner is
|
||||
-- the sole editor)
|
||||
--
|
||||
-- 3. One new column on paliad.projects:
|
||||
-- - origin_scenario_id — audit trail for promote-to-project
|
||||
-- (B5; the column lands now so the FK is in place when the
|
||||
-- wizard arrives).
|
||||
--
|
||||
-- 4. New helper function paliad.can_see_scenario(_scenario_id) that
|
||||
-- mirrors paliad.can_see_project's STABLE SECURITY DEFINER shape.
|
||||
-- Visibility logic:
|
||||
-- - global_admin sees everything,
|
||||
-- - owner_id = auth.uid() (builder-owned scenarios),
|
||||
-- - scenario_shares.shared_with_user_id = auth.uid()
|
||||
-- (read-only shared scenarios),
|
||||
-- - legacy project-scoped scenarios (owner_id IS NULL AND
|
||||
-- project_id IS NOT NULL) follow can_see_project(project_id),
|
||||
-- - legacy abstract scenarios (owner_id IS NULL AND project_id
|
||||
-- IS NULL) follow created_by = auth.uid().
|
||||
--
|
||||
-- 5. Replacement RLS policies on paliad.scenarios that fold builder
|
||||
-- visibility together with the legacy shape. The legacy
|
||||
-- project_* / abstract_* policies are dropped (they covered only
|
||||
-- legacy paths) and rewritten as a single pair of policies that
|
||||
-- treats owner_id, scenario_shares, and the legacy paths uniformly.
|
||||
--
|
||||
-- Builder-only RLS for the three new tables: read = scenario
|
||||
-- visibility; write = scenario owner (or legacy editor) only.
|
||||
--
|
||||
-- PRD §5.1 deviations called out for the reader:
|
||||
--
|
||||
-- - PRD specs `proceeding_type_id uuid REFERENCES paliad.proceeding_types(id)`.
|
||||
-- The live column is `integer` (see paliad.proceeding_types.id);
|
||||
-- scenario_proceedings.proceeding_type_id is integer here to match
|
||||
-- the real FK target. PRD authors did not check the column type;
|
||||
-- this migration uses the truth on disk.
|
||||
--
|
||||
-- - PRD references `auth.users(id)` for owner_id and share columns;
|
||||
-- the established paliad convention (see paliad.projects.created_by,
|
||||
-- paliad.scenarios.created_by) uses `paliad.users(id)`. Same UUIDs
|
||||
-- either way (paliad.users.id == auth.users.id), but the FK targets
|
||||
-- paliad.users to stay consistent with project tables.
|
||||
--
|
||||
-- Audit-first: all DDL ran clean against a BEGIN/ROLLBACK probe on the
|
||||
-- live DB before this file was committed. paliad.scenarios has 0 rows
|
||||
-- (verified pre-mig), so the column additions and constraint relaxations
|
||||
-- have no data impact.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 157: Scenario builder foundation (t-paliad-340 / m/paliad#153 B0)',
|
||||
true
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. paliad.scenarios — additive columns + constraint relaxations
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.scenarios
|
||||
ADD COLUMN owner_id uuid NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
ADD COLUMN status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','archived','promoted')),
|
||||
ADD COLUMN origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
|
||||
ADD COLUMN promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
|
||||
ADD COLUMN stichtag date NULL,
|
||||
ADD COLUMN notes text NULL;
|
||||
|
||||
ALTER TABLE paliad.scenarios ALTER COLUMN spec DROP NOT NULL;
|
||||
ALTER TABLE paliad.scenarios DROP CONSTRAINT IF EXISTS scenarios_unique_per_scope;
|
||||
|
||||
CREATE INDEX scenarios_owner_status_idx
|
||||
ON paliad.scenarios(owner_id, status)
|
||||
WHERE owner_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX scenarios_updated_idx
|
||||
ON paliad.scenarios(owner_id, updated_at DESC)
|
||||
WHERE owner_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.owner_id IS
|
||||
'Litigation Builder owner (PRD §5.1). NULL = legacy composition-spec '
|
||||
'scenario from m/paliad#124 Slice D (mig 145). Builder rows MUST have '
|
||||
'owner_id set; the application enforces it via ScenarioBuilderService.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.status IS
|
||||
'Lifecycle: active (default; user-editable) / archived (soft-deleted, '
|
||||
'still visible in side panel) / promoted (converted to project via '
|
||||
'B5 wizard; read-only). Legacy mig-145 rows default to active.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.origin_project_id IS
|
||||
'Set when the scenario was exported from an existing project '
|
||||
'("Im Builder öffnen" — Akte mode, PRD §2.3).';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.promoted_project_id IS
|
||||
'Set after the scenario was promoted to a real project via the 3-step '
|
||||
'wizard (PRD §5.4). Together with paliad.projects.origin_scenario_id, '
|
||||
'forms the bidirectional audit link.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.stichtag IS
|
||||
'Scenario-level default Stichtag; per-proceeding overrides in '
|
||||
'paliad.scenario_proceedings.stichtag take precedence.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. paliad.scenario_proceedings — one proceeding per scenario row
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.scenario_proceedings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id uuid NOT NULL
|
||||
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
|
||||
proceeding_type_id integer NOT NULL
|
||||
REFERENCES paliad.proceeding_types(id),
|
||||
primary_party text NULL
|
||||
CHECK (primary_party IN ('claimant','defendant')),
|
||||
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
CHECK (jsonb_typeof(scenario_flags) = 'object'),
|
||||
parent_scenario_proceeding_id uuid NULL
|
||||
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
|
||||
spawn_anchor_event_id uuid NULL
|
||||
REFERENCES paliad.sequencing_rules(id),
|
||||
ordinal int NOT NULL DEFAULT 0,
|
||||
stichtag date NULL,
|
||||
detailgrad text NOT NULL DEFAULT 'selected'
|
||||
CHECK (detailgrad IN ('selected','all_options')),
|
||||
appeal_target text NULL,
|
||||
collapsed boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_proceedings_scenario_idx
|
||||
ON paliad.scenario_proceedings(scenario_id, ordinal);
|
||||
|
||||
CREATE INDEX scenario_proceedings_parent_idx
|
||||
ON paliad.scenario_proceedings(parent_scenario_proceeding_id)
|
||||
WHERE parent_scenario_proceeding_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE paliad.scenario_proceedings IS
|
||||
'One proceeding inside a Litigation Builder scenario. Multiple rows '
|
||||
'per scenario for multi-proceeding constellations. '
|
||||
'parent_scenario_proceeding_id self-refs for spawned children '
|
||||
'(e.g. upc.ccr.cfi spawned by with_ccr on upc.inf.cfi). '
|
||||
'PRD §5.1, m/paliad#153 B0.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_proceedings.primary_party IS
|
||||
'Per-proceeding perspective ("our side"). NULL = no perspective '
|
||||
'picked yet (both party columns render with natural labels). '
|
||||
'Per-proceeding so multi-jurisdiction constellations can flip side '
|
||||
'independently (PRD §3.3).';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_proceedings.scenario_flags IS
|
||||
'Per-proceeding flags (e.g. {"with_ccr": true, "with_amend": false}). '
|
||||
'Mirrors paliad.projects.scenario_flags shape but lives per-proceeding-'
|
||||
'per-scenario. Validated by the application against '
|
||||
'paliad.scenario_flag_catalog at write time.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_proceedings.spawn_anchor_event_id IS
|
||||
'Which sequencing_rule of the parent proceeding caused this spawn. '
|
||||
'NULL for root proceedings. Used by the UI to place the spawned child '
|
||||
'triplet directly below the parent at the spawn node (PRD §3.6).';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_proceedings.ordinal IS
|
||||
'Stack order on canvas (top to bottom). Siblings under the same '
|
||||
'parent (or top-level) are ordered by ordinal asc, then created_at.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_proceedings.detailgrad IS
|
||||
'Per-proceeding optional-detail toggle: selected (only explicitly '
|
||||
'chosen optionals + mandatories) or all_options (every optional '
|
||||
'sequencing_rule surfaces). Matches today''s Verfahrensablauf pattern.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. paliad.scenario_events — one event card on the canvas
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.scenario_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_proceeding_id uuid NOT NULL
|
||||
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
|
||||
sequencing_rule_id uuid NULL
|
||||
REFERENCES paliad.sequencing_rules(id),
|
||||
procedural_event_id uuid NULL
|
||||
REFERENCES paliad.procedural_events(id),
|
||||
custom_label text NULL,
|
||||
state text NOT NULL DEFAULT 'planned'
|
||||
CHECK (state IN ('planned','filed','skipped')),
|
||||
actual_date date NULL,
|
||||
skip_reason text NULL,
|
||||
notes text NULL,
|
||||
horizon_optional int NOT NULL DEFAULT 0
|
||||
CHECK (horizon_optional >= 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT scenario_events_one_anchor CHECK (
|
||||
(sequencing_rule_id IS NOT NULL)::int +
|
||||
(procedural_event_id IS NOT NULL)::int +
|
||||
(custom_label IS NOT NULL)::int >= 1
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_events_proceeding_idx
|
||||
ON paliad.scenario_events(scenario_proceeding_id);
|
||||
|
||||
-- A single proceeding can't carry two cards for the same sequencing rule
|
||||
-- (each rule maps to one card). Free-form / procedural_event-only cards
|
||||
-- skip this uniqueness — multiple custom cards per proceeding are OK.
|
||||
CREATE UNIQUE INDEX scenario_events_rule_uniq_idx
|
||||
ON paliad.scenario_events(scenario_proceeding_id, sequencing_rule_id)
|
||||
WHERE sequencing_rule_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE paliad.scenario_events IS
|
||||
'One event card on the Litigation Builder canvas. Captures state '
|
||||
'(planned/filed/skipped), actual_date, notes, skip_reason, and the '
|
||||
'per-card optional-horizon setting. At least one of '
|
||||
'(sequencing_rule_id, procedural_event_id, custom_label) must be '
|
||||
'set — sequencing-rule-backed cards are the common case; free-form '
|
||||
'cards exist for events the catalog doesn''t cover yet. '
|
||||
'PRD §3.4 / §5.1.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_events.state IS
|
||||
'3-state machine: planned (default, future event with computed date) '
|
||||
'/ filed (past event, actual_date set) / skipped (user chose not to '
|
||||
'file; optional skip_reason). No "overdue" enum — that''s derived '
|
||||
'(date < today AND state=planned), not stored. PRD Q10 / §3.4.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_events.actual_date IS
|
||||
'Set when state=filed (real-world filing date) OR when state=planned '
|
||||
'and the user overrode the computed date (court-set events, manual '
|
||||
'tweaks). NULL when the computed date is canonical.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_events.horizon_optional IS
|
||||
'Per-card "show N more optional follow-ups" affordance. Default 0 '
|
||||
'(hidden). PRD Q4 / §3.4.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. paliad.scenario_shares — read-only team shares
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.scenario_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id uuid NOT NULL
|
||||
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
|
||||
shared_with_user_id uuid NOT NULL
|
||||
REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid NOT NULL REFERENCES paliad.users(id),
|
||||
UNIQUE (scenario_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_shares_user_idx
|
||||
ON paliad.scenario_shares(shared_with_user_id);
|
||||
|
||||
COMMENT ON TABLE paliad.scenario_shares IS
|
||||
'Read-only team shares for Litigation Builder scenarios. Owner '
|
||||
'(paliad.scenarios.owner_id) is the sole editor; rows here grant '
|
||||
'view-only access to other paliad users. PRD Q12 / §5.1.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 5. paliad.projects.origin_scenario_id — promote-to-project trail
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN origin_scenario_id uuid NULL
|
||||
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX projects_origin_scenario_idx
|
||||
ON paliad.projects(origin_scenario_id)
|
||||
WHERE origin_scenario_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.origin_scenario_id IS
|
||||
'FK to the scenario this project was promoted from (B5 wizard). '
|
||||
'NULL = project was created directly, not via Builder. Together with '
|
||||
'paliad.scenarios.promoted_project_id, forms the bidirectional audit '
|
||||
'link. PRD §5.2.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 6. paliad.can_see_scenario — visibility helper
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_scenario(_scenario_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $func$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = _scenario_id AND s.owner_id = auth.uid()
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.scenario_shares sh
|
||||
WHERE sh.scenario_id = _scenario_id
|
||||
AND sh.shared_with_user_id = auth.uid()
|
||||
)
|
||||
-- Legacy project-scoped scenarios (mig 145) — visible via project
|
||||
-- team membership.
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = _scenario_id
|
||||
AND s.owner_id IS NULL
|
||||
AND s.project_id IS NOT NULL
|
||||
AND paliad.can_see_project(s.project_id)
|
||||
)
|
||||
-- Legacy abstract scenarios (mig 145) — owner-only via created_by.
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = _scenario_id
|
||||
AND s.owner_id IS NULL
|
||||
AND s.project_id IS NULL
|
||||
AND s.created_by = auth.uid()
|
||||
);
|
||||
$func$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.can_see_scenario(uuid) IS
|
||||
'Returns true if the caller (auth.uid()) can see the given scenario. '
|
||||
'Mirrors paliad.can_see_project. Covers builder-owned scenarios '
|
||||
'(owner_id), read-only shares (scenario_shares), and the two legacy '
|
||||
'paths from mig 145 (project-scoped via can_see_project, abstract '
|
||||
'via created_by). Used by RLS on all four scenario_* tables.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 7. RLS — replace legacy scenarios policies + new tables
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
-- Replace mig-145's four policies with a single pair that handles
|
||||
-- builder + legacy shapes together.
|
||||
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
|
||||
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
|
||||
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
|
||||
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
|
||||
|
||||
CREATE POLICY scenarios_select ON paliad.scenarios
|
||||
FOR SELECT USING (paliad.can_see_scenario(id));
|
||||
|
||||
-- Write rule: builder owner, legacy project team member (if no owner),
|
||||
-- or legacy abstract creator (if no owner + no project). Shares are
|
||||
-- read-only — they don't grant mutate.
|
||||
CREATE POLICY scenarios_owner_mutate ON paliad.scenarios
|
||||
FOR ALL
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
|
||||
)
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
|
||||
);
|
||||
|
||||
-- scenario_proceedings — visibility piggybacks on the parent scenario.
|
||||
ALTER TABLE paliad.scenario_proceedings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY scenario_proceedings_select ON paliad.scenario_proceedings
|
||||
FOR SELECT USING (paliad.can_see_scenario(scenario_id));
|
||||
|
||||
CREATE POLICY scenario_proceedings_mutate ON paliad.scenario_proceedings
|
||||
FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = scenario_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = scenario_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
));
|
||||
|
||||
-- scenario_events — visibility piggybacks on the parent scenario via
|
||||
-- the proceeding row.
|
||||
ALTER TABLE paliad.scenario_events ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY scenario_events_select ON paliad.scenario_events
|
||||
FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.scenario_proceedings sp
|
||||
WHERE sp.id = scenario_proceeding_id
|
||||
AND paliad.can_see_scenario(sp.scenario_id)
|
||||
));
|
||||
|
||||
CREATE POLICY scenario_events_mutate ON paliad.scenario_events
|
||||
FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.scenario_proceedings sp
|
||||
JOIN paliad.scenarios s ON s.id = sp.scenario_id
|
||||
WHERE sp.id = scenario_proceeding_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.scenario_proceedings sp
|
||||
JOIN paliad.scenarios s ON s.id = sp.scenario_id
|
||||
WHERE sp.id = scenario_proceeding_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
));
|
||||
|
||||
-- scenario_shares — recipient can see their share rows; the scenario
|
||||
-- owner (or legacy editor) can manage them.
|
||||
ALTER TABLE paliad.scenario_shares ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY scenario_shares_select ON paliad.scenario_shares
|
||||
FOR SELECT
|
||||
USING (
|
||||
shared_with_user_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = scenario_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY scenario_shares_mutate ON paliad.scenario_shares
|
||||
FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = scenario_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = scenario_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
));
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 8. updated_at triggers on the new tables (reuse the function mig 145
|
||||
-- already created for paliad.scenarios).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TRIGGER scenario_proceedings_touch_updated_at_trg
|
||||
BEFORE UPDATE ON paliad.scenario_proceedings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
|
||||
|
||||
CREATE TRIGGER scenario_events_touch_updated_at_trg
|
||||
BEFORE UPDATE ON paliad.scenario_events
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 9. Informational NOTICE.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 157] paliad.scenarios extended with builder columns (0 legacy rows affected)';
|
||||
RAISE NOTICE '[mig 157] paliad.scenario_proceedings created';
|
||||
RAISE NOTICE '[mig 157] paliad.scenario_events created';
|
||||
RAISE NOTICE '[mig 157] paliad.scenario_shares created';
|
||||
RAISE NOTICE '[mig 157] paliad.projects.origin_scenario_id added';
|
||||
RAISE NOTICE '[mig 157] paliad.can_see_scenario(uuid) created';
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -142,6 +142,12 @@ type Services struct {
|
||||
// and per-rule selection state (`rule:<uuid>` keys).
|
||||
ScenarioFlags *services.ScenarioFlagsService
|
||||
|
||||
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder. CRUD over the
|
||||
// new normalised scenario shape (paliad.scenarios with owner_id +
|
||||
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
|
||||
// Nil when DATABASE_URL is unset — /api/builder/scenarios* routes 503.
|
||||
ScenarioBuilder *services.ScenarioBuilderService
|
||||
|
||||
// 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
|
||||
@@ -212,6 +218,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
scenarioFlags: svc.ScenarioFlags,
|
||||
scenarioBuilder: svc.ScenarioBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,6 +521,25 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete)
|
||||
protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario)
|
||||
|
||||
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder API over the
|
||||
// new normalised scenario shape (mig 157). Coexists with the legacy
|
||||
// /api/scenarios surface during the B0→B6 migration; B6 cleanup
|
||||
// retires the legacy routes.
|
||||
protected.HandleFunc("GET /api/builder/scenarios", handleBuilderScenariosList)
|
||||
protected.HandleFunc("POST /api/builder/scenarios", handleBuilderScenarioCreate)
|
||||
protected.HandleFunc("GET /api/builder/scenarios/{id}", handleBuilderScenarioGet)
|
||||
protected.HandleFunc("PATCH /api/builder/scenarios/{id}", handleBuilderScenarioPatch)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings", handleBuilderProceedingCreate)
|
||||
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingPatch)
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingDelete)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings/{pid}/events", handleBuilderEventCreate)
|
||||
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventPatch)
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
|
||||
// Dev-only test route — gated to PaliadinOwnerEmail (m).
|
||||
protected.HandleFunc("GET /dev/scenario-builder", handleBuilderDevTestPage)
|
||||
|
||||
// Partner units (structural partner-led units; legacy "Dezernate").
|
||||
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
|
||||
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)
|
||||
|
||||
@@ -85,6 +85,11 @@ type dbServices struct {
|
||||
|
||||
// m/paliad#149 Phase 2 P0 — per-project scenario_flags SSoT (mig 154).
|
||||
scenarioFlags *services.ScenarioFlagsService
|
||||
|
||||
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder over the new
|
||||
// normalised scenario shape (paliad.scenarios with owner_id +
|
||||
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
|
||||
scenarioBuilder *services.ScenarioBuilderService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
589
internal/handlers/scenario_builder.go
Normal file
589
internal/handlers/scenario_builder.go
Normal file
@@ -0,0 +1,589 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// t-paliad-340 / m/paliad#153 B0 — REST endpoints over the new normalised
|
||||
// scenario builder shape (paliad.scenarios with owner_id, +
|
||||
// paliad.scenario_proceedings / scenario_events / scenario_shares).
|
||||
//
|
||||
// Endpoints live under /api/builder/scenarios/* to avoid clashing with
|
||||
// the legacy /api/scenarios/* endpoints from m/paliad#124 Slice D. The
|
||||
// B6 cleanup slice retires the legacy surface; until then both shapes
|
||||
// coexist on the same paliad.scenarios table (the legacy paths require
|
||||
// project_id IS NOT NULL OR an abstract created_by = caller; the builder
|
||||
// paths require owner_id = caller).
|
||||
//
|
||||
// All handlers gate by requireScenarioBuilderService — 503 when the
|
||||
// service is nil (DATABASE_URL unset). Auth is checked via requireUser;
|
||||
// per-row visibility is enforced inside the service.
|
||||
|
||||
func requireScenarioBuilderService(w http.ResponseWriter) bool {
|
||||
if dbSvc == nil || dbSvc.scenarioBuilder == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Litigation-Builder ist vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// scenarioBuilderErrorToStatus maps service errors to HTTP statuses.
|
||||
func scenarioBuilderErrorToStatus(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrScenarioBuilderNotVisible),
|
||||
errors.Is(err, services.ErrNotVisible):
|
||||
return http.StatusNotFound, "Szenario nicht gefunden"
|
||||
case errors.Is(err, services.ErrInvalidInput):
|
||||
return http.StatusBadRequest, err.Error()
|
||||
}
|
||||
return http.StatusInternalServerError, err.Error()
|
||||
}
|
||||
|
||||
func writeBuilderError(w http.ResponseWriter, err error) {
|
||||
status, msg := scenarioBuilderErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderScenariosList — GET /api/builder/scenarios?status=<active|archived|promoted|all>
|
||||
func handleBuilderScenariosList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
status := r.URL.Query().Get("status")
|
||||
out, err := dbSvc.scenarioBuilder.ListMyScenarios(r.Context(), uid, status)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderScenarioCreate — POST /api/builder/scenarios
|
||||
func handleBuilderScenarioCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.CreateBuilderScenarioInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.CreateScenario(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// handleBuilderScenarioGet — GET /api/builder/scenarios/{id}
|
||||
func handleBuilderScenarioGet(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(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": "ungültige ID"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.GetScenarioDeep(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderScenarioPatch — PATCH /api/builder/scenarios/{id}
|
||||
func handleBuilderScenarioPatch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(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": "ungültige ID"})
|
||||
return
|
||||
}
|
||||
var input services.PatchBuilderScenarioInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.PatchScenario(r.Context(), uid, id, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proceedings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderProceedingCreate — POST /api/builder/scenarios/{id}/proceedings
|
||||
func handleBuilderProceedingCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
var input services.AddProceedingInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.AddProceeding(r.Context(), uid, sid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// handleBuilderProceedingPatch — PATCH /api/builder/scenarios/{id}/proceedings/{pid}
|
||||
func handleBuilderProceedingPatch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
pid, err := uuid.Parse(r.PathValue("pid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
|
||||
return
|
||||
}
|
||||
var input services.PatchProceedingInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.PatchProceeding(r.Context(), uid, sid, pid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderProceedingDelete — DELETE /api/builder/scenarios/{id}/proceedings/{pid}
|
||||
func handleBuilderProceedingDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
pid, err := uuid.Parse(r.PathValue("pid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.scenarioBuilder.DeleteProceeding(r.Context(), uid, sid, pid); err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderEventCreate — POST /api/builder/scenarios/{id}/proceedings/{pid}/events
|
||||
func handleBuilderEventCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
pid, err := uuid.Parse(r.PathValue("pid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
|
||||
return
|
||||
}
|
||||
var input services.AddEventInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.AddEvent(r.Context(), uid, sid, pid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// handleBuilderEventPatch — PATCH /api/builder/scenarios/{id}/events/{eid}
|
||||
func handleBuilderEventPatch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
eid, err := uuid.Parse(r.PathValue("eid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
|
||||
return
|
||||
}
|
||||
var input services.PatchEventInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.PatchEvent(r.Context(), uid, sid, eid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderEventDelete — DELETE /api/builder/scenarios/{id}/events/{eid}
|
||||
func handleBuilderEventDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
eid, err := uuid.Parse(r.PathValue("eid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.scenarioBuilder.DeleteEvent(r.Context(), uid, sid, eid); err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shares
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderShareCreate — POST /api/builder/scenarios/{id}/shares
|
||||
// Body: {"shared_with_user_id": "<uuid>"}
|
||||
func handleBuilderShareCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
SharedWithUserID uuid.UUID `json:"shared_with_user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.AddShare(r.Context(), uid, sid, body.SharedWithUserID)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// handleBuilderShareDelete — DELETE /api/builder/scenarios/{id}/shares/{sid}
|
||||
func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
scid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
shid, err := uuid.Parse(r.PathValue("sid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Share-ID"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.scenarioBuilder.DeleteShare(r.Context(), uid, scid, shid); err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-only test route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderDevTestPage — GET /dev/scenario-builder
|
||||
//
|
||||
// Gated to services.PaliadinOwnerEmail (the same single-owner gate the
|
||||
// /paliadin route uses). Every other authenticated user gets 404. Pure
|
||||
// HTML — no JS bundle — so the page works even before B1 wires the real
|
||||
// builder shell. Renders curl-equivalent forms for the B0 surface so the
|
||||
// schema can be exercised end-to-end without Postman / shell scripts.
|
||||
//
|
||||
// This is the "dev-only test route" the head's task spec asked for. It
|
||||
// disappears in B6 cleanup once the production builder UI ships at
|
||||
// /tools/procedures.
|
||||
func handleBuilderDevTestPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(builderDevTestHTML))
|
||||
}
|
||||
|
||||
const builderDevTestHTML = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Scenario Builder — Dev Test (B0)</title>
|
||||
<style>
|
||||
body { font-family: ui-monospace, Menlo, monospace; max-width: 880px; margin: 2em auto;
|
||||
padding: 0 1em; color: #222; background: #fafaf7; }
|
||||
h1, h2 { font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
h1 { border-bottom: 4px solid #c6f41c; padding-bottom: .2em; }
|
||||
section { background: #fff; border: 1px solid #ddd; border-radius: 4px;
|
||||
padding: 1em 1.2em; margin: 1em 0; }
|
||||
label { display: block; margin: .4em 0 .15em; font-size: .85em; color: #555; }
|
||||
input, textarea, select, button { font: inherit; padding: .35em .5em; box-sizing: border-box; }
|
||||
input[type="text"], input[type="number"], textarea { width: 100%; }
|
||||
button { background: #c6f41c; border: 1px solid #9ec61f; cursor: pointer;
|
||||
padding: .4em 1em; border-radius: 3px; margin: .2em 0; }
|
||||
button.secondary { background: #eee; border-color: #ccc; }
|
||||
pre.out { background: #1e1e1e; color: #e6e6e6; padding: .8em 1em; border-radius: 4px;
|
||||
overflow: auto; max-height: 30em; font-size: .85em; }
|
||||
.note { color: #777; font-size: .9em; }
|
||||
.row { display: flex; gap: .5em; }
|
||||
.row > * { flex: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Scenario Builder — Dev Test (B0)</h1>
|
||||
<p class="note">t-paliad-340 / m/paliad#153 — DB-only slice. Exercises
|
||||
paliad.scenarios (builder rows), scenario_proceedings, scenario_events,
|
||||
scenario_shares via /api/builder/scenarios/*. Gated to PaliadinOwnerEmail.</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Liste meine Szenarien</h2>
|
||||
<label>Status filter</label>
|
||||
<select id="list-status">
|
||||
<option value="">(default: alle)</option>
|
||||
<option value="active">active</option>
|
||||
<option value="archived">archived</option>
|
||||
<option value="promoted">promoted</option>
|
||||
<option value="all">all (explicit)</option>
|
||||
</select>
|
||||
<button onclick="listScenarios()">GET /api/builder/scenarios</button>
|
||||
<pre class="out" id="list-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Szenario anlegen</h2>
|
||||
<label>Name</label>
|
||||
<input type="text" id="create-name" placeholder="(leer = Unbenanntes Szenario)">
|
||||
<label>Notes (optional)</label>
|
||||
<textarea id="create-notes" rows="2"></textarea>
|
||||
<button onclick="createScenario()">POST /api/builder/scenarios</button>
|
||||
<pre class="out" id="create-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Szenario abrufen (deep)</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="get-id">
|
||||
<button onclick="getScenario()">GET /api/builder/scenarios/{id}</button>
|
||||
<pre class="out" id="get-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Verfahren hinzufügen</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="proc-sid">
|
||||
<label>proceeding_type_id (integer)</label>
|
||||
<input type="number" id="proc-pt-id" placeholder="z.B. 7 für upc.inf.cfi">
|
||||
<label>primary_party</label>
|
||||
<select id="proc-party">
|
||||
<option value="">(none)</option>
|
||||
<option value="claimant">claimant</option>
|
||||
<option value="defendant">defendant</option>
|
||||
</select>
|
||||
<button onclick="addProceeding()">POST .../proceedings</button>
|
||||
<pre class="out" id="proc-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Event-Karte hinzufügen</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="ev-sid">
|
||||
<label>Proceeding ID</label>
|
||||
<input type="text" id="ev-pid">
|
||||
<label>custom_label (oder sequencing_rule_id / procedural_event_id)</label>
|
||||
<input type="text" id="ev-label" placeholder="freitext-Karte">
|
||||
<label>state</label>
|
||||
<select id="ev-state">
|
||||
<option value="planned">planned</option>
|
||||
<option value="filed">filed</option>
|
||||
<option value="skipped">skipped</option>
|
||||
</select>
|
||||
<button onclick="addEvent()">POST .../proceedings/{pid}/events</button>
|
||||
<pre class="out" id="ev-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Status patchen (archive / restore)</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="patch-sid">
|
||||
<label>new status</label>
|
||||
<select id="patch-status">
|
||||
<option value="active">active</option>
|
||||
<option value="archived">archived</option>
|
||||
</select>
|
||||
<button onclick="patchStatus()">PATCH /api/builder/scenarios/{id}</button>
|
||||
<pre class="out" id="patch-out"></pre>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const j = (id, payload) =>
|
||||
document.getElementById(id).textContent = JSON.stringify(payload, null, 2);
|
||||
|
||||
async function call(method, url, body) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const r = await fetch(url, opts);
|
||||
const text = await r.text();
|
||||
let parsed = text;
|
||||
try { parsed = JSON.parse(text); } catch (_) {}
|
||||
return { status: r.status, body: parsed };
|
||||
}
|
||||
|
||||
async function listScenarios() {
|
||||
const status = document.getElementById('list-status').value;
|
||||
const q = status ? '?status=' + encodeURIComponent(status) : '';
|
||||
j('list-out', await call('GET', '/api/builder/scenarios' + q));
|
||||
}
|
||||
|
||||
async function createScenario() {
|
||||
const name = document.getElementById('create-name').value;
|
||||
const notes = document.getElementById('create-notes').value;
|
||||
const body = {};
|
||||
if (name) body.name = name;
|
||||
if (notes) body.notes = notes;
|
||||
j('create-out', await call('POST', '/api/builder/scenarios', body));
|
||||
}
|
||||
|
||||
async function getScenario() {
|
||||
const id = document.getElementById('get-id').value.trim();
|
||||
if (!id) return j('get-out', { error: 'ID erforderlich' });
|
||||
j('get-out', await call('GET', '/api/builder/scenarios/' + id));
|
||||
}
|
||||
|
||||
async function addProceeding() {
|
||||
const sid = document.getElementById('proc-sid').value.trim();
|
||||
const ptID = parseInt(document.getElementById('proc-pt-id').value, 10);
|
||||
const party = document.getElementById('proc-party').value;
|
||||
if (!sid || !ptID) return j('proc-out', { error: 'sid + proceeding_type_id erforderlich' });
|
||||
const body = { proceeding_type_id: ptID };
|
||||
if (party) body.primary_party = party;
|
||||
j('proc-out', await call('POST', '/api/builder/scenarios/' + sid + '/proceedings', body));
|
||||
}
|
||||
|
||||
async function addEvent() {
|
||||
const sid = document.getElementById('ev-sid').value.trim();
|
||||
const pid = document.getElementById('ev-pid').value.trim();
|
||||
const label = document.getElementById('ev-label').value.trim();
|
||||
const state = document.getElementById('ev-state').value;
|
||||
if (!sid || !pid || !label) return j('ev-out', { error: 'sid + pid + custom_label erforderlich' });
|
||||
j('ev-out', await call('POST',
|
||||
'/api/builder/scenarios/' + sid + '/proceedings/' + pid + '/events',
|
||||
{ custom_label: label, state }));
|
||||
}
|
||||
|
||||
async function patchStatus() {
|
||||
const sid = document.getElementById('patch-sid').value.trim();
|
||||
const status = document.getElementById('patch-status').value;
|
||||
if (!sid) return j('patch-out', { error: 'sid erforderlich' });
|
||||
j('patch-out', await call('PATCH', '/api/builder/scenarios/' + sid, { status }));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
936
internal/services/scenario_builder_service.go
Normal file
936
internal/services/scenario_builder_service.go
Normal file
@@ -0,0 +1,936 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// ScenarioBuilderService owns the t-paliad-340 / m/paliad#153 B0 surface
|
||||
// — CRUD over the new normalised builder shape (paliad.scenarios with
|
||||
// owner_id + status, paliad.scenario_proceedings, paliad.scenario_events,
|
||||
// paliad.scenario_shares). The legacy spec-jsonb service
|
||||
// (ScenarioService) keeps serving m/paliad#124 Slice D callers; this
|
||||
// service strictly handles builder-owned rows (owner_id IS NOT NULL).
|
||||
//
|
||||
// Visibility is enforced both in code (the owner / share / can_see_project
|
||||
// fall-through) and at the row level via the migration-157 RLS policies.
|
||||
// The application-level check is the load-bearing one — the service
|
||||
// connects with the service-role credential, which bypasses RLS.
|
||||
type ScenarioBuilderService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewScenarioBuilderService wires the service to the shared pool.
|
||||
func NewScenarioBuilderService(db *sqlx.DB) *ScenarioBuilderService {
|
||||
return &ScenarioBuilderService{db: db}
|
||||
}
|
||||
|
||||
// ErrScenarioBuilderNotVisible is returned when the caller is neither
|
||||
// owner, an accepted share recipient, nor a global_admin / legacy
|
||||
// editor for the scenario.
|
||||
var ErrScenarioBuilderNotVisible = errors.New("scenario not visible to caller")
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Row types — flat shapes matching the table columns. Deep tree (scenario +
|
||||
// proceedings + events) is composed at the GET-by-id endpoint.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// BuilderScenario is one paliad.scenarios row from the builder's perspective.
|
||||
// Legacy columns (project_id, description, spec, created_by) are still
|
||||
// returned so a UI can detect a legacy row and refuse to mutate it.
|
||||
type BuilderScenario struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
OwnerID *uuid.UUID `db:"owner_id" json:"owner_id,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Status string `db:"status" json:"status"`
|
||||
OriginProjectID *uuid.UUID `db:"origin_project_id" json:"origin_project_id,omitempty"`
|
||||
PromotedProjectID *uuid.UUID `db:"promoted_project_id" json:"promoted_project_id,omitempty"`
|
||||
Stichtag *time.Time `db:"stichtag" json:"stichtag,omitempty"`
|
||||
Notes *string `db:"notes" json:"notes,omitempty"`
|
||||
LegacyProjectID *uuid.UUID `db:"project_id" json:"legacy_project_id,omitempty"`
|
||||
LegacyDescription *string `db:"description" json:"legacy_description,omitempty"`
|
||||
LegacyCreatedBy *uuid.UUID `db:"created_by" json:"legacy_created_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// BuilderProceeding is one paliad.scenario_proceedings row.
|
||||
type BuilderProceeding struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ScenarioID uuid.UUID `db:"scenario_id" json:"scenario_id"`
|
||||
ProceedingTypeID int `db:"proceeding_type_id" json:"proceeding_type_id"`
|
||||
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||
ScenarioFlags json.RawMessage `db:"scenario_flags" json:"scenario_flags"`
|
||||
ParentScenarioProceedingID *uuid.UUID `db:"parent_scenario_proceeding_id" json:"parent_scenario_proceeding_id,omitempty"`
|
||||
SpawnAnchorEventID *uuid.UUID `db:"spawn_anchor_event_id" json:"spawn_anchor_event_id,omitempty"`
|
||||
Ordinal int `db:"ordinal" json:"ordinal"`
|
||||
Stichtag *time.Time `db:"stichtag" json:"stichtag,omitempty"`
|
||||
Detailgrad string `db:"detailgrad" json:"detailgrad"`
|
||||
AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`
|
||||
Collapsed bool `db:"collapsed" json:"collapsed"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// BuilderEvent is one paliad.scenario_events row.
|
||||
type BuilderEvent struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ScenarioProceedingID uuid.UUID `db:"scenario_proceeding_id" json:"scenario_proceeding_id"`
|
||||
SequencingRuleID *uuid.UUID `db:"sequencing_rule_id" json:"sequencing_rule_id,omitempty"`
|
||||
ProceduralEventID *uuid.UUID `db:"procedural_event_id" json:"procedural_event_id,omitempty"`
|
||||
CustomLabel *string `db:"custom_label" json:"custom_label,omitempty"`
|
||||
State string `db:"state" json:"state"`
|
||||
ActualDate *time.Time `db:"actual_date" json:"actual_date,omitempty"`
|
||||
SkipReason *string `db:"skip_reason" json:"skip_reason,omitempty"`
|
||||
Notes *string `db:"notes" json:"notes,omitempty"`
|
||||
HorizonOptional int `db:"horizon_optional" json:"horizon_optional"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// BuilderShare is one paliad.scenario_shares row.
|
||||
type BuilderShare struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ScenarioID uuid.UUID `db:"scenario_id" json:"scenario_id"`
|
||||
SharedWithUserID uuid.UUID `db:"shared_with_user_id" json:"shared_with_user_id"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// BuilderScenarioDeep bundles a scenario with its proceedings + events
|
||||
// for the GET /api/builder/scenarios/{id} response. Proceedings sort by
|
||||
// ordinal asc; events sort by created_at asc within a proceeding.
|
||||
type BuilderScenarioDeep struct {
|
||||
BuilderScenario
|
||||
Proceedings []BuilderProceeding `json:"proceedings"`
|
||||
Events []BuilderEvent `json:"events"`
|
||||
Shares []BuilderShare `json:"shares"`
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Scenario CRUD
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// CreateBuilderScenarioInput is the POST /api/builder/scenarios body.
|
||||
// Name defaults to "Unbenanntes Szenario" when blank (PRD §5.1).
|
||||
type CreateBuilderScenarioInput struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Stichtag *time.Time `json:"stichtag,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
OriginProjectID *uuid.UUID `json:"origin_project_id,omitempty"`
|
||||
}
|
||||
|
||||
// CreateScenario inserts a new builder-owned scenario. owner_id is set to
|
||||
// the caller; status defaults to 'active'. Audit reason is set inside the
|
||||
// write tx so any future audit trigger picks it up.
|
||||
func (s *ScenarioBuilderService) CreateScenario(ctx context.Context, userID uuid.UUID, input CreateBuilderScenarioInput) (*BuilderScenario, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
name = "Unbenanntes Szenario"
|
||||
}
|
||||
|
||||
var out BuilderScenario
|
||||
err := s.withAuditTx(ctx, "scenario_builder: create scenario", func(tx *sqlx.Tx) error {
|
||||
return tx.GetContext(ctx, &out,
|
||||
`INSERT INTO paliad.scenarios
|
||||
(owner_id, name, status, stichtag, notes, origin_project_id)
|
||||
VALUES ($1, $2, 'active', $3, $4, $5)
|
||||
RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
|
||||
stichtag, notes,
|
||||
project_id, description, created_by,
|
||||
created_at, updated_at`,
|
||||
userID, name, input.Stichtag, input.Notes, input.OriginProjectID)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create builder scenario: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ListMyScenarios returns the caller's owned scenarios filtered by status.
|
||||
// Status "" (or "all") returns every status; otherwise filters by the
|
||||
// given enum value. Sorted by updated_at desc.
|
||||
func (s *ScenarioBuilderService) ListMyScenarios(ctx context.Context, userID uuid.UUID, status string) ([]BuilderScenario, error) {
|
||||
switch status {
|
||||
case "", "all":
|
||||
// no filter
|
||||
case "active", "archived", "promoted":
|
||||
// ok
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: status %q must be one of {active,archived,promoted,all}",
|
||||
ErrInvalidInput, status)
|
||||
}
|
||||
|
||||
q := `SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
|
||||
stichtag, notes,
|
||||
project_id, description, created_by,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE owner_id = $1`
|
||||
args := []any{userID}
|
||||
if status != "" && status != "all" {
|
||||
q += ` AND status = $2`
|
||||
args = append(args, status)
|
||||
}
|
||||
q += ` ORDER BY updated_at DESC`
|
||||
|
||||
out := []BuilderScenario{}
|
||||
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list builder scenarios: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetScenarioDeep returns the scenario + proceedings + events + shares.
|
||||
// Visibility: owner, share recipient, global_admin, or legacy editor.
|
||||
func (s *ScenarioBuilderService) GetScenarioDeep(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenarioDeep, error) {
|
||||
sc, err := s.getScenarioRow(ctx, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible, err := s.canSeeScenario(ctx, userID, sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !visible {
|
||||
return nil, ErrScenarioBuilderNotVisible
|
||||
}
|
||||
|
||||
deep := &BuilderScenarioDeep{BuilderScenario: *sc}
|
||||
|
||||
if err := s.db.SelectContext(ctx, &deep.Proceedings, `
|
||||
SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
||||
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
||||
stichtag, detailgrad, appeal_target, collapsed,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenario_proceedings
|
||||
WHERE scenario_id = $1
|
||||
ORDER BY ordinal ASC, created_at ASC`, scenarioID); err != nil {
|
||||
return nil, fmt.Errorf("load proceedings: %w", err)
|
||||
}
|
||||
|
||||
if err := s.db.SelectContext(ctx, &deep.Events, `
|
||||
SELECT e.id, e.scenario_proceeding_id, e.sequencing_rule_id,
|
||||
e.procedural_event_id, e.custom_label, e.state, e.actual_date,
|
||||
e.skip_reason, e.notes, e.horizon_optional,
|
||||
e.created_at, e.updated_at
|
||||
FROM paliad.scenario_events e
|
||||
JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
|
||||
WHERE sp.scenario_id = $1
|
||||
ORDER BY e.created_at ASC`, scenarioID); err != nil {
|
||||
return nil, fmt.Errorf("load events: %w", err)
|
||||
}
|
||||
|
||||
if err := s.db.SelectContext(ctx, &deep.Shares, `
|
||||
SELECT id, scenario_id, shared_with_user_id, created_by, created_at
|
||||
FROM paliad.scenario_shares
|
||||
WHERE scenario_id = $1
|
||||
ORDER BY created_at ASC`, scenarioID); err != nil {
|
||||
return nil, fmt.Errorf("load shares: %w", err)
|
||||
}
|
||||
|
||||
return deep, nil
|
||||
}
|
||||
|
||||
// PatchBuilderScenarioInput is the PATCH /api/builder/scenarios/{id} body.
|
||||
// Any nil field means "don't change".
|
||||
type PatchBuilderScenarioInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Stichtag *time.Time `json:"stichtag,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// PatchScenario updates one or more fields. Status flips to 'promoted'
|
||||
// are reserved for the B5 wizard (we accept only active⇄archived here).
|
||||
func (s *ScenarioBuilderService) PatchScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PatchBuilderScenarioInput) (*BuilderScenario, error) {
|
||||
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sc.Status == "promoted" {
|
||||
return nil, fmt.Errorf("%w: scenario is promoted; mutations are blocked", ErrInvalidInput)
|
||||
}
|
||||
|
||||
if input.Status != nil {
|
||||
switch *input.Status {
|
||||
case "active", "archived":
|
||||
// ok
|
||||
case "promoted":
|
||||
return nil, fmt.Errorf("%w: status='promoted' is set by the promote-to-project wizard, not PATCH",
|
||||
ErrInvalidInput)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: status %q must be one of {active,archived}",
|
||||
ErrInvalidInput, *input.Status)
|
||||
}
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
sets = append(sets, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if input.Name != nil {
|
||||
n := strings.TrimSpace(*input.Name)
|
||||
if n == "" {
|
||||
return nil, fmt.Errorf("%w: name cannot be blank", ErrInvalidInput)
|
||||
}
|
||||
add("name = $%d", n)
|
||||
}
|
||||
if input.Status != nil {
|
||||
add("status = $%d", *input.Status)
|
||||
}
|
||||
if input.Stichtag != nil {
|
||||
add("stichtag = $%d", *input.Stichtag)
|
||||
}
|
||||
if input.Notes != nil {
|
||||
add("notes = $%d", *input.Notes)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
args = append(args, scenarioID)
|
||||
q := fmt.Sprintf(`UPDATE paliad.scenarios SET %s
|
||||
WHERE id = $%d
|
||||
RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
|
||||
stichtag, notes,
|
||||
project_id, description, created_by,
|
||||
created_at, updated_at`,
|
||||
strings.Join(sets, ", "), len(args))
|
||||
var out BuilderScenario
|
||||
err = s.withAuditTx(ctx, "scenario_builder: patch scenario", func(tx *sqlx.Tx) error {
|
||||
return tx.GetContext(ctx, &out, q, args...)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("patch builder scenario: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Proceedings
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// AddProceedingInput is the POST /api/builder/scenarios/{id}/proceedings body.
|
||||
type AddProceedingInput struct {
|
||||
ProceedingTypeID int `json:"proceeding_type_id"`
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
ScenarioFlags json.RawMessage `json:"scenario_flags,omitempty"`
|
||||
ParentScenarioProceedingID *uuid.UUID `json:"parent_scenario_proceeding_id,omitempty"`
|
||||
SpawnAnchorEventID *uuid.UUID `json:"spawn_anchor_event_id,omitempty"`
|
||||
Ordinal *int `json:"ordinal,omitempty"`
|
||||
Stichtag *time.Time `json:"stichtag,omitempty"`
|
||||
Detailgrad *string `json:"detailgrad,omitempty"`
|
||||
AppealTarget *string `json:"appeal_target,omitempty"`
|
||||
}
|
||||
|
||||
// AddProceeding appends a proceeding row to the scenario. The caller must
|
||||
// own the scenario (or be a legacy editor). Ordinal defaults to max+1.
|
||||
func (s *ScenarioBuilderService) AddProceeding(ctx context.Context, userID, scenarioID uuid.UUID, input AddProceedingInput) (*BuilderProceeding, error) {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.ProceedingTypeID == 0 {
|
||||
return nil, fmt.Errorf("%w: proceeding_type_id is required", ErrInvalidInput)
|
||||
}
|
||||
if input.PrimaryParty != nil {
|
||||
switch *input.PrimaryParty {
|
||||
case "claimant", "defendant":
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: primary_party %q must be claimant or defendant",
|
||||
ErrInvalidInput, *input.PrimaryParty)
|
||||
}
|
||||
}
|
||||
detailgrad := "selected"
|
||||
if input.Detailgrad != nil {
|
||||
switch *input.Detailgrad {
|
||||
case "selected", "all_options":
|
||||
detailgrad = *input.Detailgrad
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: detailgrad %q must be selected or all_options",
|
||||
ErrInvalidInput, *input.Detailgrad)
|
||||
}
|
||||
}
|
||||
flags := input.ScenarioFlags
|
||||
if len(flags) == 0 {
|
||||
flags = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
// Resolve ordinal: caller's value or max+1 within the same scenario.
|
||||
var ordinal int
|
||||
if input.Ordinal != nil {
|
||||
ordinal = *input.Ordinal
|
||||
} else {
|
||||
if err := s.db.GetContext(ctx, &ordinal,
|
||||
`SELECT COALESCE(MAX(ordinal), -1) + 1
|
||||
FROM paliad.scenario_proceedings
|
||||
WHERE scenario_id = $1`, scenarioID); err != nil {
|
||||
return nil, fmt.Errorf("compute ordinal: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var out BuilderProceeding
|
||||
err := s.withAuditTx(ctx, "scenario_builder: add proceeding", func(tx *sqlx.Tx) error {
|
||||
if err := tx.GetContext(ctx, &out,
|
||||
`INSERT INTO paliad.scenario_proceedings
|
||||
(scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
||||
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
||||
stichtag, detailgrad, appeal_target)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
||||
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
||||
stichtag, detailgrad, appeal_target, collapsed,
|
||||
created_at, updated_at`,
|
||||
scenarioID, input.ProceedingTypeID, input.PrimaryParty, []byte(flags),
|
||||
input.ParentScenarioProceedingID, input.SpawnAnchorEventID, ordinal,
|
||||
input.Stichtag, detailgrad, input.AppealTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
// touch the scenario's updated_at so the side panel re-orders correctly.
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.scenarios SET updated_at = now() WHERE id = $1`, scenarioID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add proceeding: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// PatchProceedingInput accepts a subset of mutable proceeding fields.
|
||||
type PatchProceedingInput struct {
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
ScenarioFlags json.RawMessage `json:"scenario_flags,omitempty"`
|
||||
Ordinal *int `json:"ordinal,omitempty"`
|
||||
Stichtag *time.Time `json:"stichtag,omitempty"`
|
||||
Detailgrad *string `json:"detailgrad,omitempty"`
|
||||
AppealTarget *string `json:"appeal_target,omitempty"`
|
||||
Collapsed *bool `json:"collapsed,omitempty"`
|
||||
}
|
||||
|
||||
// PatchProceeding updates fields on one proceeding row.
|
||||
func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input PatchProceedingInput) (*BuilderProceeding, error) {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
sets = append(sets, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if input.PrimaryParty != nil {
|
||||
switch *input.PrimaryParty {
|
||||
case "claimant", "defendant", "":
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: primary_party %q invalid", ErrInvalidInput, *input.PrimaryParty)
|
||||
}
|
||||
if *input.PrimaryParty == "" {
|
||||
add("primary_party = $%d", nil)
|
||||
} else {
|
||||
add("primary_party = $%d", *input.PrimaryParty)
|
||||
}
|
||||
}
|
||||
if len(input.ScenarioFlags) > 0 {
|
||||
add("scenario_flags = $%d", []byte(input.ScenarioFlags))
|
||||
}
|
||||
if input.Ordinal != nil {
|
||||
add("ordinal = $%d", *input.Ordinal)
|
||||
}
|
||||
if input.Stichtag != nil {
|
||||
add("stichtag = $%d", *input.Stichtag)
|
||||
}
|
||||
if input.Detailgrad != nil {
|
||||
switch *input.Detailgrad {
|
||||
case "selected", "all_options":
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: detailgrad %q invalid", ErrInvalidInput, *input.Detailgrad)
|
||||
}
|
||||
add("detailgrad = $%d", *input.Detailgrad)
|
||||
}
|
||||
if input.AppealTarget != nil {
|
||||
if *input.AppealTarget == "" {
|
||||
add("appeal_target = $%d", nil)
|
||||
} else {
|
||||
add("appeal_target = $%d", *input.AppealTarget)
|
||||
}
|
||||
}
|
||||
if input.Collapsed != nil {
|
||||
add("collapsed = $%d", *input.Collapsed)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
// nothing to do — re-fetch and return.
|
||||
return s.getProceedingRow(ctx, scenarioID, proceedingID)
|
||||
}
|
||||
|
||||
args = append(args, proceedingID, scenarioID)
|
||||
q := fmt.Sprintf(`UPDATE paliad.scenario_proceedings SET %s
|
||||
WHERE id = $%d AND scenario_id = $%d
|
||||
RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
||||
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
||||
stichtag, detailgrad, appeal_target, collapsed,
|
||||
created_at, updated_at`,
|
||||
strings.Join(sets, ", "), len(args)-1, len(args))
|
||||
var out BuilderProceeding
|
||||
err := s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
|
||||
return tx.GetContext(ctx, &out, q, args...)
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: proceeding %s not in scenario %s",
|
||||
ErrNotVisible, proceedingID, scenarioID)
|
||||
}
|
||||
return nil, fmt.Errorf("patch proceeding: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// DeleteProceeding removes a proceeding (and cascades to events + children).
|
||||
func (s *ScenarioBuilderService) DeleteProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID) error {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
return err
|
||||
}
|
||||
var n int64
|
||||
err := s.withAuditTx(ctx, "scenario_builder: delete proceeding", func(tx *sqlx.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.scenario_proceedings
|
||||
WHERE id = $1 AND scenario_id = $2`,
|
||||
proceedingID, scenarioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ = res.RowsAffected()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete proceeding: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: proceeding %s not in scenario %s",
|
||||
ErrNotVisible, proceedingID, scenarioID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Events
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// AddEventInput is the POST .../proceedings/{pid}/events body. At least
|
||||
// one of {SequencingRuleID, ProceduralEventID, CustomLabel} must be set,
|
||||
// matching the scenario_events_one_anchor CHECK constraint.
|
||||
type AddEventInput struct {
|
||||
SequencingRuleID *uuid.UUID `json:"sequencing_rule_id,omitempty"`
|
||||
ProceduralEventID *uuid.UUID `json:"procedural_event_id,omitempty"`
|
||||
CustomLabel *string `json:"custom_label,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
ActualDate *time.Time `json:"actual_date,omitempty"`
|
||||
SkipReason *string `json:"skip_reason,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
HorizonOptional *int `json:"horizon_optional,omitempty"`
|
||||
}
|
||||
|
||||
// AddEvent inserts an event card under the given proceeding. The
|
||||
// proceeding must belong to the addressed scenario.
|
||||
func (s *ScenarioBuilderService) AddEvent(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input AddEventInput) (*BuilderEvent, error) {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.SequencingRuleID == nil && input.ProceduralEventID == nil &&
|
||||
(input.CustomLabel == nil || strings.TrimSpace(*input.CustomLabel) == "") {
|
||||
return nil, fmt.Errorf("%w: at least one of sequencing_rule_id, procedural_event_id, custom_label must be set",
|
||||
ErrInvalidInput)
|
||||
}
|
||||
if err := s.assertProceedingInScenario(ctx, scenarioID, proceedingID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := "planned"
|
||||
if input.State != nil {
|
||||
switch *input.State {
|
||||
case "planned", "filed", "skipped":
|
||||
state = *input.State
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: state %q must be one of {planned,filed,skipped}",
|
||||
ErrInvalidInput, *input.State)
|
||||
}
|
||||
}
|
||||
horizon := 0
|
||||
if input.HorizonOptional != nil {
|
||||
if *input.HorizonOptional < 0 {
|
||||
return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput)
|
||||
}
|
||||
horizon = *input.HorizonOptional
|
||||
}
|
||||
|
||||
var out BuilderEvent
|
||||
err := s.withAuditTx(ctx, "scenario_builder: add event", func(tx *sqlx.Tx) error {
|
||||
if err := tx.GetContext(ctx, &out,
|
||||
`INSERT INTO paliad.scenario_events
|
||||
(scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
|
||||
custom_label, state, actual_date, skip_reason, notes, horizon_optional)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
|
||||
custom_label, state, actual_date, skip_reason, notes,
|
||||
horizon_optional, created_at, updated_at`,
|
||||
proceedingID, input.SequencingRuleID, input.ProceduralEventID,
|
||||
input.CustomLabel, state, input.ActualDate, input.SkipReason,
|
||||
input.Notes, horizon); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.scenarios SET updated_at = now() WHERE id = $1`, scenarioID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add event: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// PatchEventInput is the PATCH body for an event card.
|
||||
type PatchEventInput struct {
|
||||
State *string `json:"state,omitempty"`
|
||||
ActualDate *time.Time `json:"actual_date,omitempty"`
|
||||
SkipReason *string `json:"skip_reason,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
HorizonOptional *int `json:"horizon_optional,omitempty"`
|
||||
}
|
||||
|
||||
// PatchEvent updates fields on one event card. The card's parent
|
||||
// proceeding must belong to the addressed scenario.
|
||||
func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID, input PatchEventInput) (*BuilderEvent, error) {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
sets = append(sets, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if input.State != nil {
|
||||
switch *input.State {
|
||||
case "planned", "filed", "skipped":
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: state %q invalid", ErrInvalidInput, *input.State)
|
||||
}
|
||||
add("state = $%d", *input.State)
|
||||
}
|
||||
if input.ActualDate != nil {
|
||||
add("actual_date = $%d", *input.ActualDate)
|
||||
}
|
||||
if input.SkipReason != nil {
|
||||
add("skip_reason = $%d", *input.SkipReason)
|
||||
}
|
||||
if input.Notes != nil {
|
||||
add("notes = $%d", *input.Notes)
|
||||
}
|
||||
if input.HorizonOptional != nil {
|
||||
if *input.HorizonOptional < 0 {
|
||||
return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput)
|
||||
}
|
||||
add("horizon_optional = $%d", *input.HorizonOptional)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return s.getEventRow(ctx, eventID)
|
||||
}
|
||||
|
||||
args = append(args, eventID)
|
||||
q := fmt.Sprintf(`UPDATE paliad.scenario_events SET %s
|
||||
WHERE id = $%d
|
||||
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
|
||||
custom_label, state, actual_date, skip_reason, notes,
|
||||
horizon_optional, created_at, updated_at`,
|
||||
strings.Join(sets, ", "), len(args))
|
||||
var out BuilderEvent
|
||||
err := s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
|
||||
return tx.GetContext(ctx, &out, q, args...)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("patch event: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// DeleteEvent removes one event card.
|
||||
func (s *ScenarioBuilderService) DeleteEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID) error {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
err := s.withAuditTx(ctx, "scenario_builder: delete event", func(tx *sqlx.Tx) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.scenario_events WHERE id = $1`, eventID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Shares
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// AddShare grants read-only access to another paliad user.
|
||||
func (s *ScenarioBuilderService) AddShare(ctx context.Context, userID, scenarioID, recipientID uuid.UUID) (*BuilderShare, error) {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if recipientID == uuid.Nil {
|
||||
return nil, fmt.Errorf("%w: shared_with_user_id is required", ErrInvalidInput)
|
||||
}
|
||||
if recipientID == userID {
|
||||
return nil, fmt.Errorf("%w: cannot share a scenario with yourself", ErrInvalidInput)
|
||||
}
|
||||
var out BuilderShare
|
||||
err := s.withAuditTx(ctx, "scenario_builder: add share", func(tx *sqlx.Tx) error {
|
||||
return tx.GetContext(ctx, &out,
|
||||
`INSERT INTO paliad.scenario_shares (scenario_id, shared_with_user_id, created_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (scenario_id, shared_with_user_id) DO UPDATE
|
||||
SET created_at = paliad.scenario_shares.created_at
|
||||
RETURNING id, scenario_id, shared_with_user_id, created_by, created_at`,
|
||||
scenarioID, recipientID, userID)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add share: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// DeleteShare revokes a share row.
|
||||
func (s *ScenarioBuilderService) DeleteShare(ctx context.Context, userID, scenarioID, shareID uuid.UUID) error {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
return err
|
||||
}
|
||||
var n int64
|
||||
err := s.withAuditTx(ctx, "scenario_builder: delete share", func(tx *sqlx.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.scenario_shares
|
||||
WHERE id = $1 AND scenario_id = $2`, shareID, scenarioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ = res.RowsAffected()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete share: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: share %s not in scenario %s", ErrNotVisible, shareID, scenarioID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func (s *ScenarioBuilderService) getScenarioRow(ctx context.Context, scenarioID uuid.UUID) (*BuilderScenario, error) {
|
||||
var out BuilderScenario
|
||||
err := s.db.GetContext(ctx, &out,
|
||||
`SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
|
||||
stichtag, notes,
|
||||
project_id, description, created_by,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE id = $1`, scenarioID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: scenario %s not found", ErrNotVisible, scenarioID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get scenario: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *ScenarioBuilderService) getProceedingRow(ctx context.Context, scenarioID, proceedingID uuid.UUID) (*BuilderProceeding, error) {
|
||||
var out BuilderProceeding
|
||||
err := s.db.GetContext(ctx, &out,
|
||||
`SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
||||
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
||||
stichtag, detailgrad, appeal_target, collapsed,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenario_proceedings
|
||||
WHERE id = $1 AND scenario_id = $2`, proceedingID, scenarioID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: proceeding %s not in scenario %s",
|
||||
ErrNotVisible, proceedingID, scenarioID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get proceeding: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *ScenarioBuilderService) getEventRow(ctx context.Context, eventID uuid.UUID) (*BuilderEvent, error) {
|
||||
var out BuilderEvent
|
||||
err := s.db.GetContext(ctx, &out,
|
||||
`SELECT id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
|
||||
custom_label, state, actual_date, skip_reason, notes,
|
||||
horizon_optional, created_at, updated_at
|
||||
FROM paliad.scenario_events
|
||||
WHERE id = $1`, eventID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: event %s not found", ErrNotVisible, eventID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get event: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *ScenarioBuilderService) assertProceedingInScenario(ctx context.Context, scenarioID, proceedingID uuid.UUID) error {
|
||||
var exists bool
|
||||
if err := s.db.GetContext(ctx, &exists,
|
||||
`SELECT EXISTS (SELECT 1 FROM paliad.scenario_proceedings
|
||||
WHERE id = $1 AND scenario_id = $2)`,
|
||||
proceedingID, scenarioID); err != nil {
|
||||
return fmt.Errorf("check proceeding membership: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%w: proceeding %s not in scenario %s",
|
||||
ErrNotVisible, proceedingID, scenarioID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScenarioBuilderService) assertEventInScenario(ctx context.Context, scenarioID, eventID uuid.UUID) error {
|
||||
var exists bool
|
||||
if err := s.db.GetContext(ctx, &exists,
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.scenario_events e
|
||||
JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
|
||||
WHERE e.id = $1 AND sp.scenario_id = $2
|
||||
)`,
|
||||
eventID, scenarioID); err != nil {
|
||||
return fmt.Errorf("check event membership: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%w: event %s not in scenario %s",
|
||||
ErrNotVisible, eventID, scenarioID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// canSeeScenario mirrors the SQL paliad.can_see_scenario(...) function in
|
||||
// Go. The service connection bypasses RLS, so this check is the
|
||||
// authoritative gate.
|
||||
func (s *ScenarioBuilderService) canSeeScenario(ctx context.Context, userID uuid.UUID, sc *BuilderScenario) (bool, error) {
|
||||
// owner — fast path
|
||||
if sc.OwnerID != nil && *sc.OwnerID == userID {
|
||||
return true, nil
|
||||
}
|
||||
// global_admin
|
||||
var isAdmin bool
|
||||
if err := s.db.GetContext(ctx, &isAdmin,
|
||||
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1 AND global_role = 'global_admin')`,
|
||||
userID); err != nil {
|
||||
return false, fmt.Errorf("check global_admin: %w", err)
|
||||
}
|
||||
if isAdmin {
|
||||
return true, nil
|
||||
}
|
||||
// share recipient
|
||||
var shared bool
|
||||
if err := s.db.GetContext(ctx, &shared,
|
||||
`SELECT EXISTS (SELECT 1 FROM paliad.scenario_shares
|
||||
WHERE scenario_id = $1 AND shared_with_user_id = $2)`,
|
||||
sc.ID, userID); err != nil {
|
||||
return false, fmt.Errorf("check share: %w", err)
|
||||
}
|
||||
if shared {
|
||||
return true, nil
|
||||
}
|
||||
// legacy project-scoped — visible via project team membership
|
||||
if sc.OwnerID == nil && sc.LegacyProjectID != nil {
|
||||
var ok bool
|
||||
if err := s.db.GetContext(ctx, &ok,
|
||||
`SELECT paliad.can_see_project($1::uuid)`,
|
||||
*sc.LegacyProjectID); err == nil && ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
// legacy abstract — owner-only via created_by
|
||||
if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil &&
|
||||
*sc.LegacyCreatedBy == userID {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// requireOwnerOrLegacyEditor fetches the scenario and validates that the
|
||||
// caller has write rights. Returns the loaded row for downstream use.
|
||||
func (s *ScenarioBuilderService) requireOwnerOrLegacyEditor(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenario, error) {
|
||||
sc, err := s.getScenarioRow(ctx, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// owner
|
||||
if sc.OwnerID != nil && *sc.OwnerID == userID {
|
||||
return sc, nil
|
||||
}
|
||||
// legacy project-scoped editor
|
||||
if sc.OwnerID == nil && sc.LegacyProjectID != nil {
|
||||
var ok bool
|
||||
if err := s.db.GetContext(ctx, &ok,
|
||||
`SELECT paliad.can_see_project($1::uuid)`,
|
||||
*sc.LegacyProjectID); err == nil && ok {
|
||||
return sc, nil
|
||||
}
|
||||
}
|
||||
// legacy abstract creator
|
||||
if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil &&
|
||||
*sc.LegacyCreatedBy == userID {
|
||||
return sc, nil
|
||||
}
|
||||
return nil, ErrScenarioBuilderNotVisible
|
||||
}
|
||||
|
||||
// withAuditTx opens a transaction, stamps paliad.audit_reason via
|
||||
// set_config(..., true) so the reason persists for the duration of the
|
||||
// tx (matching the mig-079 audit-trigger pattern used by event_choice_
|
||||
// service.go), invokes fn, and commits. Any error returned by fn rolls
|
||||
// back. The audit reason is appended with the task slug so audit-log
|
||||
// readers can trace writes back to t-paliad-340.
|
||||
func (s *ScenarioBuilderService) withAuditTx(ctx context.Context, reason string, fn func(tx *sqlx.Tx) error) error {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason', $1, true)`,
|
||||
fmt.Sprintf("%s (t-paliad-340)", reason)); err != nil {
|
||||
return fmt.Errorf("set audit_reason: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
220
internal/services/scenario_builder_service_test.go
Normal file
220
internal/services/scenario_builder_service_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestScenarioBuilderService exercises the t-paliad-340 / m/paliad#153 B0
|
||||
// surface end-to-end against a live DB: create + list + deep-get + patch
|
||||
// + add-proceeding + add-event + add/delete-share, plus the visibility
|
||||
// negative case (a non-owner can't see the scenario unless shared).
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL — matches the pattern in
|
||||
// project_service_test.go / event_choice_service_test.go.
|
||||
func TestScenarioBuilderService(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
owner := uuid.New()
|
||||
other := uuid.New()
|
||||
cleanup := func() {
|
||||
// Cascade order: delete from scenarios → CASCADE clears
|
||||
// proceedings, events, shares. Then the two users.
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.scenarios WHERE owner_id IN ($1, $2)`, owner, other)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.users WHERE id IN ($1, $2)`, owner, other)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM auth.users WHERE id IN ($1, $2)`, owner, other)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
for _, seed := range []struct {
|
||||
id uuid.UUID
|
||||
email string
|
||||
name string
|
||||
}{
|
||||
{owner, "builder-owner-test@hlc.com", "Builder Owner"},
|
||||
{other, "builder-other-test@hlc.com", "Builder Other"},
|
||||
} {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
|
||||
seed.id, seed.email); err != nil {
|
||||
t.Fatalf("seed auth.users %s: %v", seed.email, err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, lang)
|
||||
VALUES ($1, $2, $3, 'munich', 'de')`,
|
||||
seed.id, seed.email, seed.name); err != nil {
|
||||
t.Fatalf("seed paliad.users %s: %v", seed.email, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pick a real proceeding_type_id so the FK insert succeeds.
|
||||
var ptID int
|
||||
if err := pool.GetContext(ctx, &ptID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true
|
||||
LIMIT 1`, CodeUPCInfringement); err != nil {
|
||||
t.Fatalf("look up upc.inf id: %v", err)
|
||||
}
|
||||
|
||||
svc := NewScenarioBuilderService(pool)
|
||||
|
||||
// 1. Create a scenario for the owner. Empty name should default.
|
||||
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenario: %v", err)
|
||||
}
|
||||
if sc.Name != "Unbenanntes Szenario" {
|
||||
t.Errorf("default name = %q, want %q", sc.Name, "Unbenanntes Szenario")
|
||||
}
|
||||
if sc.Status != "active" {
|
||||
t.Errorf("default status = %q, want active", sc.Status)
|
||||
}
|
||||
if sc.OwnerID == nil || *sc.OwnerID != owner {
|
||||
t.Errorf("owner_id = %v, want %v", sc.OwnerID, owner)
|
||||
}
|
||||
|
||||
// 2. List — should return the one row.
|
||||
list, err := svc.ListMyScenarios(ctx, owner, "active")
|
||||
if err != nil {
|
||||
t.Fatalf("ListMyScenarios: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != sc.ID {
|
||||
t.Errorf("ListMyScenarios returned %d rows; want 1 with id %s", len(list), sc.ID)
|
||||
}
|
||||
|
||||
// 3. Other user can NOT see the scenario.
|
||||
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
|
||||
t.Errorf("GetScenarioDeep by non-owner = %v, want ErrScenarioBuilderNotVisible", err)
|
||||
}
|
||||
|
||||
// 4. Add a proceeding.
|
||||
pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
|
||||
ProceedingTypeID: ptID,
|
||||
PrimaryParty: ptrString("defendant"),
|
||||
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddProceeding: %v", err)
|
||||
}
|
||||
if pr.ProceedingTypeID != ptID {
|
||||
t.Errorf("ProceedingTypeID = %d, want %d", pr.ProceedingTypeID, ptID)
|
||||
}
|
||||
if pr.PrimaryParty == nil || *pr.PrimaryParty != "defendant" {
|
||||
t.Errorf("PrimaryParty = %v, want defendant", pr.PrimaryParty)
|
||||
}
|
||||
|
||||
// 5. Add a custom-label event card.
|
||||
ev, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
|
||||
CustomLabel: ptrString("Klageerwiderung"),
|
||||
State: ptrString("planned"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddEvent: %v", err)
|
||||
}
|
||||
if ev.State != "planned" {
|
||||
t.Errorf("event state = %q, want planned", ev.State)
|
||||
}
|
||||
|
||||
// 5b. Add-event with NO anchor fields fails.
|
||||
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("AddEvent without anchor = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// 6. Deep get — should bundle the scenario + 1 proceeding + 1 event + 0 shares.
|
||||
deep, err := svc.GetScenarioDeep(ctx, owner, sc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetScenarioDeep: %v", err)
|
||||
}
|
||||
if len(deep.Proceedings) != 1 || deep.Proceedings[0].ID != pr.ID {
|
||||
t.Errorf("deep proceedings count=%d want 1; ids: %+v", len(deep.Proceedings), deep.Proceedings)
|
||||
}
|
||||
if len(deep.Events) != 1 || deep.Events[0].ID != ev.ID {
|
||||
t.Errorf("deep events count=%d want 1; ids: %+v", len(deep.Events), deep.Events)
|
||||
}
|
||||
if len(deep.Shares) != 0 {
|
||||
t.Errorf("deep shares count=%d want 0", len(deep.Shares))
|
||||
}
|
||||
|
||||
// 7. Share with `other`. Recipient should now see the scenario.
|
||||
sh, err := svc.AddShare(ctx, owner, sc.ID, other)
|
||||
if err != nil {
|
||||
t.Fatalf("AddShare: %v", err)
|
||||
}
|
||||
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); err != nil {
|
||||
t.Errorf("GetScenarioDeep by share recipient: %v", err)
|
||||
}
|
||||
// But the recipient can NOT add proceedings.
|
||||
if _, err := svc.AddProceeding(ctx, other, sc.ID, AddProceedingInput{
|
||||
ProceedingTypeID: ptID,
|
||||
}); !errors.Is(err, ErrScenarioBuilderNotVisible) {
|
||||
t.Errorf("AddProceeding by share recipient = %v, want ErrScenarioBuilderNotVisible", err)
|
||||
}
|
||||
|
||||
// 7b. Self-share should be rejected.
|
||||
if _, err := svc.AddShare(ctx, owner, sc.ID, owner); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("self-share = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// 8. Patch — archive then re-activate.
|
||||
patched, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
||||
Status: ptrString("archived"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PatchScenario archive: %v", err)
|
||||
}
|
||||
if patched.Status != "archived" {
|
||||
t.Errorf("after archive, status = %q, want archived", patched.Status)
|
||||
}
|
||||
// PATCH to 'promoted' is rejected — that's the wizard's job.
|
||||
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
||||
Status: ptrString("promoted"),
|
||||
}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("PATCH status=promoted = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
patched, err = svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
||||
Status: ptrString("active"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PatchScenario re-activate: %v", err)
|
||||
}
|
||||
if patched.Status != "active" {
|
||||
t.Errorf("after re-activate, status = %q, want active", patched.Status)
|
||||
}
|
||||
|
||||
// 9. Revoke the share. Recipient loses visibility.
|
||||
if err := svc.DeleteShare(ctx, owner, sc.ID, sh.ID); err != nil {
|
||||
t.Fatalf("DeleteShare: %v", err)
|
||||
}
|
||||
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
|
||||
t.Errorf("after revoke, recipient GetScenarioDeep = %v, want ErrScenarioBuilderNotVisible", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (Note: ptrString lives in rule_editor_service_test.go in this package
|
||||
// and is reused here. No second declaration needed.)
|
||||
@@ -205,7 +205,11 @@ func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T)
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
// IncludeOptional=true because translation_request carries
|
||||
// priority='optional'; the test exercises the before-child-of-
|
||||
// court-set-parent flow, which is orthogonal to the optional-rule
|
||||
// suppression added in t-paliad-342.
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
@@ -301,8 +305,10 @@ func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
// User pins the oral hearing to 2026-10-15.
|
||||
// User pins the oral hearing to 2026-10-15. IncludeOptional=true
|
||||
// because translation_request is priority='optional' (t-paliad-342).
|
||||
opts := CalcOptions{
|
||||
IncludeOptional: true,
|
||||
AnchorOverrides: map[string]string{
|
||||
oralCode: "2026-10-15",
|
||||
},
|
||||
|
||||
@@ -80,6 +80,21 @@ func Calculate(
|
||||
overrideDates[code] = od
|
||||
}
|
||||
|
||||
// Trigger-event anchors keyed by paliad.trigger_events.code
|
||||
// (t-paliad-342). Parsed up-front so malformed dates error before
|
||||
// the rule walk. When a rule has trigger_event_id set, the engine
|
||||
// looks up triggerAnchorByCode[trigger_event.code] for the
|
||||
// semantic anchor instead of falling back to the proceeding's
|
||||
// trigger date.
|
||||
triggerAnchorByCode := make(map[string]time.Time, len(opts.TriggerEventAnchors))
|
||||
for code, dateStr := range opts.TriggerEventAnchors {
|
||||
td, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger event anchor for %q (%q): %w", code, dateStr, err)
|
||||
}
|
||||
triggerAnchorByCode[code] = td
|
||||
}
|
||||
|
||||
// Look up proceeding type metadata.
|
||||
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
|
||||
if err != nil {
|
||||
@@ -213,6 +228,7 @@ func Calculate(
|
||||
perCardAppellant := opts.PerCardAppellant
|
||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
||||
hiddenCount := 0
|
||||
rulesAwaitingAnchor := 0
|
||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||
|
||||
for _, r := range walkRules {
|
||||
@@ -227,6 +243,17 @@ func Calculate(
|
||||
continue
|
||||
}
|
||||
|
||||
// Optional-rule suppression (t-paliad-342 / youpcorg#2570).
|
||||
// Rules tagged priority='optional' don't auto-fire in the
|
||||
// default timeline; the caller opts in via
|
||||
// CalcOptions.IncludeOptional. Cascade through skippedIDs so
|
||||
// children chaining off the suppressed rule also drop — they
|
||||
// can't compute a date against a missing parent.
|
||||
if r.Priority == "optional" && !opts.IncludeOptional {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
// SkipRules suppression (t-paliad-265).
|
||||
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
||||
// we re-surface the directly-skipped row (faded via IsHidden)
|
||||
@@ -327,15 +354,43 @@ func Calculate(
|
||||
// (m/paliad#126 / t-paliad-294). When a rule has a real
|
||||
// trigger_event_id, that catalog event is the actual semantic
|
||||
// anchor — not the parent_id node, which is only the calc-time
|
||||
// arithmetic anchor. Only the user-facing wire fields shift;
|
||||
// parentRule (and the parent_id chain feeding parentIsCourtSet
|
||||
// and the calc-time arithmetic below) stays anchored on the
|
||||
// rule tree.
|
||||
// arithmetic anchor. Only the user-facing wire fields shift
|
||||
// here; the calc-time anchor logic for trigger_event_id rules
|
||||
// lives just below.
|
||||
var triggerEventAnchor time.Time
|
||||
var hasTriggerEventAnchor bool
|
||||
if r.TriggerEventID != nil {
|
||||
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
|
||||
d.ParentRuleCode = te.Code
|
||||
d.ParentRuleName = te.NameDE
|
||||
d.ParentRuleNameEN = te.Name
|
||||
if td, ok := triggerAnchorByCode[te.Code]; ok {
|
||||
triggerEventAnchor = td
|
||||
hasTriggerEventAnchor = true
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger-event semantic-anchor suppression (t-paliad-342 /
|
||||
// youpcorg#2568). When a rule has an explicit trigger_event_id
|
||||
// but the caller hasn't supplied a date for that event via
|
||||
// CalcOptions.TriggerEventAnchors, the engine refuses to
|
||||
// fabricate a date off the proceeding's trigger date — the
|
||||
// rule's semantic anchor is the event itself, not the SoC.
|
||||
// Render IsConditional with empty dates and propagate via
|
||||
// courtSet so descendants chaining off this rule also surface
|
||||
// as conditional rather than projecting fictional dates.
|
||||
if !hasTriggerEventAnchor {
|
||||
d.IsConditional = true
|
||||
d.IsCourtSet = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
rulesAwaitingAnchor++
|
||||
if r.SubmissionCode != nil {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +434,20 @@ func Calculate(
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger-event anchor wins over the bucket logic below: a
|
||||
// zero-duration rule with trigger_event_id is "occurs on the
|
||||
// trigger event's date". Anchor missing was already caught
|
||||
// above (suppression branch).
|
||||
if hasTriggerEventAnchor {
|
||||
d.DueDate = triggerEventAnchor.Format("2006-01-02")
|
||||
d.OriginalDate = d.DueDate
|
||||
if r.SubmissionCode != nil {
|
||||
computed[*r.SubmissionCode] = triggerEventAnchor
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
|
||||
if r.ParentID == nil && !r.IsCourtSet {
|
||||
// Bucket 1: timeline anchor.
|
||||
d.IsRootEvent = true
|
||||
@@ -457,11 +526,19 @@ func Calculate(
|
||||
continue
|
||||
}
|
||||
|
||||
// Anchor: prefer alt-anchor (e.g. priority_date for
|
||||
// epa.grant.exa publish) when supplied, then parent's computed
|
||||
// date (or user override), then trigger date.
|
||||
// Anchor priority:
|
||||
// 1. trigger_event_id semantic anchor (t-paliad-342) — when
|
||||
// the rule has trigger_event_id and the caller supplied a
|
||||
// date in TriggerEventAnchors, that date wins over the
|
||||
// parent chain AND the priority_date alt-anchor. The
|
||||
// missing-anchor case was already short-circuited above.
|
||||
// 2. priority_date alt-anchor (epa.grant.exa publish).
|
||||
// 3. parent's computed date (or user override).
|
||||
// 4. proceeding trigger date (default fallback).
|
||||
baseDate := triggerDate
|
||||
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
||||
if hasTriggerEventAnchor {
|
||||
baseDate = triggerEventAnchor
|
||||
} else if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
||||
baseDate = *priorityDate
|
||||
} else if r.ParentID != nil {
|
||||
for _, prev := range rules {
|
||||
@@ -635,12 +712,13 @@ func Calculate(
|
||||
}
|
||||
|
||||
resp := &Timeline{
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
RulesAwaitingAnchor: rulesAwaitingAnchor,
|
||||
}
|
||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||
// so the trigger-event label rides on `pickedProceeding`.
|
||||
|
||||
379
pkg/litigationplanner/optional_and_trigger_anchor_test.go
Normal file
379
pkg/litigationplanner/optional_and_trigger_anchor_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Tests for t-paliad-342 / youpcorg#2568 + #2570.
|
||||
//
|
||||
// Two paired engine semantics:
|
||||
//
|
||||
// - Optional rules (priority='optional') don't auto-fire in the
|
||||
// default timeline; the caller opts in via
|
||||
// CalcOptions.IncludeOptional.
|
||||
// - Rules with explicit trigger_event_id anchor on the trigger
|
||||
// event's date (CalcOptions.TriggerEventAnchors keyed by
|
||||
// trigger_events.code). Missing anchor = render conditional
|
||||
// instead of fabricating a date off the proceeding's trigger date.
|
||||
|
||||
// stubCatalogWithTriggers extends stubCatalog with a trigger-events
|
||||
// map so the engine can resolve TriggerEventID → code for the
|
||||
// trigger-anchor branch. stubCatalog from before_court_set_anchor_test.go
|
||||
// returns an empty map, which suffices for tests that don't exercise
|
||||
// trigger_event_id; here we need real entries.
|
||||
type stubCatalogWithTriggers struct {
|
||||
stubCatalog
|
||||
triggerEvents map[int64]TriggerEvent
|
||||
}
|
||||
|
||||
func (s *stubCatalogWithTriggers) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]TriggerEvent, error) {
|
||||
out := make(map[int64]TriggerEvent, len(ids))
|
||||
for _, id := range ids {
|
||||
if te, ok := s.triggerEvents[id]; ok {
|
||||
out[id] = te
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mandatory_socRule builds a minimal SoC root rule + the proceeding
|
||||
// type wrapper that nearly every test below needs.
|
||||
func mandatorySocFixture(t *testing.T) (ProceedingType, Rule, uuid.UUID) {
|
||||
t.Helper()
|
||||
jurisdiction := "UPC"
|
||||
procID := 1
|
||||
pt := ProceedingType{
|
||||
ID: procID,
|
||||
Code: "upc.inf.cfi",
|
||||
Name: "Verletzungsverfahren",
|
||||
Jurisdiction: &jurisdiction,
|
||||
IsActive: true,
|
||||
}
|
||||
socID, _ := uuid.NewRandom()
|
||||
socCode := "upc.inf.cfi.soc"
|
||||
procIDPtr := &procID
|
||||
str := func(s string) *string { return &s }
|
||||
soc := Rule{
|
||||
ID: socID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &socCode,
|
||||
Name: "Klageerhebung",
|
||||
NameEN: "SoC",
|
||||
PrimaryParty: str("claimant"),
|
||||
DurationValue: 0,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 0,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
}
|
||||
return pt, soc, socID
|
||||
}
|
||||
|
||||
// TestCalculate_TriggerEventIDWithoutAnchor_Suppressed verifies the
|
||||
// RoP.109.5 bug from youpcorg#2568: a rule with trigger_event_id and
|
||||
// no parent_id must NOT fall back to the proceeding's trigger date.
|
||||
// The buggy behaviour rendered the rule with a fabricated date 2 weeks
|
||||
// before the user's SoC date.
|
||||
func TestCalculate_TriggerEventIDWithoutAnchor_Suppressed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, _ := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
ruleID, _ := uuid.NewRandom()
|
||||
ruleCode := "upc.inf.cfi.rop_109_5"
|
||||
rop109_5Trigger := int64(49)
|
||||
rop109_5 := Rule{
|
||||
ID: ruleID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &ruleCode,
|
||||
Name: "Vorbereitung mündliche Verhandlung",
|
||||
NameEN: "Oral hearing preparation",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: str("before"),
|
||||
SequenceOrder: 100,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
TriggerEventID: &rop109_5Trigger,
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
|
||||
triggerEvents: map[int64]TriggerEvent{
|
||||
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
|
||||
},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
rop, ok := byCode[ruleCode]
|
||||
if !ok {
|
||||
t.Fatalf("RoP.109.5 missing from timeline; want present with conditional-no-date")
|
||||
}
|
||||
if rop.DueDate != "" {
|
||||
t.Errorf("RoP.109.5: DueDate=%q, want empty (no oral_hearing anchor supplied)", rop.DueDate)
|
||||
}
|
||||
if !rop.IsConditional {
|
||||
t.Errorf("RoP.109.5: IsConditional=%v, want true", rop.IsConditional)
|
||||
}
|
||||
if timeline.RulesAwaitingAnchor != 1 {
|
||||
t.Errorf("RulesAwaitingAnchor=%d, want 1", timeline.RulesAwaitingAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_TriggerEventIDWithAnchor_Renders verifies the
|
||||
// caller-supplied trigger-event anchor produces correct arithmetic.
|
||||
// 2 weeks before 2026-10-15 = 2026-10-01.
|
||||
func TestCalculate_TriggerEventIDWithAnchor_Renders(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, _ := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
ruleID, _ := uuid.NewRandom()
|
||||
ruleCode := "upc.inf.cfi.rop_109_5"
|
||||
rop109_5Trigger := int64(49)
|
||||
rop109_5 := Rule{
|
||||
ID: ruleID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &ruleCode,
|
||||
Name: "Vorbereitung mündliche Verhandlung",
|
||||
NameEN: "Oral hearing preparation",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: str("before"),
|
||||
SequenceOrder: 100,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
TriggerEventID: &rop109_5Trigger,
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
|
||||
triggerEvents: map[int64]TriggerEvent{
|
||||
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
|
||||
},
|
||||
}
|
||||
|
||||
opts := CalcOptions{
|
||||
TriggerEventAnchors: map[string]string{
|
||||
"oral_hearing": "2026-10-15",
|
||||
},
|
||||
}
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
rop := byCode[ruleCode]
|
||||
if rop.DueDate != "2026-10-01" {
|
||||
t.Errorf("RoP.109.5: DueDate=%q, want 2026-10-01 (2 weeks before oral_hearing 2026-10-15)", rop.DueDate)
|
||||
}
|
||||
if rop.IsConditional {
|
||||
t.Errorf("RoP.109.5: IsConditional=%v, want false (anchor supplied)", rop.IsConditional)
|
||||
}
|
||||
if timeline.RulesAwaitingAnchor != 0 {
|
||||
t.Errorf("RulesAwaitingAnchor=%d, want 0", timeline.RulesAwaitingAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_MandatoryRule_RendersByDefault is the control case for
|
||||
// the optional-suppression fix: mandatory rules render with their
|
||||
// computed dates by default. Prevents regression where the optional
|
||||
// filter accidentally drops mandatory rules too.
|
||||
func TestCalculate_MandatoryRule_RendersByDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, socID := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
replyID, _ := uuid.NewRandom()
|
||||
replyCode := "upc.inf.cfi.reply"
|
||||
reply := Rule{
|
||||
ID: replyID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &socID,
|
||||
SubmissionCode: &replyCode,
|
||||
Name: "Klageerwiderung",
|
||||
NameEN: "Reply to SoC",
|
||||
PrimaryParty: str("defendant"),
|
||||
DurationValue: 3,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 10,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, reply}},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
got, ok := byCode[replyCode]
|
||||
if !ok {
|
||||
t.Fatalf("mandatory reply rule missing from default timeline")
|
||||
}
|
||||
if got.DueDate != "2026-08-26" {
|
||||
t.Errorf("reply: DueDate=%q, want 2026-08-26 (3 months after SoC)", got.DueDate)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_OptionalRule_SuppressedByDefault pins the
|
||||
// youpcorg#2570 fix: priority='optional' rules don't render in the
|
||||
// default timeline. The caller opts in via IncludeOptional=true.
|
||||
func TestCalculate_OptionalRule_SuppressedByDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, socID := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
confID, _ := uuid.NewRandom()
|
||||
confCode := "upc.inf.cfi.rop_262_2"
|
||||
conf := Rule{
|
||||
ID: confID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &socID,
|
||||
SubmissionCode: &confCode,
|
||||
Name: "Erwiderung Vertraulichkeitsantrag",
|
||||
NameEN: "Reply to confidentiality motion",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 20,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "optional",
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
for _, d := range timeline.Deadlines {
|
||||
if d.Code == confCode {
|
||||
t.Errorf("optional R.262(2) rule rendered in default timeline (DueDate=%q); want suppressed", d.DueDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_OptionalRule_RendersWithIncludeOptional verifies the
|
||||
// opt-in path: when the caller passes IncludeOptional=true, optional
|
||||
// rules show up in the timeline with their computed dates.
|
||||
func TestCalculate_OptionalRule_RendersWithIncludeOptional(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, socID := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
confID, _ := uuid.NewRandom()
|
||||
confCode := "upc.inf.cfi.rop_262_2"
|
||||
conf := Rule{
|
||||
ID: confID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &socID,
|
||||
SubmissionCode: &confCode,
|
||||
Name: "Erwiderung Vertraulichkeitsantrag",
|
||||
NameEN: "Reply to confidentiality motion",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 20,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "optional",
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
got, ok := byCode[confCode]
|
||||
if !ok {
|
||||
t.Fatalf("optional R.262(2) missing from timeline despite IncludeOptional=true")
|
||||
}
|
||||
// R.262(2) is the "optional opposing-side" pattern (priority=optional,
|
||||
// primary_party=both, parent=SoC root) — the engine renders this as
|
||||
// IsConditional (no concrete date) per the t-paliad-289 logic
|
||||
// preserved in the walk. The point of this test is that the rule
|
||||
// is no longer suppressed wholesale by the t-paliad-342 default —
|
||||
// it surfaces, just with the conditional-render UX.
|
||||
if !got.IsConditional {
|
||||
t.Errorf("R.262(2): IsConditional=%v, want true (optional opposing-side, no real trigger recorded)", got.IsConditional)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_TriggerEventID_MalformedAnchor_Errors ensures
|
||||
// malformed dates in TriggerEventAnchors fail fast at the top of the
|
||||
// engine, before any rule walking — same protocol as AnchorOverrides.
|
||||
func TestCalculate_TriggerEventID_MalformedAnchor_Errors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, _ := mandatorySocFixture(t)
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc}},
|
||||
}
|
||||
|
||||
opts := CalcOptions{
|
||||
TriggerEventAnchors: map[string]string{
|
||||
"oral_hearing": "15-10-2026", // wrong format
|
||||
},
|
||||
}
|
||||
_, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err == nil {
|
||||
t.Fatalf("Calculate: want error on malformed TriggerEventAnchors date, got nil")
|
||||
}
|
||||
}
|
||||
@@ -334,6 +334,25 @@ type CalcOptions struct {
|
||||
// filter applied) so a stale frontend chip doesn't break the
|
||||
// timeline render — see IsValidAppealTarget.
|
||||
AppealTarget string
|
||||
|
||||
// IncludeOptional surfaces rules with priority='optional' in the
|
||||
// default timeline (t-paliad-342 / youpcorg#2570). Default false:
|
||||
// optional rules don't auto-fire alongside mandatory ones. The
|
||||
// caller (paliad /tools/procedures, youpc.org/deadlines) wires this
|
||||
// to a user-facing "show optional applications" toggle.
|
||||
IncludeOptional bool
|
||||
|
||||
// TriggerEventAnchors supplies concrete dates for procedural events
|
||||
// referenced by rules' trigger_event_id (t-paliad-342 / youpcorg#2568).
|
||||
// Key = paliad.trigger_events.code (e.g. "oral_hearing"), value =
|
||||
// YYYY-MM-DD. When a rule carries an explicit trigger_event_id, that
|
||||
// catalog event is the authoritative semantic anchor: arithmetic
|
||||
// resolves against TriggerEventAnchors[code] if set, otherwise the
|
||||
// rule is suppressed as IsConditional (no fabricated date off the
|
||||
// user's trigger date). Empty map = engine never anchors on a
|
||||
// trigger event, so every rule with trigger_event_id surfaces as
|
||||
// conditional.
|
||||
TriggerEventAnchors map[string]string
|
||||
}
|
||||
|
||||
// ProjectHint scopes a Catalog call to a specific project. Paliad's
|
||||
@@ -375,6 +394,13 @@ type Timeline struct {
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
HiddenCount int `json:"hiddenCount"`
|
||||
// RulesAwaitingAnchor counts rules suppressed because their
|
||||
// trigger_event_id anchor date wasn't supplied via
|
||||
// CalcOptions.TriggerEventAnchors (t-paliad-342). Such rules still
|
||||
// render in the timeline as IsConditional (no date) — the field
|
||||
// gives the caller a single integer for "N rules waiting on an
|
||||
// anchor" UI affordances + telemetry.
|
||||
RulesAwaitingAnchor int `json:"rulesAwaitingAnchor,omitempty"`
|
||||
}
|
||||
|
||||
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
|
||||
|
||||
Reference in New Issue
Block a user