Compare commits
108 Commits
mai/knuth/
...
mai/farada
| Author | SHA1 | Date | |
|---|---|---|---|
| d834b36313 | |||
| 4092c889c4 | |||
| db1040968f | |||
| f292338919 | |||
| 2b240e7dd0 | |||
| c945cbd330 | |||
| 639ff4f672 | |||
| 264cc39a6b | |||
| 28d860a07d | |||
| d913f4fc30 | |||
| e091716f48 | |||
| 8763ab013c | |||
| e1e8db7fc9 | |||
| b746ec36c7 | |||
| 28aaafeb05 | |||
| f9331e9bb9 | |||
| e53bcf8cc2 | |||
| 68fcbc6fbf | |||
| 31e15d4b20 | |||
| a111a82640 | |||
| 63a9bedf7e | |||
| b8709b903d | |||
| 938222d602 | |||
| 47deeaf5ed | |||
| a2da501917 | |||
| 8ea78fd376 | |||
| e189d3fe6a | |||
| 58907554fc | |||
| 9b8a865c5f | |||
| f8067c2fe5 | |||
| 78a30a7ee0 | |||
| 091804923a | |||
| 9201501941 | |||
| 05247d7bd7 | |||
| a81581878e | |||
| 8d8a882f46 | |||
| 9679a98666 | |||
| fcdfba209d | |||
| 3e93e94d10 | |||
| 28ea103260 | |||
| 1c77cb6e67 | |||
| 1f6e586c63 | |||
| a4b865d6bd | |||
| a905911cf4 | |||
| 88c03e922f | |||
| 6bcac2dd20 | |||
| 46dc4ec94b | |||
| 6c1d8cc0cf | |||
| 0c857026a2 | |||
| 3c840c0366 | |||
| 1b4b2e4758 | |||
| b78a984a7c | |||
| 1844df3ae6 | |||
| 0f3c30a647 | |||
| 2c2b93bc7c | |||
| 661d87273c | |||
| ed3c5d1f32 | |||
| be570c2fd0 | |||
| 58692513a8 | |||
| 702f786771 | |||
| 93c664c865 | |||
| 6506d7d862 | |||
| 73f49c46ed | |||
| c80723fc85 | |||
| 1ed75c56e3 | |||
| 2e6427dca6 | |||
| 7945bfb364 | |||
| bfb38aab41 | |||
| 9fe06094a8 | |||
| c8f310c62c | |||
| 7554e86673 | |||
| 23b151c0f3 | |||
| 1718ea2eae | |||
| 39c8ef343b | |||
| 48a07ef4ef | |||
| bb3d7aabd7 | |||
| c8390dd02a | |||
| c8261da492 | |||
| 0568d340a7 | |||
| 60907e7153 | |||
| 66b08813c4 | |||
| 0aaa523494 | |||
| d49ff55c41 | |||
| ae1c0b861d | |||
| c8999e2a8b | |||
| 0365e84dd1 | |||
| d6a5dedb2b | |||
| 9940dd8216 | |||
| f6add95d0a | |||
| 480332a5f5 | |||
| 97d90ce651 | |||
| 3a4e99cb92 | |||
| 3533d79a25 | |||
| 2a69f7fc6c | |||
| 39353d49ed | |||
| d36cc9ee15 | |||
| a9fd979cdb | |||
| c48fa93e3d | |||
| 5f7a66bbec | |||
| 490c8a8c8c | |||
| b1c9e8dd97 | |||
| 9aee9e4101 | |||
| 810b65463e | |||
| 33c5fb2983 | |||
| 76d38c4c84 | |||
| 233547297c | |||
| 8dfdd77079 | |||
| 7584b4f428 |
@@ -174,6 +174,9 @@ func main() {
|
||||
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
|
||||
// t-paliad-315 Slice C — building-block library.
|
||||
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store
|
||||
// (Postgres bytea) backing the authoring surface.
|
||||
templateStoreSvc := services.NewPgTemplateStore(pool)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
@@ -190,6 +193,7 @@ func main() {
|
||||
SubmissionSection: submissionSectionSvc,
|
||||
SubmissionComposer: submissionComposerSvc,
|
||||
SubmissionBuildingBlock: submissionBuildingBlockSvc,
|
||||
TemplateStore: templateStoreSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -242,6 +246,17 @@ func main() {
|
||||
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
|
||||
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions.
|
||||
Scenario: services.NewScenarioService(pool, projectSvc, rules),
|
||||
// m/paliad#149 Phase 2 P0 (mig 154) — per-project scenario_flags
|
||||
// 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. B4 adds the
|
||||
// Akte-mode dual-write: project-backed scenarios write through
|
||||
// to paliad.projects.scenario_flags + paliad.deadlines via the
|
||||
// injected project + scenarioFlags services.
|
||||
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc), services.NewFristenrechnerService(rules, holidays, courts)),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
|
||||
738
docs/assessment-deadline-system-2026-05-27.md
Normal file
738
docs/assessment-deadline-system-2026-05-27.md
Normal file
@@ -0,0 +1,738 @@
|
||||
# Assessment — Deadline + Procedural-Events System
|
||||
|
||||
**Phase 1 of RFC m/paliad#149.** Read-only audit of every consumer of
|
||||
`paliad.sequencing_rules` + `paliad.procedural_events` + the legacy
|
||||
`paliad.trigger_events`, the corpus they project, and the surfaces that
|
||||
read them.
|
||||
|
||||
- Author: athena (consultant role)
|
||||
- Date: 2026-05-27
|
||||
- Live data: youpc Supabase (`paliad` schema), counts captured during the
|
||||
audit window (mig 153 applied).
|
||||
- Scope: assessment only. No design proposals; no schema sketches; no
|
||||
recommendations on column shape. Phase 2 (inventor) decides those.
|
||||
|
||||
---
|
||||
|
||||
## 0. Headline numbers
|
||||
|
||||
| Bucket | Total | Active + published | Notes |
|
||||
|---|--:|--:|---|
|
||||
| `procedural_events` | 236 | 222 | 5 drafts, 9 archived/inactive |
|
||||
| `sequencing_rules` | 236 | 226 | 1:1 row-mirror with events (mig 136 + 140) |
|
||||
| `trigger_events` (legacy) | 110 | — | bigint-keyed catalog; lives parallel to events |
|
||||
| `proceeding_types` | 50 | 23 kind=`proceeding`; 0 active in kind=`phase`/`side_action`/`meta` (mig 153 flipped them off) |
|
||||
|
||||
Rules-corpus shape (active + published, 226 rows):
|
||||
|
||||
| Classification | Rows |
|
||||
|---|--:|
|
||||
| Parent only (chain-linked) | 105 |
|
||||
| Both parent + legacy trigger | 2 |
|
||||
| Legacy `trigger_event_id` only — `proceeding_type_id IS NULL` | **73** |
|
||||
| Neither (root) — `proceeding_type_id` set | 46 |
|
||||
|
||||
Other corpus signals:
|
||||
|
||||
- `condition_expr` populated: 18 rules. Three distinct keys: `flag` (14),
|
||||
`op` + `args` (4 each — always nested AND).
|
||||
- `is_spawn = true`: 4 rules. All four point at the **inactive**
|
||||
`upc.apl.merits` (id=11). The active appeal type is id=160
|
||||
(`upc.apl.unified`). See risk R3.
|
||||
- `is_court_set = true`: 46 rules.
|
||||
- `is_bilateral = true`: 4 rules.
|
||||
- `choices_offered` populated: 28 rules. Three shapes:
|
||||
`{appellant:[…]}` (20), `{skip:[…]}` (6), `{include_ccr:[…]}` (2).
|
||||
- `applies_to_target` populated: 16 rules.
|
||||
- 67 distinct events act as chain-anchors (= parent of ≥1 active rule).
|
||||
That is the *derived* trigger set today.
|
||||
- `paliad.project_event_choices`: schema present, **0 rows** live.
|
||||
- `paliad.scenarios` (mig 145): table created, **0 rows**.
|
||||
`paliad.projects.active_scenario_id`: **0/18 projects** populated.
|
||||
|
||||
A more granular per-proceeding-type breakdown is in §4.
|
||||
|
||||
---
|
||||
|
||||
## 1. Audit — consumers of `sequencing_rules` + `procedural_events`
|
||||
|
||||
Every read site, by surface. File paths are repo-relative.
|
||||
|
||||
### 1.1 Direct services
|
||||
|
||||
| Service | File | What it reads | Surface(s) it backs |
|
||||
|---|---|---|---|
|
||||
| `DeadlineRuleService` | `internal/services/deadline_rule_service.go:14-365` | `paliad.deadline_rules_unified` view (sequencing_rules + procedural_events + legal_sources), + `paliad.trigger_events` for parent-chain labels (`:226-285`) | Admin rules list/editor, Fristenrechner result panel |
|
||||
| `FristenrechnerService` | `internal/services/fristenrechner.go:115-172,1-700+` | `sequencing_rules` + `procedural_events` (proceeding-type catalog; `EXISTS` over rules); scenarios table (`:583-627`) | `/api/tools/fristenrechner` (Mode A + Mode B + Mode C) |
|
||||
| `FristenrechnerService.LookupFollowUps` | `internal/services/fristenrechner_followups.go:87-403` | resolves anchor by `pe.id`/`pe.code`/`sr.id` (`:241-287`); one-hop children via `parent_id` (`:345-403`) | `/api/tools/fristenrechner/follow-ups` |
|
||||
| `DeadlineSearchService` | `internal/services/fristenrechner_search_events.go:143-170,194,233,696` | sequencing_rules ⋈ procedural_events ⋈ proceeding_types + legal_sources; counts child rules via `parent_id` subquery | `/api/tools/fristenrechner/search` |
|
||||
| `EventDeadlineService` | `internal/services/event_deadline_service.go:31-79,186-195,244` | `paliad.trigger_events` + `sequencing_rules WHERE trigger_event_id IS NOT NULL` | `/api/tools/event-deadlines` (legacy bigint surface) |
|
||||
| `EventTriggerService` | `internal/services/event_trigger_service.go:24-230` | `event_types.trigger_event_id` bridge + sequencing_rules | `/api/tools/event-trigger` |
|
||||
| `RuleEditorService` | `internal/services/rule_editor_service.go:104,136,232,371,381,459,625-843` | full CRUD on sequencing_rules + procedural_events; reads `trigger_event_id` as an optional filter on list | `/admin/api/procedural-events/*` (Slice B.5) |
|
||||
| `RuleEditorOrphans` | `internal/services/rule_editor_orphans.go:218-224` | sub-select on sequencing_rules for orphaned deadlines | `/admin/api/orphans` |
|
||||
| `DualWriteService` | `internal/services/dual_write.go` (+ `dual_write_test.go:50-300`) | parity assertion between legacy + unified projection | internal — write-side guard, no HTTP |
|
||||
| `ProjectionService` (SmartTimeline) | `internal/services/projection_service.go:3+` | composes the timeline by reading via `DeadlineRuleService` + `FristenrechnerService`; does NOT touch `sequencing_rules` directly | `GET /api/projects/{id}/timeline`, milestone + counterclaim endpoints in `internal/handlers/projection.go:35-436+` |
|
||||
| `ExportService` | `internal/services/export_service.go:1680` | bulk-exports `paliad.trigger_events` as the `ref__trigger_events` workbook sheet | `/api/admin/export/*` |
|
||||
| `EventChoiceService` | `internal/services/event_choice_service.go:15-180` | reads + writes `paliad.project_event_choices` | per-project flag persistence (no rows live today) |
|
||||
| `EventTypeService` | `internal/services/event_type_service.go:40-414` | user-defined `paliad.event_types` rows with optional `trigger_event_id` bridge | `/api/event-types` + Pipeline C compose |
|
||||
| `ProjectService.validateProceedingTypeCategory` | `internal/services/project_service.go:1176-1267` | reads `paliad.proceeding_types.category` + `kind` + `is_active` | binding guard for `projects.proceeding_type_id` (sister to mig-153 trigger) |
|
||||
|
||||
The handlers behind each route are listed in §1.2.
|
||||
|
||||
### 1.2 HTTP routes
|
||||
|
||||
Every route that ultimately surfaces sequencing/event data. Path
|
||||
literals + handler file:line cited.
|
||||
|
||||
**Knowledge-tool surface (public-ish, behind auth):**
|
||||
|
||||
| Route | Handler | Reads |
|
||||
|---|---|---|
|
||||
| `POST /api/tools/fristenrechner` | `internal/handlers/fristenrechner.go:39-95+` | `FristenrechnerService.CalculateForProceeding` → engine in `pkg/litigationplanner` |
|
||||
| `GET /api/tools/fristenrechner/search` | `internal/handlers/fristenrechner_search.go` (filter params: `event_kind`, `primary_party`, `jurisdiction`) | `DeadlineSearchService.SearchEvents` |
|
||||
| `GET /api/tools/fristenrechner/follow-ups` | `internal/handlers/fristenrechner_followups.go:27-65` | `FristenrechnerService.LookupFollowUps` |
|
||||
| `GET /api/tools/proceeding-types` | `internal/handlers/event_types.go` | proceeding_types filter (event_kind, jurisdiction) |
|
||||
| `GET /api/tools/trigger-events` | `internal/handlers/event_types.go` | trigger_events catalog (active only) |
|
||||
| `POST /api/tools/event-trigger` | `internal/handlers/event_trigger.go:39-106` | unified Pipeline-A + Pipeline-C compose |
|
||||
| `POST /api/tools/event-deadlines` | `internal/handlers/deadline_rules_db.go:67+` | **legacy** bigint trigger_event_id → rule list |
|
||||
|
||||
**SmartTimeline surface (project-bound):**
|
||||
|
||||
| Route | Handler | Reads |
|
||||
|---|---|---|
|
||||
| `GET /api/projects/{id}/timeline` | `internal/handlers/projection.go:35-109` | `ProjectionService.Render` (no direct rule reads — composes via services) |
|
||||
| `POST /api/projects/{id}/timeline/milestone` | `internal/handlers/projection.go:445+` | milestone insert; reads `proceeding_type.kind` via service |
|
||||
| `POST /api/projects/{id}/timeline/counterclaim` | `internal/handlers/projection.go:387-436` | spawns CCR project; reads `parent_id` on response composition |
|
||||
|
||||
**Admin editor surface (`/admin/procedural-events/*`):**
|
||||
|
||||
| Route | Handler | Reads |
|
||||
|---|---|---|
|
||||
| `GET /admin/procedural-events` | `internal/handlers/admin_rules.go:399-402` | page shell |
|
||||
| `GET /admin/procedural-events/{id}/edit` | `:403-470` | editor form (full rule + event JSON) |
|
||||
| `GET /admin/api/procedural-events` | `:101-160` | paginated list w/ canonical `code` + `event_kind` (Slice B.5 wrapper) |
|
||||
| `GET /admin/api/procedural-events/{id}` | `:161-179` | single rule fetch |
|
||||
| `POST /admin/api/procedural-events` | `:180-204` | create draft |
|
||||
| `PATCH /admin/api/procedural-events/{id}` | `:205-233` | edit draft |
|
||||
| `POST /admin/api/procedural-events/{id}/publish` | `:257-279` | publish flow |
|
||||
| `GET /admin/api/procedural-events/{id}/audit` | `:326-361` | audit log |
|
||||
| `GET /admin/api/orphans` | `:471-484` | orphaned deadlines (Slice 10 backfill UI) |
|
||||
| `POST /admin/api/orphans/{id}/resolve` | `:485-520` | link orphan to rule |
|
||||
| `/admin/rules/*` → `/admin/procedural-events/*` | `:761-772` | **301 redirects** (legacy bookmarks; one-slice deprecation window) |
|
||||
| `?trigger_event_id=…` query param | `:119-122` | exposes legacy trigger filter on the admin list |
|
||||
|
||||
**Scenarios surface (mig 145):**
|
||||
|
||||
| Route | Handler |
|
||||
|---|---|
|
||||
| `GET /api/scenarios?project=<id>|abstract=true` | `internal/handlers/scenarios.go:51-90` |
|
||||
| `GET /api/scenarios/{id}` | `:92-113` |
|
||||
| `POST /api/scenarios` | `:115-136` |
|
||||
| `PATCH /api/scenarios/{id}` | `:138-164` |
|
||||
| `DELETE /api/scenarios/{id}` | `:166-200+` |
|
||||
| `POST /api/paliadin/suggest/deadline` | `internal/handlers/paliadin_suggest.go:63+` (deadline drafts via Paliadin; does not read rules directly — calls into `DeadlineService`) |
|
||||
|
||||
Registration: `internal/handlers/handlers.go:497-501, 880`.
|
||||
|
||||
### 1.3 Frontend (TypeScript) consumers
|
||||
|
||||
These call the routes above; **no direct DB access**. References per the
|
||||
i18n key search and `frontend/src/client/*` greps:
|
||||
|
||||
- `frontend/src/admin-rules-list.tsx:24-105+` — admin list page shell;
|
||||
hits `/admin/api/procedural-events*`.
|
||||
- `frontend/src/admin-rules-edit.tsx:29-187+` — admin editor form; reads
|
||||
`procedural_events.edit.field.{code,event_kind,parent}` i18n keys.
|
||||
- `frontend/src/verfahrensablauf.tsx` — proceeding-type ablauf page
|
||||
(mode C); hits `/api/tools/fristenrechner` with proceeding shape.
|
||||
- `frontend/src/client/fristenrechner-wizard.ts:80` — Mode A wizard;
|
||||
`r4: string // procedural_events.code`.
|
||||
- `frontend/src/client/fristenrechner-mode-a.ts` — Mode A search; hits
|
||||
`/api/tools/fristenrechner/search?kind=events`.
|
||||
- `frontend/src/client/fristenrechner-result.ts` — result panel; hits
|
||||
`/api/tools/fristenrechner/follow-ups`.
|
||||
- `frontend/src/client/projects-new.ts` — type-aware project wizard;
|
||||
hits `/api/tools/fristenrechner?proceeding_type_code=…`.
|
||||
- `frontend/src/client/deadlines-detail.ts` — deadline CRUD detail.
|
||||
- i18n keys: `admin.procedural_events.list/edit/col.*` and translations
|
||||
in `frontend/src/client/i18n.ts:3193-3204, 6338-6346+`.
|
||||
|
||||
### 1.4 Offline snapshot
|
||||
|
||||
- `cmd/gen-upc-snapshot/main.go:150-268` — reads `paliad.trigger_events`,
|
||||
the legacy `paliad.deadline_rules` projection (now via the unified
|
||||
view), and `paliad.proceeding_types`. Writes JSON to
|
||||
`pkg/litigationplanner/embedded/upc/{trigger_events.json,
|
||||
rules.json, proceeding_types.json, meta.json}`.
|
||||
- `pkg/litigationplanner/catalog.go` + `engine.go` + `types.go:73-156` —
|
||||
Rule struct carries `TriggerEventID`, `SpawnProceedingTypeID`,
|
||||
`ConditionExpr`, `Priority`, `IsCourtSet`, `PrimaryParty`, `IsSpawn`,
|
||||
`SpawnLabel`, `CombineOp`. youpc.org consumes this snapshot.
|
||||
|
||||
### 1.5 Migrations touching the tables (chronological)
|
||||
|
||||
`internal/db/migrations/`:
|
||||
|
||||
`028_youpc_deadlines_import`, `030_event_types`, `033_trigger_events_de`,
|
||||
`035_event_deadlines_title_de_backfill`, `038_concept_links_and_legal_source`,
|
||||
`046_cross_cutting_triggers`, `047_deadline_search_view`,
|
||||
`051_proceeding_display_order`, `063_frist_verpasst_upc`,
|
||||
`078_unified_rule_columns`, `091_drop_legacy_rule_columns`,
|
||||
`098_submission_codes_prefix_and_rename`, `125_cross_cutting_filter_legal_source`,
|
||||
`132_wave1_tier1_rule_additions`, **`136_procedural_events_additive`**
|
||||
(the schema-authoritative additive split), `139_deadline_rules_unified_view`,
|
||||
**`140_drop_deadline_rules`** (legacy projection dropped),
|
||||
`151_dedupe_null_procedural_events`, `152_dedupe_identical_sequencing_rule_clones`,
|
||||
**`153_proceeding_types_kind`** (kind discriminator + projects FK trigger).
|
||||
|
||||
Mig 145 is scenario-side: creates `paliad.scenarios` (table, **not**
|
||||
a `scenarios` jsonb column on `projects` — the RFC text was imprecise)
|
||||
and `paliad.projects.active_scenario_id` FK.
|
||||
|
||||
---
|
||||
|
||||
## 2. Health-check per consumer
|
||||
|
||||
### 2.1 Works — green
|
||||
|
||||
- **`DualWriteService` parity.** Every CRUD on the editor surface
|
||||
keeps sequencing_rules + procedural_events + legal_sources locked,
|
||||
asserted by `dual_write_test.go:50-202`.
|
||||
- **Admin editor (`/admin/procedural-events/*`).** Full create / edit /
|
||||
publish / audit loop. Drafts state respected.
|
||||
- **Mode A picker via search.** `DeadlineSearchService` filters by
|
||||
`event_kind` / `primary_party` / `jurisdiction`; returns child-rule
|
||||
counts (`fristenrechner_search_events.go:159`).
|
||||
- **Mode B Verfahrensablauf calc.** `pkg/litigationplanner.CalculateRule`
|
||||
+ the `proceeding_type` fan-out works for every type that has any
|
||||
rule (17/23).
|
||||
- **`gen-upc-snapshot`.** UPC snapshot for youpc.org keeps shipping;
|
||||
no DB writes; reads only.
|
||||
- **Counterclaim spawn project creation.**
|
||||
`internal/handlers/projection.go:387-436` + mig 153 trigger guard
|
||||
reject any non-`proceeding` `proceeding_type_id`.
|
||||
- **EventChoiceService** SQL is wired and tested — but see §2.3.
|
||||
|
||||
### 2.2 Works with known caveats — yellow
|
||||
|
||||
- **Spawn rules.** Behaviour is correct in the abstract (rule fires,
|
||||
user can spawn a child case), but every spawn target points at the
|
||||
**inactive** `upc.apl.merits` (id=11). Surfaces that resolve the
|
||||
spawn target via `paliad.proceeding_types` will return an inactive
|
||||
row. See R3. Cited at `sequencing_rules` 4 rows; service code in
|
||||
`fristenrechner_followups.go:388` SELECTs `spt.code` via
|
||||
`LEFT JOIN paliad.proceeding_types spt ON spt.id = sr.spawn_proceeding_type_id`
|
||||
— no `is_active` filter on the join. Frontend renders an "open
|
||||
Berufungsverfahren" CTA that points at a UI flow expecting the
|
||||
active id=160 (`upc.apl.unified`).
|
||||
- **Legacy 73 globals.** 73 rules with `proceeding_type_id IS NULL`
|
||||
and `trigger_event_id NOT NULL`. These all anchor on legacy
|
||||
`null.<8hex>` event codes that don't match any `proceeding_types.code`
|
||||
prefix. They are consumed via `/api/tools/event-deadlines` (the
|
||||
bigint route) AND surface on the unified view. They have no place
|
||||
in the Mode B "proceeding-type ablauf" view because they have no
|
||||
proceeding. See R4.
|
||||
- **Legacy `/api/tools/event-deadlines` route.** Live, used by
|
||||
Pipeline-C `event_types` consumers (`EventTypeService`). The
|
||||
`ExportService:1680` also still emits `ref__trigger_events` to the
|
||||
workbook. Deprecation has been deferred — see R5.
|
||||
|
||||
### 2.3 Broken / leaky — red
|
||||
|
||||
- **B1 — Follow-up cross-party filter is over-broad.**
|
||||
`fristenrechner_followups.go:358-367`:
|
||||
|
||||
```go
|
||||
if party == "claimant" || party == "defendant" {
|
||||
args = append(args, party)
|
||||
where = append(where, fmt.Sprintf(
|
||||
"(sr.primary_party = $%d OR sr.primary_party = 'both' OR sr.primary_party IS NULL)",
|
||||
len(args)))
|
||||
}
|
||||
```
|
||||
|
||||
The filter keeps `both` + `NULL` rules but **drops cross-party
|
||||
follow-ups**. From the corpus there are 39 active rules whose
|
||||
`primary_party` differs from their parent's primary_party (excluding
|
||||
`court`). Example: `upc.inf.cfi.def_to_ccr` is claimant-filed; its
|
||||
child rule `RoP.029.d → reply_def_ccr` is defendant-filed. With
|
||||
`party=claimant` selected on the result view, the defendant child
|
||||
is hidden and the user reads "Keine Folge-Fristen" — a lie. This
|
||||
is the exact bug the RFC §"What's actually broken" item 2 calls
|
||||
out.
|
||||
|
||||
- **B2 — Picker doesn't distinguish triggers from leaves.**
|
||||
`LookupFollowUps` (`fristenrechner_followups.go:241-287`) resolves
|
||||
by `pe.id` / `pe.code` / `sr.id` with no
|
||||
"is-this-event-actually-a-trigger" gate. The data already supports
|
||||
derivation — 67 of 222 active events act as a chain anchor. The
|
||||
picker just isn't wired to the derivation. Compounding: 4 events
|
||||
are *spawn-only* consequences (`upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn`)
|
||||
— picking one returns the spawn rule itself with no follow-ups,
|
||||
which surfaces as "Keine Folge-Fristen".
|
||||
|
||||
- **B3 — Scenario state is forked across three stores by design but
|
||||
zero stores by data.**
|
||||
- `paliad.project_event_choices` (mig 129) — schema present, 0 rows.
|
||||
`EventChoiceService` reads + writes it via
|
||||
`internal/services/event_choice_service.go:74,123,180`.
|
||||
- `paliad.scenarios` (mig 145) — 0 rows, 0/18 projects bound via
|
||||
`active_scenario_id`. `ScenarioService.LoadScenarios` in
|
||||
`internal/services/fristenrechner.go:583-627` reads it.
|
||||
- DOM state on the result view — Verfahrensablauf checkbox state
|
||||
only lives client-side. Confirmed by absence of a write path
|
||||
from `verfahrensablauf.tsx` to either DB-side store.
|
||||
|
||||
The RFC's "three independent stores" claim is *architecturally*
|
||||
true today, but every store is empty. Risk is dormant — until
|
||||
someone enables persistence on either path and the divergence
|
||||
materialises. See R6.
|
||||
|
||||
- **B4 — 6 active `proceeding_types` have zero rules.**
|
||||
`upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`,
|
||||
`upc.epo.review`, `upc.pl.cfi`. They appear in
|
||||
`/api/tools/proceeding-types` (`is_active=true` + `kind='proceeding'`)
|
||||
but produce empty timelines when chosen. The Mode A picker can
|
||||
bind a project to them; the Mode B result view is blank.
|
||||
|
||||
### 2.4 Dead-or-decaying code
|
||||
|
||||
- **`paliad.trigger_events` table.** 110 rows; columns
|
||||
`(id, code, name, name_de, description, is_active, created_at, concept_id)`.
|
||||
Bigint PK. No `parent_id`, no `proceeding_type_id`. Consumed by:
|
||||
`deadline_rule_service.go:226-285` (label fallback), `event_deadline_service.go`
|
||||
(legacy route), `event_type_service.go` (Pipeline C bridge),
|
||||
`export_service.go:1680` (workbook sheet), and 80 active
|
||||
sequencing_rules' `trigger_event_id` (which is in turn primarily a
|
||||
bridge for the 73 globals + 7 hybrid rules with a real proceeding).
|
||||
- **Inactive proceeding_types still referenced by spawn rules.**
|
||||
id 11 (`upc.apl.merits`), 19 (`upc.apl.cost`), 20 (`upc.apl.order`).
|
||||
Mig 138 (`appeal_target_backfill_merits_order`) split them, mig
|
||||
later unified them onto id 160. The 4 spawn rules' FK was not
|
||||
updated.
|
||||
- **3 non-`proceeding` kinds.** 23 rows total
|
||||
(`phase` × 4 + `side_action` × 10 + `meta` × 9), all
|
||||
`is_active=false` after mig 153. Live in the table for audit;
|
||||
unused by any active surface. The Slice 10 orphan-resolution path
|
||||
(`rule_editor_orphans.go`) could theoretically encounter them, but
|
||||
active = false filters them out.
|
||||
|
||||
---
|
||||
|
||||
## 3. Rules-corpus quality audit (live data)
|
||||
|
||||
### 3.1 `parent_id` coverage
|
||||
|
||||
- 107/226 active+published rules have `parent_id` set (**47%**, matches
|
||||
RFC).
|
||||
- 119/226 do not. Decomposition (active+published):
|
||||
|
||||
| Subset | Rows | Meaning |
|
||||
|---|--:|---|
|
||||
| `parent_id NULL` AND `trigger_event_id IS NULL` AND `proceeding_type_id` set | 46 | Genuine proceeding-level roots (each PT has 1–6 such). |
|
||||
| `parent_id NULL` AND `trigger_event_id` set AND `proceeding_type_id NULL` | 73 | The legacy globals — no place in the new chain model yet. |
|
||||
|
||||
Of the 46 proceeding-level roots:
|
||||
|
||||
| `proceeding_type.code` | roots | active rules |
|
||||
|---|--:|--:|
|
||||
| `de.inf.lg` | 5 | 9 |
|
||||
| `de.null.bpatg` | 4 | 10 |
|
||||
| `epa.grant.exa` | 4 | 7 |
|
||||
| `upc.apl.unified` | 6 | 16 |
|
||||
| `epa.opp.boa` | 3 | 8 |
|
||||
| `upc.pi.cfi` | 3 | 7 |
|
||||
| `epa.opp.opd` | 2 | 8 |
|
||||
| `de.inf.bgh`, `de.inf.olg`, `de.null.bgh`, `dpma.appeal.bgh`, `dpma.appeal.bpatg`, `dpma.opp.dpma`, `upc.disc.cfi` | 1 each | various |
|
||||
| `upc.dmgs.cfi`, `upc.inf.cfi`, `upc.rev.cfi` | 4 each | 8/25/17 |
|
||||
|
||||
Most "root" rules are legitimate (the chain start event has no logical
|
||||
predecessor — `Klageerhebung`, `Zustellung`, `Veröffentlichung`,
|
||||
`Anmeldung`, etc.). A small number are leaves whose parent chain just
|
||||
hasn't been seeded (e.g. `de.inf.lg.berufung` / `de.inf.lg.beruf_begr`
|
||||
list "Berufungsfrist" and "Berufungsbegründung" as parent-NULL despite
|
||||
both having a logical predecessor in `de.inf.lg.urteil`).
|
||||
|
||||
### 3.2 `condition_expr` usage
|
||||
|
||||
18 rules use the column. Three keys total:
|
||||
|
||||
| Key | Uses | Sample shape |
|
||||
|---|--:|---|
|
||||
| `flag` | 14 | `{"flag":"with_ccr"}`, `{"flag":"with_amend"}`, `{"flag":"with_cci"}` |
|
||||
| `op` | 4 | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` |
|
||||
| `args` | 4 | always nested under an `op:and` |
|
||||
|
||||
Distinct expressions (4 total, all UPC inf/rev):
|
||||
`{"flag":"with_ccr"}` (×6), `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` (×4), `{"flag":"with_cci"}` (×4), `{"flag":"with_amend"}` (×4).
|
||||
|
||||
No formal validation at write time — `RuleEditorService` accepts the
|
||||
column as freeform jsonb. The 3 flags are de-facto convention.
|
||||
|
||||
### 3.3 Spawn distribution
|
||||
|
||||
4 rules, all in the UPC CFI cluster, all `priority='optional'` +
|
||||
`primary_party='both'` + spawn target id=11 (`upc.apl.merits`, inactive):
|
||||
|
||||
| Anchor event | Spawn label | Target |
|
||||
|---|---|---|
|
||||
| `upc.inf.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
|
||||
| `upc.rev.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
|
||||
| `upc.pi.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
|
||||
| `upc.dmgs.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
|
||||
|
||||
### 3.4 `primary_party` distribution
|
||||
|
||||
Excluding the 73 globals (all NULL), the published+active rules split:
|
||||
|
||||
| `proceeding_type` cluster | `claimant` | `defendant` | `both` | `court` |
|
||||
|---|--:|--:|--:|--:|
|
||||
| `upc.inf.cfi` (25) | 6 | 7 | 8 | 4 |
|
||||
| `upc.rev.cfi` (17) | 6 | 7 | 1 | 3 |
|
||||
| `upc.apl.unified` (16) | 0 | 0 | 12 | 4 |
|
||||
| `de.null.bpatg` (10) | 2 | 2 | 3 | 3 |
|
||||
| `de.inf.lg` (9) | 2 | 3 | 2 | 2 |
|
||||
| `epa.opp.opd` (8) | 0 | 1 | 6 | 1 |
|
||||
| `epa.opp.boa` (8) | 0 | 0 | 6 | 2 |
|
||||
| `de.inf.bgh` (8) | 0 | 0 | 6 | 2 |
|
||||
| `upc.dmgs.cfi` (8) | 2 | 2 | 1 | 3 |
|
||||
|
||||
39 rules have a `primary_party` value that differs from their parent
|
||||
rule's `primary_party` (excluding `court` ↔ anything, which is
|
||||
trivial). All 39 are legitimate "ball-in-other-court" hand-offs
|
||||
(claimant SoC → defendant SoD → claimant Reply → defendant Rejoinder
|
||||
…). The /follow-ups filter (§2.3 B1) hides all of them when the user
|
||||
picks a perspective.
|
||||
|
||||
### 3.5 `is_court_set` coverage
|
||||
|
||||
46 rules carry `is_court_set=true`. Distribution: every proceeding has
|
||||
at least one (the decision / order / oral-hearing rows). Highest:
|
||||
`de.inf.lg` (5), `epa.grant.exa` (4), `upc.apl.unified` (4),
|
||||
`upc.inf.cfi` (3), `upc.rev.cfi` (3), `upc.pi.cfi` (3), `upc.dmgs.cfi`
|
||||
(3). Calculator skips these in date math — they surface as
|
||||
"wird vom Gericht bestimmt" markers.
|
||||
|
||||
### 3.6 Legacy `trigger_event_id` overlap with `parent_id`
|
||||
|
||||
| Combination | Rows |
|
||||
|---|--:|
|
||||
| `parent_id` set AND `trigger_event_id` set | **2** |
|
||||
| `parent_id` set AND `trigger_event_id` NULL | 105 |
|
||||
| `parent_id` NULL AND `trigger_event_id` set | 73 |
|
||||
| `parent_id` NULL AND `trigger_event_id` NULL | 46 |
|
||||
|
||||
**Overlap is 2 rules out of 226 (0.9%).** The two models are
|
||||
effectively **disjoint** in the corpus: the 73 legacy globals own the
|
||||
`trigger_event_id` lane; the 105 chain-linked rules own `parent_id`.
|
||||
The schema permits both columns to be set simultaneously, and 2 rules
|
||||
exercise that — but they are outliers, not a documented pattern.
|
||||
|
||||
The legacy `paliad.trigger_events` table is still read for label
|
||||
display by `deadline_rule_service.go:226-285` (the "abhängig von …"
|
||||
chip rule fallback when `parent_id` isn't set) and for the legacy
|
||||
`/api/tools/event-deadlines` route.
|
||||
|
||||
---
|
||||
|
||||
## 4. Editorial gap map
|
||||
|
||||
Per `proceeding_type` (active, kind=`proceeding`). Columns:
|
||||
|
||||
- **A** = active+published rules
|
||||
- **P** = rules with `parent_id` set
|
||||
- **R** = rules without `parent_id` (roots + leaves with missing parent)
|
||||
- **E** = active+published events whose code matches this PT's
|
||||
prefix
|
||||
|
||||
| PT code | A | P | R | E | Health |
|
||||
|---|--:|--:|--:|--:|---|
|
||||
| `upc.inf.cfi` | 25 | 21 | 4 | 25 | 84% chained — strongest |
|
||||
| `upc.rev.cfi` | 17 | 13 | 4 | 17 | 76% |
|
||||
| `upc.apl.unified` | 16 | 10 | 6 | 16 † | 63% — code-prefix issue, see below |
|
||||
| `de.null.bpatg` | 10 | 6 | 4 | 10 | 60% |
|
||||
| `de.inf.lg` | 9 | 4 | 5 | 9 | 44% — gappy |
|
||||
| `epa.opp.opd` | 8 | 6 | 2 | 8 | 75% |
|
||||
| `epa.opp.boa` | 8 | 5 | 3 | 8 | 63% |
|
||||
| `de.inf.bgh` | 8 | 7 | 1 | 8 | 88% |
|
||||
| `upc.dmgs.cfi` | 8 | 4 | 4 | 8 | 50% |
|
||||
| `upc.pi.cfi` | 7 | 4 | 3 | 7 | 57% |
|
||||
| `de.inf.olg` | 7 | 6 | 1 | 7 | 86% |
|
||||
| `epa.grant.exa` | 7 | 3 | 4 | 7 | 43% |
|
||||
| `de.null.bgh` | 6 | 5 | 1 | 6 | 83% |
|
||||
| `dpma.appeal.bpatg` | 5 | 4 | 1 | 5 | 80% |
|
||||
| `dpma.appeal.bgh` | 4 | 3 | 1 | 4 | 75% |
|
||||
| `dpma.opp.dpma` | 4 | 3 | 1 | 4 | 75% |
|
||||
| `upc.disc.cfi` | 4 | 3 | 1 | 4 | 75% |
|
||||
| `upc.bsv.cfi` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
| `upc.ccr.cfi` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
| `upc.costs.cfi` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
| `upc.dni.cfi` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
| `upc.epo.review` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
| `upc.pl.cfi` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
|
||||
† `upc.apl.unified` (id=160) is the active type, but its 16 events
|
||||
retain the *legacy* code prefixes `upc.apl.{merits,cost,order}.*`
|
||||
from the pre-unification taxonomy. The rules' `proceeding_type_id`
|
||||
was rebound to 160; the event codes were not renamed. Functional but
|
||||
inconsistent — see R3.
|
||||
|
||||
**Events with no rule:** 0. Every active+published event has at least
|
||||
one rule (corpus is 1:1 since mig 136). Editorial gap is therefore
|
||||
parent-chain-shaped, not rule-coverage-shaped.
|
||||
|
||||
**Unmatched-prefix events:** 69 events with `code LIKE 'null.%'`. They
|
||||
have rules (the 73 legacy globals — note the disparity: 73 rules but
|
||||
69 events, because dedupe in mig 151 collapsed some duplicates while
|
||||
the rules still point at the canonical event). They do not belong to
|
||||
any proceeding_type and never will under the current taxonomy.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk register
|
||||
|
||||
Eleven items. Each: what, where, severity. Severity scale:
|
||||
**critical** (user-visible incorrect output / data loss possible) →
|
||||
**high** (user-visible UX lie, no data corruption) → **medium**
|
||||
(developer-trap; breaks at next refactor) → **low** (cosmetic / dead
|
||||
code, deferred maintenance).
|
||||
|
||||
### R1 — Cross-party follow-up filter drops legitimate hand-offs — **high**
|
||||
|
||||
- Where: `internal/services/fristenrechner_followups.go:358-367`.
|
||||
- Effect: with `party=claimant|defendant`, 39 active rules are hidden
|
||||
because their `primary_party` is the *other* side. Result-view
|
||||
reports "Keine Folge-Fristen" on chains that continue cross-party
|
||||
(e.g. `def_to_ccr` claimant-filed → `reply_def_ccr` defendant-filed
|
||||
in `upc.inf.cfi`).
|
||||
- Impact: UX lies to users about chain completion; can lead to missed
|
||||
deadlines on the opposing side's view.
|
||||
|
||||
### R2 — Picker accepts spawn-only and leaf events — **high**
|
||||
|
||||
- Where: `internal/services/fristenrechner_followups.go:241-287` (anchor
|
||||
resolution does not check chain-anchor status); `internal/services/fristenrechner_search_events.go`
|
||||
(search returns every event).
|
||||
- Effect: Picking `upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn` (spawn-only)
|
||||
shows the spawn rule itself but no follow-ups → "Keine Folge-Fristen".
|
||||
Picking a leaf event (e.g. `upc.inf.cfi.def_to_ccr`) only reaches
|
||||
whatever hop-1 children exist on the leaf's own party, see R1.
|
||||
- 67/222 active events are chain-anchors. Today's picker shows all
|
||||
222 with equal weight.
|
||||
|
||||
### R3 — 4 spawn rules point at an inactive `proceeding_type` — **high**
|
||||
|
||||
- Where: 4 rows in `paliad.sequencing_rules` with `is_spawn=true`
|
||||
and `spawn_proceeding_type_id=11` (`upc.apl.merits`, `is_active=false`).
|
||||
The active appeal type is id=160 (`upc.apl.unified`).
|
||||
- Effect: any consumer that joins on `spt.is_active=true` (none today,
|
||||
but the moment any does) returns NULL for the spawn target. Today
|
||||
the join is permissive (`fristenrechner_followups.go:394`) — it
|
||||
returns `upc.apl.merits` to the frontend, which may surface as a
|
||||
CTA pointing at a stale type slug.
|
||||
- Plus consequence: `upc.apl.unified` events kept legacy code prefixes
|
||||
`upc.apl.{merits,cost,order}.*` even though the type rebinds to 160.
|
||||
Code/PT mismatch is harmless today; trap for any future code-prefix
|
||||
routing.
|
||||
|
||||
### R4 — 73 "global" legacy rules orphan from the chain model — **medium**
|
||||
|
||||
- Where: `paliad.sequencing_rules WHERE proceeding_type_id IS NULL AND trigger_event_id IS NOT NULL` (73 rows). Anchored on `null.<8hex>`
|
||||
procedural_events (69 distinct events, 73 rules — small overlap from
|
||||
pre-dedupe history).
|
||||
- Effect: invisible to Mode B (proceeding-type ablauf) because they
|
||||
don't bind to any PT; visible to the legacy bigint route
|
||||
`/api/tools/event-deadlines` and to /admin/procedural-events.
|
||||
- Migration debt: any "deprecate `trigger_event_id`" plan must decide
|
||||
whether to (a) reparent these onto a PT + parent chain, (b) keep them
|
||||
as floating cross-cutting rules in a separate lane, or (c) drop them.
|
||||
|
||||
### R5 — Legacy `paliad.trigger_events` table is read by 5 surfaces — **medium**
|
||||
|
||||
- Where:
|
||||
- `internal/services/deadline_rule_service.go:226-285` — bulk-load for
|
||||
"abhängig von …" chip label fallback.
|
||||
- `internal/services/event_deadline_service.go:79,244` — legacy
|
||||
`/api/tools/event-deadlines` route.
|
||||
- `internal/services/event_type_service.go:40-414` — Pipeline-C event
|
||||
types bridge (`event_types.trigger_event_id`).
|
||||
- `internal/services/export_service.go:1680` — `ref__trigger_events`
|
||||
workbook sheet.
|
||||
- `cmd/gen-upc-snapshot/main.go:185-202` — UPC offline snapshot for
|
||||
youpc.org.
|
||||
- Effect: 110-row catalog with bigint PK lives alongside the 222 active
|
||||
procedural_events (UUID PK). Two ID spaces, two label sources,
|
||||
partial overlap.
|
||||
|
||||
### R6 — Three scenario stores: 0 rows each, but 3 live read/write paths — **medium**
|
||||
|
||||
- Stores: `paliad.project_event_choices` (0 rows), `paliad.scenarios`
|
||||
(0 rows), DOM state on Verfahrensablauf checkboxes.
|
||||
- Paths:
|
||||
- `EventChoiceService` (`internal/services/event_choice_service.go:15-180`)
|
||||
reads + writes the table.
|
||||
- `ScenarioService.LoadScenarios` + handlers
|
||||
(`internal/services/fristenrechner.go:583-627`, `internal/handlers/scenarios.go:14-200+`)
|
||||
read + write the table.
|
||||
- Verfahrensablauf result view writes nothing back — DOM only.
|
||||
- Effect today: nothing — empty tables. Effect tomorrow: the moment any
|
||||
surface starts persisting, the three paths can diverge. The RFC
|
||||
(§"What's actually broken" item 3) calls out the symptom: toggling
|
||||
"Mit Widerklage" on Verfahrensablauf doesn't drive conditional
|
||||
checkboxes in result-view submission cards.
|
||||
|
||||
### R7 — 6 active `proceeding_types` are entirely unruled — **medium**
|
||||
|
||||
- Where: `upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`,
|
||||
`upc.epo.review`, `upc.pl.cfi`. All `is_active=true`, `kind='proceeding'`,
|
||||
0 active+published rules, 0 events with their code prefix.
|
||||
- Effect: pickable on `/api/tools/proceeding-types`, bindable on
|
||||
`paliad.projects.proceeding_type_id` (mig 153 only rejects non-
|
||||
proceeding kind, not zero-rule). Binding succeeds → SmartTimeline +
|
||||
Mode B render an empty result. UX lies.
|
||||
|
||||
### R8 — `condition_expr` is freeform jsonb — **medium**
|
||||
|
||||
- Where: column declaration in mig 136; consumer in
|
||||
`deadline_rule_service.go` (selected + passed to engine in
|
||||
`pkg/litigationplanner/engine.go`); writer in
|
||||
`internal/services/rule_editor_service.go:625-843` (no validation).
|
||||
- Effect: 4 distinct shapes used today, 3 keys (`flag`, `op`, `args`).
|
||||
No write-time validation. New keys can be silently added; the
|
||||
engine consumes by switching on string literals. Refactor trap.
|
||||
|
||||
### R9 — Inactive `proceeding_types` rows linger (23) — **low**
|
||||
|
||||
- Where: mig 153 flipped 4 phase + 10 side_action + 9 meta rows to
|
||||
`is_active=false`. They still exist for audit.
|
||||
- Effect: snapshots and snapshots-of-snapshots
|
||||
(`proceeding_types_pre_153`, `procedural_events_pre_151`,
|
||||
`sequencing_rules_pre_151/_pre_152`) accumulate without a decay
|
||||
policy. Storage cost is trivial; query-shape cost is real if any
|
||||
query forgets `WHERE kind='proceeding' AND is_active=true`.
|
||||
|
||||
### R10 — `event_kind` is nullable + not enumerated in DB — **low**
|
||||
|
||||
- Where: `paliad.procedural_events.event_kind text NULL`. Code at
|
||||
`frontend/src/admin-rules-edit.tsx:187` lists `filing / hearing /
|
||||
decision / order` in the UI but the DB accepts anything.
|
||||
- Effect: drift between UI vocab and persisted values is possible.
|
||||
Currently 5 buckets: `filing`, `hearing`, `decision`, `order`, NULL
|
||||
(per RFC).
|
||||
|
||||
### R11 — `applies_to_target` + `choices_offered` lack a schema — **low**
|
||||
|
||||
- Where: `paliad.sequencing_rules.applies_to_target text[]`,
|
||||
`choices_offered jsonb`.
|
||||
- Effect: 16 rules use `applies_to_target`, 28 use `choices_offered`.
|
||||
Three observed `choices_offered` shapes: `{appellant:[…]}` (20),
|
||||
`{skip:[…]}` (6), `{include_ccr:[…]}` (2). Wire-level convention,
|
||||
no documentation. New shapes silently land if a future editor
|
||||
decides on one.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommendation — order of operations for the inventor
|
||||
|
||||
Phase 2 design starts with the highest-stakes, hardest-to-rewind
|
||||
decisions and finishes with editorial/cleanup. Each step is a
|
||||
question for m, not a design choice for the inventor.
|
||||
|
||||
### Tier 1 — model decisions (grill first)
|
||||
|
||||
1. **Trigger semantics.** Keep `parent_id` as the canonical link?
|
||||
What is the role of `trigger_event_id` after this RFC ships? If
|
||||
deprecated, what happens to the 73 legacy globals (R4) — reparent
|
||||
onto PTs, keep as a separate "cross-cutting" lane, or drop?
|
||||
2. **Trigger discoverability.** Derive from data (events that
|
||||
parent ≥1 rule = 67 today), maintain a materialised view, or carry
|
||||
an explicit `is_trigger` flag on `procedural_events`? Affects R2.
|
||||
3. **Scenario state — single home.** Of the three stores in R6, which
|
||||
wins? Migration shape for the others? The RFC mis-spoke about
|
||||
`projects.scenarios jsonb` — the table is `paliad.scenarios` with
|
||||
a `spec` jsonb column (mig 145). Confirm which storage the inventor
|
||||
reasons from.
|
||||
4. **Cross-party display semantics.** Backend stops filtering,
|
||||
frontend groups by side? Or backend tags + frontend renders an
|
||||
"andere Partei" group? Affects R1.
|
||||
|
||||
### Tier 2 — surface decisions
|
||||
|
||||
5. **Spawn → consequence-only events.** Stop surfacing spawn-only
|
||||
events in the picker (R2), or keep them and tag visually?
|
||||
6. **Re-target the 4 spawn rules** (R3) — point at id=160 vs reseed
|
||||
legacy ids; align event code prefixes vs. accept the mismatch.
|
||||
7. **Sequence-from-proceeding-type view** (Entry A). Where does it
|
||||
live? How do its toggles persist to the chosen scenario store?
|
||||
8. **Legacy `/api/tools/event-deadlines` deprecation** (R5). Drop,
|
||||
redirect, or keep behind a flag during transition?
|
||||
|
||||
### Tier 3 — editorial + cleanup
|
||||
|
||||
9. **Editorial backfill plan.** Which of the 119 parent-NULL rules
|
||||
are real roots vs. unseeded leaves (a per-PT walkthrough by m).
|
||||
10. **Empty proceeding_types** (R7). Stub with placeholder rules, or
|
||||
hide from the picker until rules land?
|
||||
11. **`condition_expr` formalisation** (R8). Pick a grammar, document
|
||||
it, add write-time validation. Same question for `choices_offered`
|
||||
+ `applies_to_target` (R11).
|
||||
12. **Legacy `trigger_events` table fate.** Drop, archive, or
|
||||
repurpose? Depends on Q1 + Q2 above.
|
||||
|
||||
The inventor should grill m on Tier 1 before sketching anything.
|
||||
Tier 2 follows from Tier 1's decisions. Tier 3 is mechanical once
|
||||
Tier 1+2 land.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — query receipts
|
||||
|
||||
All counts in this assessment came from the live `paliad` schema on
|
||||
the youpc Supabase instance during the audit window (2026-05-27).
|
||||
Representative queries:
|
||||
|
||||
```sql
|
||||
-- §0 + §3.1 + §3.6
|
||||
SELECT
|
||||
CASE
|
||||
WHEN parent_id IS NOT NULL AND trigger_event_id IS NOT NULL THEN 'both'
|
||||
WHEN parent_id IS NOT NULL AND trigger_event_id IS NULL THEN 'parent only'
|
||||
WHEN parent_id IS NULL AND trigger_event_id IS NOT NULL THEN 'legacy only'
|
||||
ELSE 'neither (root)'
|
||||
END AS classification,
|
||||
proceeding_type_id IS NULL AS pt_null, count(*) AS rules
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE is_active AND lifecycle_state = 'published'
|
||||
GROUP BY classification, pt_null
|
||||
ORDER BY classification, pt_null;
|
||||
-- → both/false=2, legacy only/true=73, neither/false=46, parent only/false=105
|
||||
|
||||
-- §3.4
|
||||
SELECT pt.code, sr.primary_party, count(*)
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
|
||||
WHERE sr.is_active AND sr.lifecycle_state='published'
|
||||
GROUP BY pt.code, sr.primary_party ORDER BY pt.code, count(*) DESC;
|
||||
|
||||
-- §4 (gap map)
|
||||
SELECT pt.code, count(sr.id) AS active_rules,
|
||||
count(*) FILTER (WHERE sr.parent_id IS NULL) AS roots
|
||||
FROM paliad.proceeding_types pt
|
||||
LEFT JOIN paliad.sequencing_rules sr ON sr.proceeding_type_id = pt.id
|
||||
AND sr.is_active AND sr.lifecycle_state='published'
|
||||
WHERE pt.is_active AND pt.kind='proceeding'
|
||||
GROUP BY pt.code ORDER BY pt.code;
|
||||
|
||||
-- §3.2 (condition_expr keys)
|
||||
WITH expanded AS (
|
||||
SELECT jsonb_object_keys(condition_expr) AS k
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE condition_expr IS NOT NULL AND condition_expr::text <> '{}'
|
||||
) SELECT k, count(*) FROM expanded GROUP BY k ORDER BY count(*) DESC;
|
||||
-- → flag=14, args=4, op=4
|
||||
```
|
||||
|
||||
Full set of queries used during the audit is available in the agent
|
||||
transcript; reproducible against any read-only Supabase role.
|
||||
|
||||
— end of assessment.
|
||||
776
docs/design-deadline-system-revision-2026-05-27.md
Normal file
776
docs/design-deadline-system-revision-2026-05-27.md
Normal file
@@ -0,0 +1,776 @@
|
||||
# Design — Deadline + procedural-events system revision (Phase 2 of RFC m/paliad#149)
|
||||
|
||||
**Task:** t-paliad-329
|
||||
**Gitea:** m/paliad#149 (Phase 2)
|
||||
**Inventor:** atlas (shift-1)
|
||||
**Date:** 2026-05-27
|
||||
**Status:** Draft — coder gate held; awaiting m's go on the slice train
|
||||
**Branch:** `mai/atlas/inventor-deadline-system`
|
||||
|
||||
**Builds on:**
|
||||
- `docs/assessment-deadline-system-2026-05-27.md` (athena Phase 1, 738 lines — premises here are athena's)
|
||||
- `docs/design-fristenrechner-followup-rules-2026-05-27.md` (atlas t-paliad-327, pre-ratified subset: cross-party display + scenario SSoT + spawn-only picker exclusion)
|
||||
- `docs/design-proceeding-types-taxonomy-2026-05-26.md` (mig 153 shipped; `kind` discriminator)
|
||||
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (Entry B foundation S1-S6 shipped)
|
||||
|
||||
m authorised Phase 2 at 2026-05-27 11:33 ("Go on"). m's "big picture" direction at 13:53 ("yeah, b - big! We need an overall schema for all procedural events and how they are connected") makes the connection graph itself the spine of this design.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises — reconciliation with athena's audit
|
||||
|
||||
Athena established the live data; this design takes that as given. Three cross-checks ran 2026-05-27 against the live `paliad` schema; counts match athena's §0/§3 numbers (chain-linked 107 / PT-roots 46 / legacy globals 73 / overlap 2). The only material refinement is athena's R3 finding ("4 spawn rules point at INACTIVE id=11") — which m's Q5 answer now re-interprets as **correct** rather than broken (see §3.1).
|
||||
|
||||
### §0.1 The athena↔RFC conflicts surfaced
|
||||
|
||||
| Item | RFC said | Athena found | Picked side |
|
||||
|---|---|---|---|
|
||||
| Scenario state shape | "`projects.scenarios` jsonb (mig 145)" exists | `paliad.scenarios` table exists; `projects.scenarios` jsonb does **not** | Athena. Use new `projects.scenario_flags jsonb` column (Q4) — different from both. |
|
||||
| Three stores diverge | "Three independent stores. No single source of truth." | All three stores empty (0 rows in `project_event_choices`, 0 in `scenarios`, DOM-only). Risk dormant. | Athena. Design picks one store going forward; nothing to migrate. |
|
||||
| Spawn FK is "broken" | Implied | Athena R3: 4 spawn rules point at inactive `upc.apl.merits`. | m's Q5 inverts: the unification was the bug, not the FK. Re-split apl into merits/cost/order (§3.1). |
|
||||
|
||||
### §0.2 The pre-ratified subset from t-paliad-327
|
||||
|
||||
m ratified the following on 2026-05-27 (via `AskUserQuestion`, all on-recommendation in that task) — Phase 2 carries them forward unchanged:
|
||||
|
||||
- Cross-party display: backend stops filtering by party, `is_cross_party` derived field, "Gegenseitig" badge, muted/greyed visual, unchecked default, write-back excluded unconditionally. (Folded into §2.4.)
|
||||
- Scenario flag SSoT: `paliad.projects.scenario_flags jsonb` column + GET/PATCH `/api/projects/{id}/scenario-flags`. (Folded into §2.3.)
|
||||
- Spawn-only event picker exclusion: `SearchEvents` SQL adds `AND sr.is_spawn = false`. (Folded into §2.2.)
|
||||
|
||||
These are not re-asked. They are the foundation Phase 2 builds on.
|
||||
|
||||
---
|
||||
|
||||
## §1 The overall connection schema (m's "big picture")
|
||||
|
||||
Per m's direction: document the canonical connection graph across all procedural_events + sequencing_rules + proceeding_types as a unified model.
|
||||
|
||||
### §1.1 Conceptual model in one paragraph
|
||||
|
||||
A **rule** (`paliad.sequencing_rules` row) is the atomic node. It carries one deadline for one event, on one proceeding-type. Every rule has at most one **predecessor edge** via `parent_id` → another rule whose own deadline must elapse before this one starts. The chain root (rule with `parent_id IS NULL`) is anchored to its **proceeding-type root event** (typically a filing — Klageerhebung, Veröffentlichung, Anmeldung). A small number of rules are **spawn rules** (`is_spawn=true`) — they don't compute their own deadline; instead they open a fresh proceeding of a different type, edge labelled by `spawn_proceeding_type_id`. Conditional rules carry a `condition_expr` jsonb predicate over a small flag vocabulary (`with_ccr`, `with_amend`, `with_cci`); the active subset of the graph for a given project is the rules whose predicate is satisfied by `projects.scenario_flags`. **The only canonical predecessor link is `parent_id`. The `trigger_event_id` column is deprecated** (Q1). Trigger discoverability is **derived from data**: any event whose anchor rule has `EXISTS (non-spawn child WHERE child.parent_id = anchor.id)` is a valid trigger; everything else (spawn-only consequences, terminal leaves) is filtered out at the picker (Q3, §2.2).
|
||||
|
||||
### §1.2 The shape — ASCII tree per representative PT
|
||||
|
||||
Showing 3 representative PTs (the rest follow the same structural pattern; counts in §1.4).
|
||||
|
||||
#### upc.inf.cfi (25 rules, depth 5, the densest tree)
|
||||
|
||||
```
|
||||
upc.inf.cfi (Verletzungsverfahren CFI)
|
||||
├─ RoP.013.1 soc Klageerhebung [claimant · M] ← anchor
|
||||
│ ├─ RoP.019.1 prelim Vorl. Einwendungen [defendant · O]
|
||||
│ ├─ RoP.262.2 confidentiality_response Vertraulichkeit [both · O]
|
||||
│ ├─ RoP.023 sod Klageerwiderung [defendant · M]
|
||||
│ │ └─ RoP.029.b reply Replik [claimant · M · ?with_ccr]
|
||||
│ │ └─ RoP.029.c rejoin Duplik [defendant · M · ?with_ccr]
|
||||
│ ├─ RoP.025 ccr Widerklage auf Nichtigkeit [defendant · O · ?with_ccr]
|
||||
│ │ └─ RoP.029.a def_to_ccr Erwiderung auf CCR [claimant · M · ?with_ccr]
|
||||
│ │ └─ RoP.029.d reply_def_ccr Replik auf Erw. CCR [defendant · M · ?with_ccr] ← X-party from claimant
|
||||
│ │ └─ RoP.029.e rejoin_reply_ccr Duplik auf Replik CCR [claimant · M · ?with_ccr]
|
||||
│ │ └─ RoP.030.1 app_to_amend Antrag auf Patentänderung [claimant · M · ?with_amend]
|
||||
│ │ └─ RoP.032.1 def_to_amend Erwiderung auf Änderung [defendant · M · ?with_amend]
|
||||
│ │ └─ RoP.032.3 reply_def_amd Replik auf Erw. Änderung [claimant · M · ?with_amend]
|
||||
│ │ └─ RoP.032.3 rejoin_amd Duplik auf Replik Änderung [defendant · M · ?with_amend]
|
||||
│ ├─ RoP.333.2 cmo_review Antrag CMO-Überprüfung [both · O]
|
||||
│ ├─ RoP.109.1 translation_request Übersetzungsantrag [both · O]
|
||||
│ ├─ RoP.109.5 translations_lodge Übersetzungen einreichen [both · M]
|
||||
│ ├─ RoP.118.4 cons_orders Antrag Folgenanordnungen [both · O]
|
||||
│ ├─ RoP.151 cost_app Kostenantrag [both · O]
|
||||
│ ├─ RoP.353 rectification Berichtigungsantrag [both · O]
|
||||
│ └─ RoP.220.1.a appeal_spawn ⇲ Berufungsverfahren öffnen [both · O · SPAWN→ upc.apl.merits]
|
||||
├─ RoP.104 interim Zwischenanhörung [court · M]
|
||||
├─ (n/a) oral Mündliche Verhandlung [court · M]
|
||||
├─ (n/a) decision Endentscheidung [court · M]
|
||||
│ (Note: interim/oral/decision are court-set; they're chain-anchored but
|
||||
│ have no scheduled rule of their own — phase markers carried via event_kind.)
|
||||
└─ RoP.109.4 interpreter_cost Dolmetscherkosten [court · M]
|
||||
```
|
||||
|
||||
**Legend.** `[party · M|O · ?flag · SPAWN→target]`. `M` = mandatory, `O` = optional. `?flag` = conditional on the scenario flag. ← X-party = cross-party row vs claimant perspective; see §2.4 for display. SPAWN → opens a new proceeding under that PT.
|
||||
|
||||
#### upc.rev.cfi (17 rules, depth 4, mirrors inf.cfi shape)
|
||||
|
||||
Same SoC → SoD → Reply → Rejoinder spine; CCR mirrored as Erwiderung auf Widerklage on revocation. `with_cci` (Widerklage auf Verletzung — the inverse of with_ccr) replaces `with_ccr`. Same `with_amend` branch for R.30. 13 chain-linked, 5 roots, 1 spawn (→ upc.apl.merits, post-Q5 split).
|
||||
|
||||
#### upc.apl (POST-Q5 SPLIT — 3 trees, 16 rules total)
|
||||
|
||||
After §3.1 mig: id=160 `upc.apl.unified` is retired; rules re-bound to the 3 reactivated PTs (id=11 `upc.apl.merits` 7 rules / id=19 `upc.apl.cost` 2 rules / id=20 `upc.apl.order` 7 rules). Trees:
|
||||
|
||||
```
|
||||
upc.apl.merits (7 rules)
|
||||
├─ RoP.224.1.a notice Berufungseinlegung
|
||||
│ └─ RoP.224.2.a grounds Berufungsbegründung
|
||||
│ └─ RoP.235.1 response Berufungserwiderung
|
||||
│ └─ RoP.237 cross_a Anschlussberufung
|
||||
│ └─ RoP.238.1 cross_a_reply Erwiderung Anschlussberufung
|
||||
├─ (n/a) oral Mündliche Verhandlung [court · M]
|
||||
└─ (n/a) decision Entscheidung [court · M]
|
||||
|
||||
upc.apl.cost (2 rules)
|
||||
├─ RoP.221.1 leave_app Antrag auf Berufungszulassung
|
||||
└─ (n/a) decision Kostenfestsetzungsbeschluss
|
||||
|
||||
upc.apl.order (7 rules)
|
||||
├─ (n/a) order angegriffene Entscheidung
|
||||
│ ├─ RoP.220.2 with_leave Berufung mit Zulassung
|
||||
│ └─ RoP.220.3 discretion Ermessensüberprüfung
|
||||
├─ RoP.224.2.b grounds_orders Berufungsbegründung (Orders Track)
|
||||
│ └─ RoP.235.2 response_orders Berufungserwiderung (Orders Track)
|
||||
└─ RoP.237 cross Anschlussberufung
|
||||
└─ RoP.238.2 cross_reply Erwiderung Anschlussberufung
|
||||
```
|
||||
|
||||
The 3 trees are independent. Determinator UX (proceeding_mapping.go) keeps a single user-facing "Berufung" entry that fans out to one of the 3 based on what's being appealed (judgment → merits, cost decision → cost, order → order). Routing layer unchanged from t-paliad-204 S1; only the data shape changes.
|
||||
|
||||
The remaining 14 ruled PTs (de.inf.lg / .olg / .bgh, de.null.bpatg / .bgh, dpma.opp / .appeal.bpatg / .bgh, epa.opp.opd / .opp.boa / .grant.exa, upc.dmgs.cfi, upc.disc.cfi, upc.pi.cfi) follow the same shape — root anchored on a filing/grant event, chain depth 1-3, optionals and conditionals branching off the root or first-hop. Athena's §4 gap map gives the per-PT P/R counts; see also §1.4 below.
|
||||
|
||||
### §1.3 Cross-PT edges — the spawn graph (post-Q5)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
upc_inf_cfi[upc.inf.cfi<br/>Verletzungsverfahren CFI] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits[upc.apl.merits<br/>Berufung Hauptsache]
|
||||
upc_rev_cfi[upc.rev.cfi<br/>Nichtigkeitsverfahren CFI] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits
|
||||
upc_dmgs_cfi[upc.dmgs.cfi<br/>Schadensbemessung] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits
|
||||
upc_pi_cfi[upc.pi.cfi<br/>Einstweilige Maßnahmen] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_order[upc.apl.order<br/>Berufung Orders Track]
|
||||
```
|
||||
|
||||
4 spawn edges, all in the UPC CFI cluster. PI appeals go to the orders track (not main proceedings); the rest go to merits. The cost-decision-appeal track (`upc.apl.cost`) is reached not via spawn but via direct filing (`leave_app` rule); cost decisions arrive within their parent proceeding and the cost-appeal opens as a standalone application.
|
||||
|
||||
DE-side, EPA-side, DPMA-side: no spawn edges today. Each tier-of-court is a separate `proceeding_type` (de.inf.lg / .olg / .bgh) with its own root + chain; chained-by-instance is not modelled as a spawn (the user explicitly creates a new project for the appeal stage). m may revisit this if DE-side workflow benefits from spawn edges; out of scope for this revision.
|
||||
|
||||
### §1.4 Per-PT health summary (post-Q5)
|
||||
|
||||
| PT code | rules | roots | chained | conditional | spawns | gap |
|
||||
|---|--:|--:|--:|--:|--:|---|
|
||||
| upc.inf.cfi | 25 | 4 | 21 | 10 | 1 | 84% chained — strongest |
|
||||
| upc.rev.cfi | 17 | 4 | 13 | 8 | 1 | 76% |
|
||||
| upc.apl.merits | 7 | 3 | 4 | 0 | 0 | post-Q5 split — to be re-rooted |
|
||||
| upc.apl.order | 7 | 3 | 4 | 0 | 0 | post-Q5 split |
|
||||
| upc.apl.cost | 2 | 1 | 1 | 0 | 0 | post-Q5 split |
|
||||
| de.inf.lg | 9 | 5 | 4 | 0 | 0 | 44% — gappy |
|
||||
| de.null.bpatg | 10 | 4 | 6 | 0 | 0 | 60% |
|
||||
| de.inf.olg | 7 | 1 | 6 | 0 | 0 | 86% |
|
||||
| de.inf.bgh | 8 | 1 | 7 | 0 | 0 | 88% |
|
||||
| de.null.bgh | 6 | 1 | 5 | 0 | 0 | 83% |
|
||||
| dpma.opp.dpma | 4 | 1 | 3 | 0 | 0 | 75% |
|
||||
| dpma.appeal.bpatg | 5 | 1 | 4 | 0 | 0 | 80% |
|
||||
| dpma.appeal.bgh | 4 | 1 | 3 | 0 | 0 | 75% |
|
||||
| epa.opp.opd | 8 | 2 | 6 | 0 | 0 | 75% |
|
||||
| epa.opp.boa | 8 | 3 | 5 | 0 | 0 | 63% |
|
||||
| epa.grant.exa | 7 | 4 | 3 | 0 | 0 | 43% |
|
||||
| upc.dmgs.cfi | 8 | 4 | 4 | 0 | 1 | 50% |
|
||||
| upc.pi.cfi | 7 | 3 | 4 | 0 | 1 | 57% |
|
||||
| upc.disc.cfi | 4 | 1 | 3 | 0 | 0 | 75% |
|
||||
| **Empty (Q6)** | | | | | | |
|
||||
| upc.bsv.cfi | 0 | — | — | — | — | unruled — badge "Keine Regeln" |
|
||||
| upc.ccr.cfi | 0 | — | — | — | — | unruled — badge |
|
||||
| upc.costs.cfi | 0 | — | — | — | — | unruled — badge |
|
||||
| upc.dni.cfi | 0 | — | — | — | — | unruled — badge |
|
||||
| upc.epo.review | 0 | — | — | — | — | unruled — badge |
|
||||
| upc.pl.cfi | 0 | — | — | — | — | unruled — badge |
|
||||
|
||||
Plus **73 legacy globals** sitting in the corpus with `proceeding_type_id IS NULL` — these are the editorial backfill target (Q2 / §4.2). Each needs to be reparented onto one of the 23 PTs.
|
||||
|
||||
---
|
||||
|
||||
## §2 Tier 1 — model decisions (m ratified all 4 on-recommendation)
|
||||
|
||||
### §2.1 `parent_id` is the canonical predecessor link
|
||||
|
||||
`paliad.sequencing_rules.parent_id` (uuid FK to another rule) is the **only** predecessor pointer going forward. `paliad.sequencing_rules.trigger_event_id` (bigint FK to legacy `paliad.trigger_events`) gets dropped at the end of the migration train (§5).
|
||||
|
||||
**Implication for the 75 rules that currently use `trigger_event_id`:**
|
||||
|
||||
- The 73 legacy globals (proceeding_type_id IS NULL): editorial walk reparents each onto a real PT chain (Q2, §4.2). Slow but right — no data is lost, just structurally normalised.
|
||||
- The 2 hybrid rules (both parent_id AND trigger_event_id set): keep `parent_id`, NULL out `trigger_event_id`. No data loss — `parent_id` already carries the live edge.
|
||||
|
||||
After backfill, `trigger_event_id` is unused — safe to drop the column (§5, Mig P4).
|
||||
|
||||
### §2.2 Trigger discoverability — derive from data
|
||||
|
||||
A `procedural_event` is a **picker-eligible trigger** when EXISTS a published+active non-spawn rule with `parent_id` pointing at this event's anchor rule. The picker SQL gains:
|
||||
|
||||
```sql
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules child
|
||||
WHERE child.parent_id = anchor.id
|
||||
AND child.is_active = true
|
||||
AND child.lifecycle_state = 'published'
|
||||
AND child.is_spawn = false -- spawn-only consequences not pickable (t-paliad-327 §3a)
|
||||
)
|
||||
```
|
||||
|
||||
No new column. No materialised view. The EXISTS subquery uses the existing `sequencing_rules.parent_id` index. At today's scale (226 rules) it's cheap; at 10× scale still fine (parent_id is indexed; child lookup is index-only scan).
|
||||
|
||||
Mode A's `SearchEvents` (`internal/services/fristenrechner_search_events.go`) and Mode B R4's chip-strip both apply this filter. Terminal leaves (Duplik etc.) stay pickable — they have a non-spawn anchor rule and result in an empty follow-up list, which is honest UX (t-paliad-327 §3a.4, m ratified).
|
||||
|
||||
### §2.3 Scenario state SSoT — `projects.scenario_flags jsonb`
|
||||
|
||||
Reconfirmed from t-paliad-327 §3.2:
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
```
|
||||
|
||||
Shape:
|
||||
```json
|
||||
{ "with_ccr": true, "with_amend": false, "with_cci": false }
|
||||
```
|
||||
|
||||
Whitelist-validated against the set of flag names appearing in `sequencing_rules.condition_expr` (today: `with_ccr`, `with_amend`, `with_cci`).
|
||||
|
||||
API: `GET /api/projects/{id}/scenario-flags` returns the map; `PATCH /api/projects/{id}/scenario-flags` accepts partial deltas (null deletes a key).
|
||||
|
||||
**Kontextfrei (no project):** stays on localStorage. No DB writes when `project_id IS NULL`.
|
||||
|
||||
**Relationship with `paliad.scenarios`:** complementary, not duplicate. `scenarios.spec.flags[]` (the Litigation Planner Slice D shape) is a *named snapshot*; activating a scenario copies its flag array into `projects.scenario_flags`. Live edits write to `scenario_flags`. `paliad.project_event_choices` (the legacy empty table) is deprecated (§4.3).
|
||||
|
||||
### §2.4a Selection state + detail-level view-mode filter
|
||||
|
||||
m's reframe (14:40): the real ask isn't "rarity" — it's **detail-level control over the timeline**. Every event/rule is a card; the user picks which optional cards belong to *their* scenario; the Verfahrensablauf has a view-mode toggle that controls how much of the picture surfaces.
|
||||
|
||||
m's quote (14:40): *"It is more that I want a grade of detail in our swimlane display […] I want to show them but also be able to 'focus' by not displaying optional things. And we can select these options somehow, for example like we do with the appeal in the Decision dropdown. And if none is selected, none are displayed. We need an option 'Show unselected options' or 'show only selected' or 'mandatory' […] It would be great to basically filter events from the timeline based on whether they are selected in this scenario."*
|
||||
|
||||
The underlying mental model:
|
||||
|
||||
- **Mandatory rules** are always in the scenario. They render in every view-mode. The user cannot deselect them.
|
||||
- **Recommended rules** are *selected by default* in the scenario. The user can deselect them.
|
||||
- **Optional rules** are *not selected by default*. The user opts in via the same UI mechanism that already exists for `with_ccr` / `with_amend` (a chip / dropdown / "Aufnehmen" CTA per rule).
|
||||
- **Conditional rules** (with `condition_expr`) are gated by scenario flags first, then by selection (a conditional rule whose flag is on still respects its priority's default selection rule).
|
||||
|
||||
The Verfahrensablauf gets a three-way **detail-level toggle** (§3.3a):
|
||||
|
||||
- **Nur Pflicht (Mandatory only)** — only `priority='mandatory'` cards.
|
||||
- **Gewählt (Selected)** — mandatory + every rule the scenario has explicitly selected. Default.
|
||||
- **Alle Optionen (All considered)** — every rule that *could* belong, including unselected optionals (rendered with a dotted border + "Aufnehmen" CTA) and conditional rules whose flag isn't set (rendered greyed with a "wenn-…" hint).
|
||||
|
||||
#### Schema — no new column on `sequencing_rules`
|
||||
|
||||
The original §2.4a strawman proposed `is_edge_case boolean` as a chain-head flag. m's reframe makes that wrong: **every** optional rule is potentially "rare" depending on the lawyer's scenario; the dimension isn't a property of the rule, it's a property of the scenario.
|
||||
|
||||
Instead, the selection state lives entirely in **`projects.scenario_flags jsonb`** (already on the table from P0, §2.3) with an extended shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"with_ccr": true,
|
||||
"with_amend": false,
|
||||
"with_cci": false,
|
||||
"rule:<uuid_of_recommended_X>": false,
|
||||
"rule:<uuid_of_optional_Y>": true
|
||||
}
|
||||
```
|
||||
|
||||
The flat-map shape stays — entries are either named scenario flags (`with_*`) or per-rule selection deviations (`rule:<uuid>`). Storage only carries **deviations from the priority default**:
|
||||
- `priority='recommended'` is selected-by-default; `rule:X = false` records an explicit deselection.
|
||||
- `priority='optional'` is unselected-by-default; `rule:X = true` records an explicit selection.
|
||||
- `priority='mandatory'` is always selected; trying to store `rule:X = false` is rejected (422 from the PATCH endpoint).
|
||||
|
||||
Whitelist (Q9 catalog) gains a wildcard pattern `rule:<uuid>` — any well-formed UUID matches; the handler validates that the UUID resolves to an active+published rule on the project's proceeding_type before persisting.
|
||||
|
||||
Kontextfrei (no project): localStorage stores the same shape under a per-PT key (`scenario:upc.inf.cfi`). Different PT → different stored selection set; this matches how kontextfrei users explore.
|
||||
|
||||
#### Visual — generalising the CCR dropdown to per-rule chips
|
||||
|
||||
The existing `with_ccr` / `with_amend` checkboxes are *coarse* scenario flags. The new per-rule selection is *fine-grained* but uses the same UI vocabulary:
|
||||
|
||||
- **Selected rule**: solid card, normal background. (Identical to today's mandatory render.)
|
||||
- **Selected optional that's deselectable**: solid card with a small `[Entfernen]` chip; click removes from `selected_optionals` (writes `rule:X = false`).
|
||||
- **Unselected optional (default state in "Alle Optionen" mode)**: dotted-border card, muted background, `[Aufnehmen]` CTA. Click writes `rule:X = true`.
|
||||
- **Conditional rule whose flag isn't set**: greyed card with a "Aktivieren via 'Mit Widerklage' im Szenario" hint; clicking the hint scrolls to the scenario-flags strip.
|
||||
- **Cross-party** (§2.4): orthogonal — applies its `Gegenseitig` badge and muted style on top of whichever state above.
|
||||
|
||||
Each card thus carries up to four orthogonal axes of display state — priority, selection, conditional-gate, cross-party. The 4 axes compose; no axis dominates.
|
||||
|
||||
#### Subtree semantics — implicit via parent chain
|
||||
|
||||
When a chain head is deselected (e.g. R.109.1 Übersetzungsantrag = `false`), its descendants in the parent_id tree (R.109.4 Mitteilung etc.) **inherit the deselected state for display** without needing their own entries in `selected_optionals`. The tree renderer walks the chain; if any ancestor is unselected, the descendant doesn't render in "Gewählt" mode. In "Alle Optionen" mode, the whole subtree renders greyed under the deselected head.
|
||||
|
||||
If a descendant has its own explicit `rule:X = true` entry, that overrides the ancestor — the user has explicitly pulled this leaf into their scenario despite not selecting the parent. Edge case; documented but no special UI affordance.
|
||||
|
||||
#### Default population on project creation
|
||||
|
||||
When a project is created with `proceeding_type_id = X`, the server seeds `scenario_flags = {}`. Nothing in the map. The tree renderer computes per-rule selection on-the-fly from priority + scenario_flags entries. No upfront write-storm of "rule:X = true" for every recommended rule — only deviations land in storage.
|
||||
|
||||
#### Why this beats the `is_edge_case` boolean
|
||||
|
||||
- **No new column.** All state lives in the existing `projects.scenario_flags jsonb` from P0.
|
||||
- **Generalised.** Every optional rule is selectable, not just the few flagged as "rare". m's "sequence density is very high" complaint is solved by the user controlling which optionals belong to *their* scenario, rather than the editorial process having to decide globally which rules deserve dotted-border treatment.
|
||||
- **Composable with condition_expr.** A conditional rule is selectable when its flag is on; the selection state is independent of the flag state.
|
||||
- **Matches m's stated UX prior art.** The CCR dropdown pattern *is* the model; we're just generalising it from 3 named flags to N per-rule selections.
|
||||
|
||||
### §2.4 Cross-party display
|
||||
|
||||
From t-paliad-327 §2 (m ratified on-recommendation all 8 sub-Qs):
|
||||
|
||||
- Backend: drop the perspective WHERE clause in `queryFollowUpRows`; return all rows; add server-computed `is_cross_party` boolean.
|
||||
- UI: render cross-party rows with a `Gegenseitig` badge, muted/greyed style, unchecked by default, date visible.
|
||||
- Write-back: cross-party rows are **unconditionally excluded** from the project-deadline bulk insert, even if the user manually checks the box.
|
||||
|
||||
Composite `condition_expr` (and-of-flags) — checkbox is read-only in the result view; Verfahrensablauf is the canonical toggle surface for individual flags.
|
||||
|
||||
Sync: `document.dispatchEvent(new CustomEvent('scenario-flag-changed', { detail: { flag, value } }))`. Single-tab v1; cross-tab in Akte mode deferred.
|
||||
|
||||
---
|
||||
|
||||
## §3 Tier 2 — surface decisions
|
||||
|
||||
### §3.1 Appeal re-split: revert upc.apl.unified → merits/cost/order (m's Q5 divergent pick)
|
||||
|
||||
**m's call (2026-05-27):** *"Reverse the unification as suggested in 3. They are different proceedings, I only wanted the approach to be unified in the 'determinator' — but they are actually different proceedings!"*
|
||||
|
||||
The current state (mig 096 unified the appeal track):
|
||||
- id=160 `upc.apl.unified` is `is_active=true`, holds 16 rules.
|
||||
- id=11 `upc.apl.merits` is `is_active=false`.
|
||||
- id=19 `upc.apl.cost` is `is_active=false`.
|
||||
- id=20 `upc.apl.order` is `is_active=false`.
|
||||
- 4 spawn rules point at id=11 (inactive) — looks like the R3 bug but is actually correctly aimed at merits since cost+order arrive differently (athena R3 partially mis-classified the situation).
|
||||
- Event codes already carry the split prefix: `upc.apl.{merits,cost,order}.*`. 16 events split cleanly into 7 merits + 2 cost + 7 order.
|
||||
|
||||
The migration:
|
||||
|
||||
```sql
|
||||
-- Mig P1: re-activate the three discrete appeal PTs and retire the unified row.
|
||||
UPDATE paliad.proceeding_types SET is_active = true WHERE id IN (11, 19, 20);
|
||||
UPDATE paliad.proceeding_types SET is_active = false WHERE id = 160;
|
||||
|
||||
-- Mig P1: re-target each rule whose proceeding_type_id is currently 160
|
||||
-- to the right reactivated PT based on its event_code prefix.
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET proceeding_type_id = 11
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id = sr.procedural_event_id
|
||||
AND sr.proceeding_type_id = 160
|
||||
AND pe.code LIKE 'upc.apl.merits.%';
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET proceeding_type_id = 19
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id = sr.procedural_event_id
|
||||
AND sr.proceeding_type_id = 160
|
||||
AND pe.code LIKE 'upc.apl.cost.%';
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET proceeding_type_id = 20
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id = sr.procedural_event_id
|
||||
AND sr.proceeding_type_id = 160
|
||||
AND pe.code LIKE 'upc.apl.order.%';
|
||||
|
||||
-- 4 spawn FKs: stay at id=11 (merits) for inf/rev/dmgs; update upc.pi.cfi's
|
||||
-- spawn to point at id=20 (order) — appeals against PI orders go to the
|
||||
-- orders track, not merits.
|
||||
UPDATE paliad.sequencing_rules
|
||||
SET spawn_proceeding_type_id = 20
|
||||
WHERE is_spawn AND procedural_event_id = (
|
||||
SELECT id FROM paliad.procedural_events WHERE code = 'upc.pi.cfi.appeal_spawn'
|
||||
);
|
||||
-- The other 3 spawn rules (inf/rev/dmgs) keep spawn_proceeding_type_id = 11
|
||||
-- (correct after re-activation).
|
||||
```
|
||||
|
||||
**Determinator UX preserved.** `internal/services/proceeding_mapping.go` (t-paliad-204 S1) keeps its single "Berufung" front door. The mapping fans out to id=11/19/20 based on what's being appealed (judgment / cost decision / order). No user-facing routing change. The change is purely structural.
|
||||
|
||||
**Active scenarios / projects pointing at id=160:** none (`paliad.scenarios` and `paliad.projects.active_scenario_id` both empty per athena §0; only 6 projects have any `proceeding_type_id` set and none of them is 160). Zero data migration on the project side.
|
||||
|
||||
### §3.2 Empty PTs — show with "Keine Regeln gepflegt" badge
|
||||
|
||||
Per m's Q6 — option 2 with a follow-on editorial note ("We need to publish rules then... but yeah, show with the badge for now"):
|
||||
|
||||
Picker query for `/api/tools/proceeding-types` gains a flag-not-filter:
|
||||
|
||||
```sql
|
||||
SELECT pt.*,
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules sr
|
||||
WHERE sr.proceeding_type_id = pt.id
|
||||
AND sr.is_active AND sr.lifecycle_state = 'published'
|
||||
) AS has_rules
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.is_active AND pt.kind = 'proceeding';
|
||||
```
|
||||
|
||||
Frontend renders the chip with a muted/disabled treatment + badge "Keine Regeln gepflegt" when `has_rules = false`. Project creation can still bind to an empty PT (admin override), but Mode A/B/Verfahrensablauf surface a clear "this proceeding has no seeded rules yet" message.
|
||||
|
||||
Editorial follow-up: m publishes rules for the 6 empty PTs (`upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`, `upc.epo.review`, `upc.pl.cfi`) over time; each new published rule auto-removes the badge for its PT. Not blocking this design.
|
||||
|
||||
### §3.3 Entry A — extend /tools/verfahrensablauf
|
||||
|
||||
Per m's Q7. The existing `/tools/verfahrensablauf` page (used by `frontend/src/client/verfahrensablauf.ts` + shared `views/verfahrensablauf-core.ts`) already serves the pick-a-PT shape. Extend it to:
|
||||
|
||||
- Render the parent_id chain as a **collapsible tree** (top-down chronological). Same data shape as §1.2's ASCII trees.
|
||||
- Expose **optionals + conditionals as toggleable checkboxes** in the tree itself. Ticking writes via `PATCH /api/projects/{id}/scenario-flags` (Akte mode) or localStorage (kontextfrei).
|
||||
- Reflect cross-party rows with the same muted style as §2.4 (Gegenseitig badge).
|
||||
- Spawn rows render as **leaf with edge annotation** (⇲ Berufungsverfahren öffnen) and a "create child case" CTA in Akte mode.
|
||||
- Optionally: a "Zur Frist-Ansicht" deeplink on each tree node → opens Mode B Fristenrechner with that event pre-locked as the trigger.
|
||||
|
||||
Backend: extend `/api/tools/fristenrechner` (the proceeding-type fan-out endpoint) to return a tree-shaped payload (`parent_id` resolved into nested children). New handler param or new endpoint `/api/tools/verfahrensablauf/tree?proceeding_type_code=X&project=Y`.
|
||||
|
||||
The legacy `/tools/fristenrechner?legacy=1` Procedure-mode page deprecates naturally — same scope, replaced by this Entry A view.
|
||||
|
||||
### §3.3a Verfahrensablauf view-mode toggle
|
||||
|
||||
A three-way segmented control above the tree at the Verfahrensablauf surface:
|
||||
|
||||
```
|
||||
┌─ Anzeige ──────────────────────────────────────┐
|
||||
│ ( ) Nur Pflicht (•) Gewählt ( ) Alle Optionen │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
- **Nur Pflicht**: only `priority='mandatory'` cards render. Tightest view.
|
||||
- **Gewählt** (default): mandatory + every rule that resolves to "selected" given current scenario state (mandatory always; recommended unless explicitly deselected via `rule:X = false`; optional only if explicitly selected via `rule:X = true`; conditional only if its flag predicate holds AND the priority-default-or-deviation puts it in the selected set). Honest summary of what *this* lawyer has chosen for *this* project.
|
||||
- **Alle Optionen**: everything that could belong, with unselected optionals rendered with the dotted-border + `[Aufnehmen]` CTA, and conditional rules whose flag isn't set rendered greyed with the activation hint.
|
||||
|
||||
**Persistence**: per-user, per-browser via `localStorage` under key `verfahrensablauf:view_mode`. Not project-scoped — the same user looking at two different projects probably wants the same verbosity. Not in `scenario_flags` either — view-mode is a UI preference, not a scenario fact. No new schema; no API; no migration.
|
||||
|
||||
Cross-surface sync: the **Mode B result view** does NOT carry its own view-mode toggle. It always renders in "Gewählt" semantics (mandatory + selected). Rationale: Mode B locks a single trigger event and lists its follow-ups; the lawyer isn't browsing the full ablauf, they're focused on one moment. The view-mode toggle is a Verfahrensablauf-only affordance.
|
||||
|
||||
The view-mode toggle composes with the scenario-flags strip (§2.3). Toggling "Mit Widerklage auf Nichtigkeit" off in "Gewählt" mode removes the CCR conditional branch from view; flipping to "Alle Optionen" re-renders the CCR branch greyed with the activation hint. The user can see what they're *not* currently considering without losing the simplified default view.
|
||||
|
||||
### §3.4 Legacy `/api/tools/event-deadlines` deprecation
|
||||
|
||||
Per m's Q8. Sequence:
|
||||
|
||||
1. **Mig P3 — 73-globals reparenting completes** (§4.2, editorial work). Once `paliad.sequencing_rules WHERE proceeding_type_id IS NULL` is empty, the legacy route has no live data shape it uniquely serves.
|
||||
2. **Code drop:** remove `/api/tools/event-deadlines` route + `EventDeadlineService` + the `deadline_rule_service.go:226-285` label-fallback path + the `ExportService:1680` workbook sheet.
|
||||
3. **Table drop:** `DROP TABLE paliad.trigger_events` (mig P4, §4.3).
|
||||
4. **Snapshot generator:** `cmd/gen-upc-snapshot/main.go` stops reading `paliad.trigger_events`; UPC snapshot for youpc.org only carries the unified rule shape.
|
||||
|
||||
The cleanup is gated on §4.2 completion. If editorial backfill is slow, the route can live behind a `/api/legacy/` prefix until done — but the design assumption is that we close the loop within the slice train.
|
||||
|
||||
---
|
||||
|
||||
## §4 Tier 3 — editorial + cleanup framework
|
||||
|
||||
### §4.1 `condition_expr` grammar formalisation
|
||||
|
||||
Per m's Q9. The grammar:
|
||||
|
||||
```ts
|
||||
type CondExpr =
|
||||
| { flag: KnownFlag } // leaf
|
||||
| { op: 'and' | 'or'; args: CondExpr[] } // composite (recursive)
|
||||
|
||||
type KnownFlag = 'with_ccr' | 'with_amend' | 'with_cci' // closed set; extensible via admin
|
||||
```
|
||||
|
||||
Implementation:
|
||||
|
||||
- A JSON-schema validator in `RuleEditorService.create`/`update` rejects writes that don't match. Today's 18 rules all conform; no data migration.
|
||||
- Known-flag whitelist sourced from a small Go constant + an admin-editable `paliad.scenario_flag_catalog(name, description, added_at)` table — keeps the vocabulary discoverable. (Lightweight ALTER, not a major migration.)
|
||||
- Engine consumer (`pkg/litigationplanner/expr.go`, currently a switch over string literals) gains exhaustive-case enforcement against the same catalog. Linter catches drift between catalog and engine.
|
||||
|
||||
`choices_offered` and `applies_to_target` (athena R11) — same grammar treatment in a separate ticket (not blocking this revision). Document their 3 known shapes (`appellant`, `skip`, `include_ccr`) in code comments meanwhile.
|
||||
|
||||
### §4.2 Editorial backfill workflow — `/admin/procedural-events` parent-NULL filter
|
||||
|
||||
Per m's Q10:
|
||||
|
||||
- Add filter chip "parent: nicht gesetzt" to the admin list at `/admin/procedural-events`. The filter URL `?parent_filter=null` (or similar).
|
||||
- Track completion per PT via the existing gap-map query (athena §3.1) — show as a progress bar in the admin shell ("upc.inf.cfi: 4/4 roots OK" / "de.inf.lg: 2/5 roots remain").
|
||||
- For the 73 globals: a separate filter `?orphan=true` showing only `proceeding_type_id IS NULL` rules. m clicks each, assigns a PT + parent rule via the editor.
|
||||
- Each save flips lifecycle_state to draft (unchanged from existing editor flow); m publishes a batch when satisfied with a PT.
|
||||
|
||||
No new code surface — the existing admin list + editor handle everything once the filter is added.
|
||||
|
||||
This is editorial work, not coder work. The design captures the framework; m drives the content at his own cadence. No mig is gated on completion (the parent-NULL filter is a feature add; rules stay valid in their current shape during the walk).
|
||||
|
||||
#### §4.2.1 Worked editorial example — R.109 translation chain
|
||||
|
||||
m flagged this case (14:35) as a concrete instance of malformed parent-chain shape. The current data for `upc.inf.cfi`:
|
||||
|
||||
| rule | event | current parent | current primary_party | correct shape |
|
||||
|---|---|---|---|---|
|
||||
| `RoP.109.1` | `upc.inf.cfi.translation_request` (Antrag auf Simultanübersetzung) | upc.inf.cfi root (Mündliche Verhandlung) | both | parent stays at MV; flagged optional (default-unselected) |
|
||||
| `RoP.109.4` | `upc.inf.cfi.interpreter_cost` (Mitteilung Dolmetscherkosten) | upc.inf.cfi root (Mündliche Verhandlung) — **WRONG** | court — **WRONG** | parent = R.109.1; primary_party = both (parties give the Mitteilung, not the court); condition_expr = `{"flag": "with_interpreter_denied"}` |
|
||||
| `RoP.109.5` | `upc.inf.cfi.translations_lodge` (Übersetzungen einreichen) | upc.inf.cfi root | both | parent = R.109.1 (lodging follows the request); priority stays mandatory but conditional via `{"flag": "with_translation_granted"}` |
|
||||
|
||||
Two new scenario flags introduced (`with_interpreter_denied`, `with_translation_granted`) get added to the `scenario_flag_catalog` (§4.1) when the editor saves these rules.
|
||||
|
||||
Editorial walk for m:
|
||||
1. Open `/admin/procedural-events?orphan=false&parent_filter=null&proceeding_type=upc.inf.cfi`.
|
||||
2. Find R.109.1, R.109.4, R.109.5 — they sit at depth 1 under the root.
|
||||
3. Edit R.109.4: set `parent_id = <R.109.1's id>`; set `primary_party = both`; set `condition_expr = {"flag": "with_interpreter_denied"}`. Save (draft).
|
||||
4. Edit R.109.5: set `parent_id = <R.109.1's id>`; set `condition_expr = {"flag": "with_translation_granted"}`. Save (draft).
|
||||
5. Publish both.
|
||||
6. The catalog accepts the two new flag names; the validator updates.
|
||||
|
||||
Result in the Verfahrensablauf tree (post-fix):
|
||||
|
||||
```
|
||||
upc.inf.cfi root
|
||||
├─ Mündliche Verhandlung (court · M)
|
||||
├─ Antrag auf Simultanübersetzung (RoP.109.1) [both · O]
|
||||
│ ├─ Mitteilung Dolmetscherkosten (RoP.109.4) [both · M · ?with_interpreter_denied]
|
||||
│ └─ Übersetzungen einreichen (RoP.109.5) [both · M · ?with_translation_granted]
|
||||
```
|
||||
|
||||
In **Gewählt** mode without scenario flags: only the root + Mündliche Verhandlung surface. R.109.1 is an unselected optional → hidden. R.109.4 + R.109.5 are conditional + below an unselected ancestor → hidden.
|
||||
|
||||
In **Gewählt** mode after the user clicks `[Aufnehmen]` on R.109.1: R.109.1 appears. R.109.4 still hidden (its flag `with_interpreter_denied` isn't set; the user would need to know the court denied the Antrag, then tick the flag in the Szenario-Flags strip). R.109.5 similarly hidden until `with_translation_granted` is on.
|
||||
|
||||
In **Alle Optionen** mode: every rule renders, conditionals greyed with their flag hint, R.109.1 dotted with `[Aufnehmen]`.
|
||||
|
||||
This is the model in miniature: the editorial fix is data-only (no schema change, just `parent_id` + `condition_expr` + `primary_party` UPDATEs via the editor); the display fix is policy that the existing scenario_flags + view-mode mechanism already supports.
|
||||
|
||||
### §4.3 `paliad.trigger_events` table fate — drop
|
||||
|
||||
Per m's Q11. Sequence (chained to §3.4):
|
||||
|
||||
1. After 73-globals reparented + route dropped + label-fallback ported to `procedural_events.name`:
|
||||
2. `DROP TABLE paliad.trigger_events` (mig P5, last in the train).
|
||||
3. Migrate `cmd/gen-upc-snapshot/main.go` to no longer SELECT from this table.
|
||||
4. Remove the `ref__trigger_events` sheet from `ExportService` workbook output.
|
||||
|
||||
The bigint PK / parallel taxonomy disappears entirely. `procedural_events` (uuid PK) is the only event catalog.
|
||||
|
||||
---
|
||||
|
||||
## §5 Schema delta + migration plan (slice train)
|
||||
|
||||
Six slices, sequential where data-coupled, parallelisable where not. Each slice ships as one or two PRs.
|
||||
|
||||
| Slice | Mig | What ships | Reversible? |
|
||||
|---|---|---|---|
|
||||
| **P0 — Scenario SSoT** | mig 154 | `ALTER TABLE projects ADD COLUMN scenario_flags jsonb`; GET/PATCH endpoints w/ extended whitelist (named flags + `rule:<uuid>` per-rule entries, validated against project's PT rule set); Verfahrensablauf + result-view binding; `scenario_flag_catalog` table (§4.1) | Yes — DROP COLUMN |
|
||||
| **P1 — Appeal re-split** | mig 155 | UPDATE proceeding_types (re-activate 11/19/20, deactivate 160); UPDATE sequencing_rules (rebind 16 rules to merits/cost/order by event_code prefix); UPDATE pi.cfi spawn FK → 20 | Reversible by inverse UPDATEs; documented in down mig |
|
||||
| **S1+S1a from t-paliad-327** | — | Cross-party display backend + frontend; spawn-only picker filter (`sr.is_spawn = false` in SearchEvents) | Yes — code-only |
|
||||
| **P2 — Empty-PT badge** | — | `has_rules` flag on /api/tools/proceeding-types; frontend muted-chip rendering | Yes — code-only |
|
||||
| **P3 — Entry A (Verfahrensablauf tree)** | — | Tree endpoint + tree UI in /tools/verfahrensablauf; three-way view-mode toggle (localStorage); per-rule `[Aufnehmen]`/`[Entfernen]` chips wire to scenario_flags `rule:<uuid>` entries; subtree-hide-on-unselected-ancestor render logic | Yes — code-only |
|
||||
| **P4 — Editorial walk (73 globals)** | — | parent-NULL filter on /admin/procedural-events; editorial work by m (no coder task per se) | Trivially reversible |
|
||||
| **P5 — trigger_event_id deprecation** | mig 156 | DROP `/api/tools/event-deadlines`; DROP `EventDeadlineService`; port label-fallback in deadline_rule_service.go; remove ref__trigger_events sheet; `ALTER TABLE sequencing_rules DROP COLUMN trigger_event_id`; `DROP TABLE trigger_events`; condition_expr write-time validator | Last; downgrade requires re-adding column + re-populating — irreversible in practice |
|
||||
|
||||
Constraint: **P5 is gated on P4 completion** (no rules can have NULL proceeding_type_id when DROP runs). All other slices ship independently.
|
||||
|
||||
Ordering rationale:
|
||||
- P0 unblocks the Fristenrechner-side bugs immediately (no waiting on appeal-split editorial).
|
||||
- P1 is data-only, low risk, can land in parallel with P0.
|
||||
- S1+S1a are code-only follow-ons to P0 (same scenario-flag plumbing).
|
||||
- P2 ships once P1 lands (re-activated PTs need badge support too).
|
||||
- P3 builds on P2 + the tree endpoint; depends on P0 for flag persistence.
|
||||
- P4 is m's editorial work — duration depends on m's cadence, not coder velocity.
|
||||
- P5 is the cleanup at the end. Only safe when P4 is done.
|
||||
|
||||
---
|
||||
|
||||
## §6 Entry A UI spec (sequence-from-proceeding-type)
|
||||
|
||||
Live URL: `/tools/verfahrensablauf?project=<id>&proceeding_type=upc.inf.cfi`.
|
||||
|
||||
### §6.1 Layout
|
||||
|
||||
```
|
||||
┌─ Akte / kontextfrei ─────────┐ ┌─ Verfahren ──┐ ┌─ Anzeige ──────────────────────────┐
|
||||
│ HL-2024-001 ▼ │ ohne Akte │ │ upc.inf.cfi ▼│ │ Nur Pflicht ⦿ Gewählt ○ Alle Optionen │
|
||||
└──────────────────────────────┘ └──────────────┘ └────────────────────────────────────┘
|
||||
|
||||
┌─ Szenario-Flags ──────────────────────────────────┐
|
||||
│ ☑ Mit Widerklage auf Nichtigkeit (with_ccr) │
|
||||
│ ☐ Mit Antrag auf Patentänderung R.30 (with_amend) │
|
||||
│ ☐ Mit Widerklage auf Verletzung (with_cci) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ Ablauf ── (view-mode: Gewählt) ───────────────────────────────────┐
|
||||
│ 📥 Klageerhebung [claimant · mandatory] │
|
||||
│ ├─ Klageerwiderung [defendant · mandatory] │
|
||||
│ │ └─ Replik [claimant · M · ?with_ccr]│
|
||||
│ │ └─ Duplik [defendant · M · ?with_ccr]│
|
||||
│ ├─ Widerklage auf Nichtigkeit [defendant · O · ?with_ccr][Entfernen]│ ← selected optional
|
||||
│ │ └─ Erwiderung auf CCR [claimant · M · ?with_ccr]│
|
||||
│ │ └─ Replik auf Erw. CCR [defendant · M · ?with_ccr][Gegenseitig]│
|
||||
│ │ └─ Duplik auf Replik [claimant · M · ?with_ccr]│
|
||||
│ └─ ⇲ Berufungsverfahren öffnen [SPAWN → upc.apl.merits] │
|
||||
│ 🏛️ Zwischenanhörung [court · mandatory] │
|
||||
│ 🏛️ Mündliche Verhandlung [court · mandatory] │
|
||||
│ ⚖️ Endentscheidung [court · mandatory] │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
↓ (user flips view-mode to "Alle Optionen")
|
||||
|
||||
┌─ Ablauf ── (view-mode: Alle Optionen) ─────────────────────────────┐
|
||||
│ 📥 Klageerhebung [claimant · mandatory] │
|
||||
│ ├─ ┄ Vorl. Einwendungen [defendant · O] [Aufnehmen]┄ │ ← unselected, dotted
|
||||
│ ├─ Klageerwiderung [defendant · mandatory] │
|
||||
│ ├─ Widerklage auf Nichtigkeit [defendant · O · ?with_ccr][Entfernen]│
|
||||
│ ├─ ┄ Antrag auf Patentänderung [O · ?with_amend] greyed │ ← flag not set
|
||||
│ │ └─ wenn 'Mit Patentänderung' im Szenario aktiv │
|
||||
│ ├─ ┄ Antrag auf Simultanübersetzung [O] [Aufnehmen]┄ │ ← post-§4.2.1
|
||||
│ │ ├─ ┄ Mitteilung Dolmetscherkosten [M · ?with_interpreter_denied]│
|
||||
│ │ └─ ┄ Übersetzungen einreichen [M · ?with_translation_granted]│
|
||||
│ ├─ ┄ Antrag CMO-Überprüfung [both · O] [Aufnehmen]┄ │
|
||||
│ ├─ ┄ Antrag Folgenanordnungen R.118(4) [both · O] [Aufnehmen]┄ │
|
||||
│ └─ ⇲ Berufungsverfahren öffnen [SPAWN → upc.apl.merits] │
|
||||
│ 🏛️ ... │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### §6.2 Behaviour
|
||||
|
||||
- **Project picker (Step 0)** unchanged from Fristenrechner.
|
||||
- **Proceeding-type picker** chips → switching re-fetches the tree.
|
||||
- **View-mode toggle (§3.3a)** — three-way segmented control (Nur Pflicht / Gewählt / Alle Optionen). State in `localStorage["verfahrensablauf:view_mode"]`. Default = "Gewählt". Re-renders the tree on toggle; no network call.
|
||||
- **Szenario-Flags strip** reads/writes `projects.scenario_flags` (Akte) or localStorage (kontextfrei). Same `scenario-flag-changed` CustomEvent as Mode B's result view — both surfaces stay in sync. Flag entries (`with_ccr` etc.) live alongside per-rule entries (`rule:<uuid>`) in the same jsonb.
|
||||
- **Per-rule selection chips** — every non-mandatory rule's card carries `[Aufnehmen]` (unselected → tick selects) or `[Entfernen]` (selected → tick deselects). The handler PATCHes `projects.scenario_flags` with `{ "rule:<uuid>": true|false }` and fires the same `scenario-flag-changed` event.
|
||||
- **Subtree hide-on-deselect** — when a chain head (any rule with children via `parent_id`) is unselected in "Gewählt" mode, its descendants don't render. The tree walker checks each rule's full ancestor chain; any unselected ancestor hides the descendant. In "Alle Optionen" mode, descendants render greyed under the unselected ancestor.
|
||||
- **Cross-party rows** render with `Gegenseitig` badge, muted style (same as Mode B result view §2.4). Composes with selection state and view-mode independently.
|
||||
- **Spawn rows** render as leaves with the ⇲ symbol + "Neues Verfahren öffnen" CTA (Akte mode only; kontextfrei shows the badge without the CTA). Spawn rows ignore selection state — they always render in "Gewählt" + "Alle Optionen" modes since they represent a possible next-procedure rather than an in-scenario deadline.
|
||||
- **Empty PT** (the 6 unruled): tree area renders an inline "Für dieses Verfahren sind noch keine Regeln gepflegt" message + a link to /admin if the user is admin.
|
||||
- **Deeplink to Mode B:** each tree node has a "Frist berechnen" link that opens `/tools/fristenrechner?event=<code>&trigger_date=…&project=…`.
|
||||
|
||||
### §6.3 Backend
|
||||
|
||||
New handler: `GET /api/tools/verfahrensablauf/tree?proceeding_type=upc.inf.cfi&project=<id>` returns:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"proceeding_type": { "code": "upc.inf.cfi", "name_de": "...", "name_en": "..." },
|
||||
"scenario_flags": { "with_ccr": true, "with_amend": false },
|
||||
"tree": [
|
||||
{
|
||||
"rule_id": "...", "event_code": "upc.inf.cfi.soc",
|
||||
"name_de": "Klageerhebung", "primary_party": "claimant",
|
||||
"priority": "mandatory", "has_condition": false, "is_spawn": false,
|
||||
"is_cross_party": false,
|
||||
"children": [
|
||||
{ "rule_id": "...", "event_code": "upc.inf.cfi.sod", ... , "children": [...] },
|
||||
...
|
||||
]
|
||||
},
|
||||
... // chain-anchored roots
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The tree is the result of walking `parent_id` recursively from the PT's root rules (those with `parent_id IS NULL` for this PT). Computed via one recursive CTE; cached per-PT (the tree shape changes only on rule edits).
|
||||
|
||||
`is_cross_party` is computed against `projects.our_side` (Akte mode) or the request's `?party=` query param (kontextfrei).
|
||||
|
||||
---
|
||||
|
||||
## §7 Entry B UI spec — reaffirms shipped Fristenrechner Mode A+B
|
||||
|
||||
Mode A (`/tools/fristenrechner?mode=search`) and Mode B (`?mode=wizard`) — both shipped via t-paliad-322 S1-S6. Surgical follow-ons from t-paliad-327 design (§0.2):
|
||||
|
||||
- Mode A search: add `AND sr.is_spawn = false` to `SearchEvents` WHERE block + add the derived-trigger filter `EXISTS (non-spawn child)` from §2.2. Compiled together as one PR (S1+S1a).
|
||||
- Mode B R4 chip-strip: identical filter on the wizard's event-pool query.
|
||||
- Result view: stop filtering follow-ups by party server-side (§2.4); render cross-party with badge.
|
||||
- Scenario flag binding: result-view CONDITIONAL group reads/writes `projects.scenario_flags` via the new API (P0). Same CustomEvent sync as Entry A.
|
||||
|
||||
No layout changes. The mode tabs (⚡ Direkt suchen / 🧭 Geführt) stay as today. The 3rd entry path is Entry A on the verfahrensablauf page — not a Mode C.
|
||||
|
||||
---
|
||||
|
||||
## §8 Worked examples
|
||||
|
||||
### §8.1 Entry A — claimant on HL-2024-001 (upc.inf.cfi, with_ccr=true)
|
||||
|
||||
User opens `/tools/verfahrensablauf?project=HL-2024-001&proceeding_type=upc.inf.cfi`.
|
||||
|
||||
- Project context loads. `scenario_flags = {with_ccr: true}`.
|
||||
- Tree GET returns the §1.2 shape, with conditional rules' `has_condition` flagged.
|
||||
- UI renders: top-level SoC anchor → branches. The CCR branch is fully expanded because `with_ccr=true`. The R.30 amend branch renders but conditionals are greyed (with_amend=false).
|
||||
- User clicks "Mit Antrag auf Patentänderung R.30" in the Szenario-Flags strip.
|
||||
- Frontend fires `PATCH /api/projects/HL-2024-001/scenario-flags { with_amend: true }`. Server stores. CustomEvent dispatches.
|
||||
- Tree re-renders: R.30 amend branch ungreys; conditional rules become live.
|
||||
- User scrolls to "Erwiderung auf CCR" → clicks "Frist berechnen" → deeplinks to Mode B with `event=upc.inf.cfi.def_to_ccr&trigger_date=<today>&project=HL-2024-001`.
|
||||
- Mode B result view loads. Cross-party RoP.029.d (defendant Replik) shows with `Gegenseitig` badge.
|
||||
|
||||
### §8.2 Entry B — Mode A search after picker filter
|
||||
|
||||
User types "Berufung" in Mode A.
|
||||
|
||||
- Backend SQL (post-§2.2 + post-spawn filter):
|
||||
```sql
|
||||
WHERE pe.name % 'Berufung' OR pe.code % 'Berufung'
|
||||
AND sr.is_active AND sr.is_spawn = false
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules child
|
||||
WHERE child.parent_id = sr.id AND child.is_active AND NOT child.is_spawn
|
||||
)
|
||||
```
|
||||
- Returns: real triggers in the appeal track (`upc.apl.merits.notice`, `upc.apl.merits.grounds`, `upc.apl.order.with_leave`, etc. — post-Q5 split). Does NOT return: `upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn` (spawn-only) or terminal leaves (no children).
|
||||
|
||||
User picks `upc.apl.merits.notice` → result view loads its follow-ups. Tree renders cleanly because the Q5 split gave merits its own chain root.
|
||||
|
||||
### §8.3 Editorial flow — m reparents a legacy global
|
||||
|
||||
m opens `/admin/procedural-events?orphan=true`. Sees the 73-row list.
|
||||
|
||||
- m clicks row "Antrag auf Verlängerung der Klagefrist" (one of the legacy globals with `proceeding_type_id NULL`).
|
||||
- Editor opens. m assigns `proceeding_type_id = upc.inf.cfi` and `parent_id = <RoP.013.1 soc rule>`.
|
||||
- Save. Rule lifecycle flips to draft. m clicks Publish.
|
||||
- The rule now sits under upc.inf.cfi's tree as a hop-1 child of SoC. Mode A picker EXISTS check passes for SoC (was already passing); the tree gains one more chip.
|
||||
- 72 globals to go. m walks at own cadence; no coder time blocked.
|
||||
|
||||
---
|
||||
|
||||
## §9 Out of scope
|
||||
|
||||
- **Calculator (`pkg/litigationplanner.CalculateRule`).** Working as designed.
|
||||
- **Holiday / working-day logic.** Out of scope.
|
||||
- **`choices_offered` + `applies_to_target` formalisation** (athena R11). Same shape as condition_expr would warrant — separate ticket once condition_expr formalisation ships.
|
||||
- **Adding new proceeding_types.** The 23 are stable; editorial work fills the 6 unruled ones.
|
||||
- **DE-side spawn edges** (LG → OLG → BGH as spawns instead of separate projects). Possible v2; not driven by current pain.
|
||||
- **AI-extracted deadlines from documents.** Deferred per memory `b6a11b55…`.
|
||||
- **Cross-tab scenario-flag sync in Akte mode.** Single-tab v1; SSE/WebSocket if it matters later.
|
||||
- **`event_kind` ENUM-ing** (athena R10). Cosmetic; vocab is stable.
|
||||
|
||||
---
|
||||
|
||||
## §10 m's decisions (2026-05-27)
|
||||
|
||||
All 12 questions answered via `AskUserQuestion` on 2026-05-27 ~13:55 (3 batches of 4). 11 picks on-recommendation; Q5 diverged with verbatim reasoning. Plus 8 pre-ratified picks from t-paliad-327 carried forward (§0.2).
|
||||
|
||||
### Tier 1 — model decisions
|
||||
|
||||
- **Q1 (Trigger link canonical): `parent_id` wins, deprecate `trigger_event_id`.** [= recommendation] **Locks §2.1.** Drop the column after backfill completes.
|
||||
- **Q2 (73 legacy globals fate): Reparent onto PT chains via editorial walk.** [= recommendation] **Locks §4.2.** m drives the walk at admin /admin/procedural-events; the orphan filter is the only new UI surface.
|
||||
- **Q3 (Trigger discoverability): Derive from data.** [= recommendation] **Locks §2.2.** EXISTS subquery on parent_id; no new column, no view.
|
||||
- **Q4 (Scenario SSoT shape): `projects.scenario_flags jsonb`.** [= recommendation; confirms t-paliad-327 design under wider scrutiny] **Locks §2.3.**
|
||||
|
||||
### Tier 2 — surface decisions
|
||||
|
||||
- **Q5 (Appeal taxonomy): Reverse the unification — split upc.apl.unified back into merits/cost/order.** [≠ recommendation; m picked option 3, "reverse the unification"] m's verbatim:
|
||||
> yes, reverse the unification as suggested in 3. They are different proceedings, I only wanted the approach to be unified in the "determinator" - but they are actually different proceedings!
|
||||
**Updates §1.4 + §3.1.** Mig P1 re-activates id=11/19/20, retires id=160, rebinds 16 rules by event_code prefix, retargets the pi.cfi spawn FK to id=20. Determinator routing layer (proceeding_mapping.go) keeps the single "Berufung" front door but fans out to the 3 PTs.
|
||||
- **Q6 (Empty PTs): Show with "Keine Regeln gepflegt" badge for now.** [= recommendation; option 2] m's note: "We need to publish rules then... but yeah, show with the badge for now." **Locks §3.2.** Editorial follow-up is m's; not blocking the design.
|
||||
- **Q7 (Entry A location): Fold into /tools/verfahrensablauf.** [= recommendation] **Locks §3.3 + §6.**
|
||||
- **Q8 (Legacy /event-deadlines route): Drop after Tier 1 + 73-globals reparenting.** [= recommendation] **Locks §3.4. Gated on §4.2 completion.**
|
||||
|
||||
### Tier 3 — editorial + cleanup framework
|
||||
|
||||
- **Q9 (condition_expr grammar): Lock to `{flag: "X"} | {op: "and"|"or", args: [...]}`.** [= recommendation] **Locks §4.1.** Write-time JSON-schema validator + known-flag catalog table.
|
||||
- **Q10 (Editorial backfill workflow): Admin /admin/procedural-events with parent-NULL filter.** [= recommendation] **Locks §4.2.** No new UI surface beyond the filter chip.
|
||||
- **Q11 (`trigger_events` table fate): Drop after route is gone.** [= recommendation] **Locks §4.3.** Sequenced as Mig P5, last in the slice train.
|
||||
- **Q12 (Visual format): ASCII trees per PT + Mermaid for spawn edges.** [= recommendation] **Locks §1.2 + §1.3.**
|
||||
|
||||
### 10.0a Post-ratification additions (m, 2026-05-27 14:34–14:40)
|
||||
|
||||
After the §10 main grilling, m added three directions on top of the ratified design. None re-opened a Tier 1 decision; all extended the Verfahrensablauf surface.
|
||||
|
||||
- **Selection state + detail-level filter (m 14:40, supersedes earlier "rarity" framing).** Every optional rule becomes a per-scenario selectable card; selection state lives in the existing `projects.scenario_flags jsonb` with extended shape (`{flag: bool, "rule:<uuid>": bool}`). Recommended = default-selected; optional = default-unselected; mandatory = locked. Deviations only land in storage. No new column on `sequencing_rules`. **Locks §2.4a.** Replaces the pre-clarification strawman that proposed `is_edge_case boolean` — m's reframe makes that wrong (rarity is a scenario property, not a rule property).
|
||||
- **View-mode toggle on Verfahrensablauf.** Three-way segmented control: Nur Pflicht / Gewählt / Alle Optionen. Per-user persistence via `localStorage["verfahrensablauf:view_mode"]`. Default "Gewählt". **Locks §3.3a.** Mode B result view does NOT carry the toggle — it's a Verfahrensablauf-only affordance.
|
||||
- **R.109 chain editorial worked example.** m flagged R.109.1 / R.109.4 / R.109.5 as a concrete editorial-backfill case (wrong parent_id, wrong primary_party on R.109.4, missing condition_expr on R.109.4/.5). Folded as **§4.2.1** worked example demonstrating the parent-NULL filter workflow without code change. Two new scenario-flag names introduced (`with_interpreter_denied`, `with_translation_granted`); both land in the `scenario_flag_catalog` (§4.1) at edit time.
|
||||
|
||||
These additions don't change the slice train sequence (§5). They tighten P0 (the `scenario_flags` PATCH endpoint now validates `rule:<uuid>` keys against the project's active rule set) and P3 (Entry A tree now renders the view-mode toggle + per-rule selection chips), but no new mig is added.
|
||||
|
||||
### 10.1 What changed from the strawman as a result
|
||||
|
||||
Beyond §10.0a additions, the Q5 divergence is the only material change:
|
||||
|
||||
- **Mig P1 (appeal re-split)** is now part of the slice train. It was NOT in the strawman; the strawman assumed athena's R3 was a simple FK retarget. m's pick recasts the unification itself as the bug.
|
||||
- §1.4 per-PT table shows 3 separate appeal PT rows (merits/cost/order) instead of one unified row. The 16 rules under id=160 redistribute to id=11/19/20.
|
||||
- §1.3 spawn graph fan-out has merits (3 edges from inf/rev/dmgs) + order (1 edge from pi) as distinct targets instead of all 4 pointing at a single unified row.
|
||||
|
||||
All other §1-§8 sections hold as originally drafted.
|
||||
|
||||
---
|
||||
|
||||
## §11 Synthesis links
|
||||
|
||||
- mBrian: file as `[synthesis]` linked `triggered_by` t-paliad-329; `related_to` athena's assessment (`document-assessment-deadline-system`) + my proceeding_types taxonomy synthesis + Fristenrechner overhaul synthesis + t-paliad-327 follow-up rules synthesis.
|
||||
- Cross-refs: `docs/assessment-deadline-system-2026-05-27.md` (athena), `docs/design-fristenrechner-followup-rules-2026-05-27.md` (atlas, pre-ratified subset), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus, S1-S6 shipped), `docs/design-proceeding-types-taxonomy-2026-05-26.md` (atlas, mig 153 shipped).
|
||||
- Related migrations: 084 (condition_expr backfill), 136 (procedural_events additive), 140 (drop legacy deadline_rules), 145 (`scenarios` table), 153 (proceeding_types.kind).
|
||||
- Coder phase (deferred per inventor SKILL): runs after m ratifies. Slice ordering per §5. NOT cronus (parked) / NOT atlas (inventor). A pattern-fluent Sonnet coder picks up P0 first; P1 + S1/S1a can parallelise; P3 follows; P4 + P5 are gated on each other.
|
||||
510
docs/design-procedures-workflow-tracker-2026-05-27.md
Normal file
510
docs/design-procedures-workflow-tracker-2026-05-27.md
Normal file
@@ -0,0 +1,510 @@
|
||||
# Design — `/tools/procedures` workflow tracker (m/paliad#152)
|
||||
|
||||
**Task:** t-paliad-337
|
||||
**Gitea:** m/paliad#152
|
||||
**Inventor:** atlas (shift-1, fresh — name-recycle, not the atlas from earlier today)
|
||||
**Date:** 2026-05-27
|
||||
**Branch:** `mai/atlas/inventor-extend-tools`
|
||||
**Status:** Draft — coder gate held; m to ratify the remaining open questions via `AskUserQuestion` before any coder shift.
|
||||
|
||||
**Builds on:**
|
||||
- `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus's U0-U4 design, shipped today as `/tools/procedures`)
|
||||
- `docs/design-deadline-system-revision-2026-05-27.md` §3.3 + §3.3a (atlas Phase 2 model layer + view-mode toggle)
|
||||
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26 Mode A+B+result, shipped via t-paliad-322)
|
||||
|
||||
**Reframe note (2026-05-27 21:01):** the first draft of this doc overengineered the surface — three-view toggle, separate compound drawer, separate Konstellationen drawer. m re-anchored: "clean display of timelines that have potential forks the user can select. UX should be key. It should be easy to find your thing." This rewrite collapses to a single canonical shape and folds the zoom / constellation / cross-cut concepts into it. The pre-grilling §13 + the 11-Q batch in §14 of the first draft are gone — superseded by m's 4 answers in §0.2 and the smaller open-question set in §10.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises
|
||||
|
||||
### §0.1 What shipped today and what m hit
|
||||
|
||||
`/tools/procedures` (U0-U4, knuth, m/paliad#151) is a **catalog browser**:
|
||||
- 4 always-visible tabs (Verfahren wählen / Direkt suchen / Geführt / Aus Akte).
|
||||
- Shared filter strip + search box at the top (markup-only in U0).
|
||||
- Two output shapes — TREE (Verfahrensablauf) and LINEAR (Mode A/B result view) — bound to specific entry tabs.
|
||||
|
||||
m's bugs (2026-05-27 20:43 / 20:46):
|
||||
|
||||
1. 4 tabs visible → pre-form leaks across them, page feels like 4 disjoint workflows.
|
||||
2. Result view fires too many rules incl. conditional-flag-off + curie's 7 compound rules.
|
||||
3. Proaktiv/Gericht/Reaktiv columns are a stance grouping, not a sequence anchor.
|
||||
4. No "you are here" marker.
|
||||
5. Sequence isn't visualised as a sequence — flat priority groups, not chained.
|
||||
|
||||
m's reframe (verbatim, 20:43): "view proceedings with all possible constellations and the sequences and determine **where we are** in that sequence and **what steps are coming next** for any given procedural event."
|
||||
|
||||
Tightened by m on 21:01:
|
||||
|
||||
> "clean display of timelines that have potential forks the user can select. procedural_events that act as triggers for mandatory or optional events. And there is a limited type of proceedings — a sequence of the events builds the proceeding. Some aux proceedings, some main… but a lot is connected. UX should be key. It should be easy to find your thing."
|
||||
|
||||
### §0.2 The four m-answers that lock the architecture
|
||||
|
||||
Asked back during the grilling round at 20:57, answered 21:01:
|
||||
|
||||
| | inventor's grilling question | m's answer | what it locks |
|
||||
|---|---|---|---|
|
||||
| 1 | One canonical shape or still 3 views? | "I still want zoomability for one event and all events it triggers. But that can be from within the full timeline/tree as well." | **One canonical view** (full timeline/tree); zoom is an *interaction* on it, not a separate view. The Anchor / Verfahren / Konstellationen toggle is dropped. |
|
||||
| 2 | What's a "fork" — scenario flags only / +optionals / everything? | "c" (everything: flags + optionals + appeal-target + court-set picks) | **Every choice point in the data is a fork.** Optionals (priority='optional') + conditional flags + appeal-target + perspective + court-set scheduling. Inline pickers. |
|
||||
| 3 | "Easy to find" — timeline-as-index / search box / proceeding picker first? | "all of these — text search, filter pills, a display of the resulting proceedings timelines" | **Find = combined affordance.** Text search + filter pills + the displayed result *is* the matched proceeding timelines. The page never has chrome that isn't either the find affordance or the timelines themselves. |
|
||||
| 4 | Aux proceedings inline or drillable? | "inline" | **Aux proceedings draw inline as expandable child timelines** hanging off the spawn point in the parent timeline. The full connected graph is one visible thing. |
|
||||
|
||||
### §0.3 Live data the tracker has to work against
|
||||
|
||||
Verified 2026-05-27 against `paliad.sequencing_rules` (231 published / 242 total):
|
||||
- 110 chained (parent_id not null) — most rules in a chain.
|
||||
- 78 trigger-rooted, 4 spawns (cross-PT), 47 court-set, 18 conditional (6 `with_ccr` / 4 `with_amend` / 4 `with_cci` / 4 compound `with_ccr AND with_amend`).
|
||||
- Biggest single proceeding: `upc.inf.cfi` (50 rules).
|
||||
- ~46 proceeding types total (UPC 35 / DE 5 / EPA 3 / DPMA 3).
|
||||
- `paliad.deadlines` carries both `procedural_event_id` and `sequencing_rule_id` → Akte actuals overlay is a direct join.
|
||||
|
||||
### §0.4 Scope
|
||||
|
||||
**In:** redesign the `/tools/procedures` surface as a single timeline-tree view with inline forks + a combined find affordance.
|
||||
|
||||
**Out:**
|
||||
- Calculator changes.
|
||||
- Editorial backfill (curie's t-paliad-333 owns the 7 compound rules + R.109 chain). This design is *independent* of curie's column-shape work; compound rules surface inline via parent_id like any other rule, with whatever annotation curie ships.
|
||||
- `/admin/procedural-events` write surface.
|
||||
- `/projects/{id}` Verlauf / SmartTimeline — cross-link only.
|
||||
- youpc.org cross-repo / Outlook sync / PDF export.
|
||||
|
||||
---
|
||||
|
||||
## §1 The single canonical shape
|
||||
|
||||
One page. One view. Top section = find affordance. Below = matched proceeding timelines, each as an inline-forked tree, vertically stacked.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ [🔍 Suche: Klageerwiderung_____________________] │
|
||||
│ Forum: [● UPC] [DE] [EPA] [DPMA] │
|
||||
│ Verfahren: [● Verletzung] [● Widerklage] [Berufung] [Nichtigkeit] … │
|
||||
│ Partei: [Klägerseite] [● Beklagtenseite] │
|
||||
│ Akte: HL-2024-001 ▼ Stichtag: 2026-04-01 │
|
||||
│ │
|
||||
│ 2 Verfahren passen — Anker: Klageerwiderung (HL-2024-001) │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.inf.cfi · Verletzungsverfahren UPC ─────────────────────────┐
|
||||
│ │
|
||||
│ ● Klageerhebung (R.13) 2026-01-15 · Klg · M │
|
||||
│ │ │
|
||||
│ ▼ ● Klageerwiderung (R.23.1) 2026-04-01 · Bekl · M │
|
||||
│ │ ━━━━ DU BIST HIER ━━━━ │
|
||||
│ │ Optionen für dieses Ereignis: │
|
||||
│ │ ☑ Widerklage auf Nichtigkeit │
|
||||
│ │ ☐ Antrag Patentänderung (R.30) │
|
||||
│ │ ☐ Vorläufige Einwendungen │
|
||||
│ │ │
|
||||
│ ├─● Replik (R.29.a/b) 2026-06-01 · Klg · M │
|
||||
│ │ ├─● Duplik (R.29.c) 2026-07-01 · Bekl · M │
|
||||
│ │ └─● Replik auf Defence to CCR (R.29.d) 2026-08-01 · Klg · M │
|
||||
│ │ └─● Rejoinder (R.29.e) 2026-09-01 · Bekl · M │
|
||||
│ │ │
|
||||
│ ├─● Widerklage auf Nichtigkeit ✓ │
|
||||
│ │ └─▼ Tochterverfahren upc.rev.cfi ▾ │
|
||||
│ │ │ │
|
||||
│ │ ├─● Antrag Patentänderung (R.50) optional ☐ │
|
||||
│ │ ├─● Hauptverhandlung [Gericht] │
|
||||
│ │ └─● Entscheidung [Gericht] │
|
||||
│ │ │
|
||||
│ └─● Vorläufige Einwendungen ☐ (optional, ausgewählt: nein) │
|
||||
│ │
|
||||
│ ● Mündliche Verhandlung [Gericht bestimmt] │
|
||||
│ │ │
|
||||
│ └─● Urteil [Gericht] │
|
||||
│ └─▼ Berufungsverfahren upc.apl ▸ (auf Endentscheidung) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.ccr.cfi · Widerklage auf Nichtigkeit (Tochter, oben verlinkt) ┐
|
||||
│ … │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
No tabs. No view toggle. The output reacts to the find affordance, the anchor pin, and per-node fork selections.
|
||||
|
||||
### §1.1 The shape's components
|
||||
|
||||
1. **Find header** (sticky at top): search input + filter pills + Akte/date row + a one-line result summary. §2.
|
||||
2. **Timeline-trees** (the page body): one block per matched proceeding, full chain + inline forks + inline aux branches. §3-§5.
|
||||
3. **Anchor pin** (when set): the "DU BIST HIER" band on a specific node, optionally with zoom mode collapsing everything else. §6.
|
||||
|
||||
That's the entire UI surface. No drawers, no separate drillable panes, no constellation viewer. Forks are inline checkboxes; aux proceedings are inline expandable subtrees; zoom is an interaction on the existing rendering.
|
||||
|
||||
---
|
||||
|
||||
## §2 The find affordance
|
||||
|
||||
m's #3 answer makes this load-bearing: text + pills + result-timelines are all the same affordance. As the user narrows, the timelines below filter; as the timelines change, the result-count summary updates; clicking a node in a timeline auto-narrows the filter pills to that proceeding (optional sugar).
|
||||
|
||||
### §2.1 Composition
|
||||
|
||||
| Control | Source | Composes via | Persists in |
|
||||
|---|---|---|---|
|
||||
| Free-text search | input box, debounced 200ms | OR-against (procedural_event.name DE/EN, rule_code, aliases) | `?q=<text>` |
|
||||
| Forum pill row | static enum (UPC/DE/EPA/DPMA), single-select | AND | `?forum=<id>` |
|
||||
| Verfahren pill row | proceeding_type chips, multi-select (deduped from active forum) | AND (any-of) | `?procs=<csv>` |
|
||||
| Partei pill row | claimant / defendant / both / — (or auto from Akte) | AND | `?party=<x>` |
|
||||
| Akte picker | dropdown of user's projects | seeds Verfahren + Partei + scenario_flags + anchor | `?project=<uuid>` |
|
||||
| Stichtag (date) | date input, defaults today | feeds computed dates throughout the timelines | `?trigger_date=<iso>` |
|
||||
|
||||
All controls live in one sticky header. The header keeps its height stable so the timelines below don't reflow on every keystroke.
|
||||
|
||||
### §2.2 Cold open behaviour
|
||||
|
||||
No URL params, no Akte:
|
||||
- Search box empty, all forums neutral, all proceeding pills neutral. Show a curated default of the most-common proceedings: `upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma`. (See Q4 below.)
|
||||
- A hint above the timelines: "Suche oder filtere, um andere Verfahren einzublenden."
|
||||
|
||||
With a `?project=` param: filters pre-fill from the Akte, anchor pins to the latest completed deadline.
|
||||
|
||||
With a `?q=` or `?event=` param: filters pre-fill to match, single matched proceeding renders pinned.
|
||||
|
||||
### §2.3 What the search matches
|
||||
|
||||
Free-text search hits the same corpus the existing `/api/tools/fristenrechner/search?kind=events` endpoint covers — procedural_events by name + code + aliases. Spawn-only events stay filtered out (per atlas P0 §2.2). Hits surface in two ways simultaneously:
|
||||
|
||||
- The matched proceeding(s) render expanded with the hit event(s) anchor-pinned.
|
||||
- A small "Treffer: 3 Ereignisse in 2 Verfahren" summary above the timelines.
|
||||
|
||||
If the user types something narrow enough to match a single event, the page auto-pins that event (auto-anchor). If multiple events match, the user picks via a small dropdown under the search input — picking sets the anchor.
|
||||
|
||||
### §2.4 Why pills, not chips-with-sub-modes
|
||||
|
||||
The shipped 4-tab UI tried to express "what kind of question are you asking" via tabs. m's answer #3 collapses that — the find affordance doesn't care which "kind" of question; it cares about the active filter set. A user with a search + a forum + an Akte set gets the right timelines regardless of which tab they "came from". The mental model is: narrow the set; the timelines arrive.
|
||||
|
||||
---
|
||||
|
||||
## §3 Timelines and forks
|
||||
|
||||
Each matched proceeding renders as one card. Inside the card: the proceeding's name + jurisdiction badge in a thin header strip, then the chain.
|
||||
|
||||
### §3.1 The chain
|
||||
|
||||
Vertical, top-to-bottom = chronological. Each node = one procedural_event (the rule that fires it lives inside). Edges = parent_id. Per node:
|
||||
|
||||
- **Bullet style** by priority: solid filled (mandatory), solid outline (recommended), dotted (optional), dashed (conditional-flag-off and hidden).
|
||||
- **Bullet colour**: priority band — black/grey/blue/light depending on the scale we end up picking. Lime accent (`#c6f41c`) reserved for the anchor pin.
|
||||
- **Inline metadata**: name, rule code, computed date, party badge, priority badge. Stripped to one line.
|
||||
- **Court-set events**: render with `[Gericht bestimmt]` in date column.
|
||||
- **Spawn nodes**: terminate the bullet with `▼ Tochterverfahren <code> ▾` — expandable inline; collapsed by default unless the spawn flag is on. §5.
|
||||
|
||||
### §3.2 Forks — every choice point is one
|
||||
|
||||
A "fork" is anywhere the user can flip the proceeding's shape:
|
||||
|
||||
1. **Scenario flags** (`with_ccr`, `with_amend`, `with_cci`) — currently 3, extensible via curie's `scenario_flag_catalog`.
|
||||
2. **Optional rules** (`priority='optional'`) — each is a "do I do this?" pick.
|
||||
3. **Appeal-target picks** — `applies_to_target` array on appeal proceedings (endentscheidung / kostenentscheidung / anordnung / schadensbemessung / bucheinsicht). Per-card chip group at the appeal root.
|
||||
4. **Perspective** — claimant / defendant per proceeding (mostly comes from Akte's `our_side`, picker overrides).
|
||||
5. **Court-set timing choices** — `choices_offered` JSON on `sequencing_rules` (`appellant` / `include_ccr` / `skip` shapes from einstein). Per-card chip set.
|
||||
|
||||
**Where forks render.** Inline, *on the node where the fork's effect begins.* Not in a top-of-page flag strip (m's bug #5 — sequences should be visualised as sequences; flags above the tree decouple cause from effect).
|
||||
|
||||
Concretely: the `with_ccr` fork renders as a checkbox **on the Klageerwiderung node**, because that's where the user decides "we are filing a Widerklage with our KEW". Toggling it lights up the CCR child branches below. Similarly:
|
||||
|
||||
- `with_amend` renders on the KEW node *and* on the Antrag-Patentänderung node it gates.
|
||||
- `with_cci` renders on the Defence-to-Revocation node.
|
||||
- Optional rules render as a checkbox on their own card.
|
||||
- Appeal-target picks render on the appeal root.
|
||||
|
||||
If multiple forks share a node, they cluster as a small "Optionen für dieses Ereignis" mini-strip *below* the node header:
|
||||
|
||||
```
|
||||
▼ ● Klageerwiderung (R.23.1) 2026-04-01 · Bekl · M
|
||||
│ Optionen:
|
||||
│ ☑ Widerklage auf Nichtigkeit
|
||||
│ ☐ Antrag Patentänderung (R.30)
|
||||
│ ☐ Vorläufige Einwendungen einlegen
|
||||
```
|
||||
|
||||
### §3.3 Default rendering ("Gewählt" semantics)
|
||||
|
||||
Each node renders iff:
|
||||
- It's mandatory (priority='mandatory'), OR
|
||||
- It's selected per current scenario state (priority='recommended' unless explicitly deselected; priority='optional' iff explicitly selected; conditional iff flag is on).
|
||||
- Same as atlas P3's "Gewählt" view-mode.
|
||||
|
||||
Conditional rules whose flag is off **do not render at all** by default. The fork checkbox to *turn the flag on* still appears on the gating node — turning it on causes the dependent branch to render.
|
||||
|
||||
This is m's bug #2 fix: no more dump of every-rule including flag-off conditional. The forks themselves are the affordance that brings hidden branches into view.
|
||||
|
||||
### §3.4 Optional reveal — "Alle Optionen"
|
||||
|
||||
A single toggle at the top of each proceeding card (NOT page-wide):
|
||||
|
||||
```
|
||||
[· Gewählt ·] [ Alle Optionen ]
|
||||
```
|
||||
|
||||
"Alle Optionen" renders every rule including flag-off conditionals (greyed with their flag hint) and unselected optionals (dotted with `[Aufnehmen]` chip). Useful when the user wants to see the whole shape. Per-proceeding so a page with 3 proceedings can have one in "Alle Optionen" mode without affecting the others. State persists in `localStorage` per proceeding code.
|
||||
|
||||
### §3.5 Why dropping "Nur Pflicht"
|
||||
|
||||
Atlas P3's three-way toggle had Nur Pflicht / Gewählt / Alle Optionen. With forks made inline + per-node, "Nur Pflicht" loses load-bearing — it was useful when the page had no fork interactivity (you wanted to dial down clutter). Now the user can just leave all forks off and see the mandatory-only chain in Gewählt mode. The two-way Gewählt ↔ Alle Optionen is enough.
|
||||
|
||||
### §3.6 Cross-party rows
|
||||
|
||||
Per atlas §2.4 / m's lock: every rule for the proceeding renders, with rows where the user is *not* the primary_party muted + carrying a "Gegenseitig" badge. Same treatment in this tracker. Not hidden by perspective; just visually deemphasised.
|
||||
|
||||
---
|
||||
|
||||
## §4 Court-set events & date rendering
|
||||
|
||||
`is_court_set=true` rules don't compute a date — the court picks it on the day. Two display options that interact:
|
||||
|
||||
- Render with `[Gericht bestimmt]` in the date column, no date. Standard.
|
||||
- When the user has scheduled the actual date (an `appointments` row on the project or a manual override), the actual date replaces the badge. Akte-only.
|
||||
|
||||
If the date is "vom Gericht" and matters as a trigger for downstream events, downstream events render `[abhängig von Verhandlung]` instead of a date, and recompute live once the court date is known.
|
||||
|
||||
`choices_offered` per-rule (the 3 known shapes today: `appellant`, `include_ccr`, `skip`) — also inline per-node, treated as forks (§3.2 #5).
|
||||
|
||||
---
|
||||
|
||||
## §5 Aux proceedings inline
|
||||
|
||||
Per m's #4 answer: spawned proceedings draw inline as expandable subtrees, not as drillable separate pages.
|
||||
|
||||
### §5.1 Render
|
||||
|
||||
A spawn node renders as a leaf chip terminating the parent's chain segment:
|
||||
|
||||
```
|
||||
●─● Widerklage auf Nichtigkeit ✓
|
||||
└─▼ Tochterverfahren upc.rev.cfi ▾
|
||||
│
|
||||
├─● Antrag Patentänderung (R.50) optional ☐
|
||||
├─● Hauptverhandlung [Gericht]
|
||||
└─● Entscheidung [Gericht]
|
||||
└─▼ Berufungsverfahren upc.apl ▸
|
||||
```
|
||||
|
||||
- Collapsed by default unless the gating fork is on (e.g. `with_ccr` ticked → CCR's spawn into upc.rev.cfi auto-expands).
|
||||
- Expanding writes nothing — pure UI state in `sessionStorage["procedures:expanded_spawns"]`.
|
||||
- The aux subtree renders with the same node vocabulary as the parent; forks inside the aux are independently editable.
|
||||
- Aux subtrees can themselves have aux subtrees (e.g. CCR → Berufung). Depth is bounded by the data — today 2 levels deep at most.
|
||||
|
||||
### §5.2 Cross-references
|
||||
|
||||
When two paths converge on the same aux proceeding (e.g. CCR triggers from a couple of places), the aux renders inline at the first path's spawn point and renders as a back-reference at subsequent spawn points: `▸ (siehe oben: Tochterverfahren upc.rev.cfi)`. Single source of truth in the rendered tree, even when the graph has multiple edges.
|
||||
|
||||
### §5.3 Akte mode
|
||||
|
||||
In Akte mode, if the spawn was actualised (a child project exists linked via `parent_project_id`), the aux subtree shows the child project's badge: `📁 HL-2024-001-CCR · Tochterakte`. Clicking the badge navigates to that child project. The subtree itself still renders inline.
|
||||
|
||||
---
|
||||
|
||||
## §6 Anchor pin & zoom
|
||||
|
||||
m's #1 answer: "zoomability for one event and all events it triggers, from within the full timeline".
|
||||
|
||||
### §6.1 The anchor pin
|
||||
|
||||
Any node can be pinned as the anchor. Pinning sources:
|
||||
- URL `?event=<sequencing_rule_id>` (deep link).
|
||||
- Search box auto-pin when the search narrows to a single hit.
|
||||
- Click-to-pin on any node (small pin icon in the node's metadata row).
|
||||
- Akte landing: auto-pin to latest completed deadline.
|
||||
|
||||
The pinned node renders with a 4px lime-coloured left band + a `━━ DU BIST HIER ━━` divider above its successors. The pin is also reflected in the find-header's result summary: "Anker: Klageerwiderung (HL-2024-001)".
|
||||
|
||||
### §6.2 Zoom mode
|
||||
|
||||
A small `[ 🔍 Fokus ]` chip on the anchored node toggles zoom mode for that anchor. When zoom is on:
|
||||
|
||||
- The anchored node's ancestors collapse to a single breadcrumb at the top of the proceeding card:
|
||||
```
|
||||
upc.inf.cfi ▸ Klageerhebung ▸ ━ Klageerwiderung ━
|
||||
```
|
||||
- The anchored node renders full.
|
||||
- Successors render fully (the forward subtree under the anchor).
|
||||
- Sibling branches at every ancestor depth collapse to a single-line summary card: `… 4 weitere Schritte verborgen — [zeigen]`.
|
||||
|
||||
This is what m means by "zoom into one event from within the timeline" — the *same view*, just with non-relevant siblings collapsed. Toggle off → full timeline restored, anchor still pinned.
|
||||
|
||||
Zoom is page-scoped (one anchor per page, one zoom state). State in URL: `?event=<id>&zoom=1`.
|
||||
|
||||
### §6.5 Multi-proceeding anchor scope (m's Q3 divergence)
|
||||
|
||||
When the page shows >1 matched proceeding *and* an anchor is pinned, the non-anchored proceedings auto-collapse to a header-only one-line card:
|
||||
|
||||
```
|
||||
┌─ upc.inf.cfi · Verletzungsverfahren UPC ─────┐
|
||||
│ … full timeline … │
|
||||
│ ━━ DU BIST HIER: Klageerwiderung ━━ │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.rev.cfi ▸ ausblenden — [zeigen] ────────┐
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.apl.merits ▸ — [zeigen] ────────────────┐
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Clicking a header card's `[zeigen]` link restores that proceeding's full timeline (the header stays as a per-card affordance for re-collapse). The collapsed state persists in `sessionStorage["procedures:collapsed_proceedings"]`. Un-pinning the anchor restores all visible proceedings to full-render automatically.
|
||||
|
||||
The rule applies regardless of how the anchor was pinned (URL, search-auto, click-to-pin, Akte landing). The find-header result count still shows N proceedings matched — header cards are present, just collapsed.
|
||||
|
||||
### §6.3 The "where I came from" question
|
||||
|
||||
m's brief asked for backward-walk visualisation. Without zoom: the chain above the anchor is the backward walk — it's the same tree. With zoom: the breadcrumb at the top of the proceeding card is the backward walk in compact form. No separate concept; backward walk = upward in the tree.
|
||||
|
||||
### §6.4 Akte mode: actuals overlay
|
||||
|
||||
When `?project=<uuid>` is set, each node in the chain queries `paliad.deadlines WHERE project_id = $p AND sequencing_rule_id = $r` and overlays:
|
||||
|
||||
- `status='done'` → ✓ in the node bullet area + actual completed date in the date column. Greyed slightly to read as "past".
|
||||
- `status='open'` and `due_date < today` → ⚠ overdue.
|
||||
- `status='open'` and `due_date >= today` → 📅 actual due date if differs from computed; ◇ marker.
|
||||
- No deadline row → render as template (current behaviour).
|
||||
|
||||
The anchor auto-pins to the latest `status='done'` deadline by default — the natural reading is "we just finished this".
|
||||
|
||||
---
|
||||
|
||||
## §7 What lives where: the find header vs the timelines
|
||||
|
||||
A short table to make the responsibility boundary explicit:
|
||||
|
||||
| Concern | Find header | Timeline body |
|
||||
|---|---|---|
|
||||
| Pick proceeding(s) | Filter pill row | (auto-rendered after) |
|
||||
| Pick anchor | Search-narrow → auto-pin / URL `?event=` | Click pin icon on any node |
|
||||
| Pick perspective | Pill (or auto from Akte) | (read-only — feeds rendering) |
|
||||
| Pick scenario flags | (no) | Inline fork checkboxes on gating nodes |
|
||||
| Pick optional rules | (no) | Inline fork checkboxes on each optional node |
|
||||
| Pick appeal target | (no) | Inline chip group on appeal root |
|
||||
| Pick date | Stichtag input | (read-only — feeds computed dates) |
|
||||
| Toggle Alle Optionen / Gewählt | (no) | Per-proceeding 2-way toggle |
|
||||
| Zoom on anchor | (no) | `[Fokus]` chip on anchored node |
|
||||
| Akte select | Akte picker | (read-only — feeds actuals overlay) |
|
||||
|
||||
Find header = "narrow the set + global context". Timelines = "everything per-event". No drawers, no overlays.
|
||||
|
||||
---
|
||||
|
||||
## §8 Cold open + empty state
|
||||
|
||||
Cold open with no Akte, no URL params (Q4 below): show a curated default of 6 most-common proceedings (`upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma`), each rendered with default Gewählt + no forks selected + no anchor. Hint text above: "Suche oder filtere, um andere Verfahren zu sehen."
|
||||
|
||||
Empty filter result (e.g. user types nonsense): zero timelines render, with a helper card: "Keine Treffer. Filter zurücksetzen ▸"
|
||||
|
||||
---
|
||||
|
||||
## §9 Migration (direct replace per m's Q7)
|
||||
|
||||
4 slices + 1 cleanup, all surface, no DB mig, no `?tracker=1` flag. Each slice ships visibly to users at `/tools/procedures`. T1 must be at least as functional as today's catalog browser — so the find header + multi-proceeding render + inline forks + aux inline all front-load there. T2-T4 layer the remaining behaviour.
|
||||
|
||||
All independent of curie's editorial work — compound rules render inline via parent_id like any other rule; if curie ships a `compound_predecessors uuid[]` column later, those rules can render at multiple positions (one inline per predecessor) without tracker code changes beyond the join.
|
||||
|
||||
| Slice | What ships | Notes |
|
||||
|---|---|---|
|
||||
| **T1 — Tracker shell replaces the catalog page** | `/tools/procedures` now renders: sticky find header (search + Forum/Verfahren/Partei pills + Akte picker + global Stichtag), N-proceeding render (one card per matched proceeding), inline forks (scenario flags + optionals visible as checkboxes on the gating node), aux proceedings inline-expandable at spawn points, cold-open with 6 curated defaults (Q4), default = Gewählt. The 4 entry-mode tabs are deleted in the same PR; URL params `?mode=proceeding\|search\|wizard\|akte` 301-redirect or drop. URL anchor `?event=<rule_id>` scroll-highlights the matching node (no zoom yet). | Replaces catalog UI; users see the new tracker immediately. |
|
||||
| **T2 — Anchor pin + zoom + multi-proceeding scope** | Anchor pin (lime band + DU BIST HIER divider), `[Fokus]` chip on anchored node toggles zoom (§6.2), URL state `?event=…&zoom=1`. Multi-proceeding auto-collapse rule (§6.5) kicks in when an anchor is set. Click-to-pin on any node. | Layered on T1's existing render. |
|
||||
| **T3 — Akte landing + actuals overlay** | `?project=<uuid>` derives anchor from latest `status='done'` deadline (Q5), backward walk overlays `paliad.deadlines` actuals as status badges (§6.4), scenario_flags load from project, fork write-back via existing `PATCH /api/projects/{id}/scenario-flags` + `POST /api/projects/{id}/deadlines/bulk`. | The first slice that exercises the project hookup end-to-end. |
|
||||
| **T4 — Appeal-target + court-set choices + polish** | Wire `applies_to_target` array forks on appeal proceedings, `choices_offered` shapes (`appellant`, `include_ccr`, `skip`), court-set date override from `appointments` table, cross-party muted treatment per §3.6. Per-proceeding "Alle Optionen" toggle (§3.4). | Polish + the edge-case fork shapes. |
|
||||
| **T5 — Cleanup** | Dead-code removal: legacy `procedures.ts` tab toggling, `fristenrechner-mode-a.ts` / `fristenrechner-wizard.ts` / `fristenrechner-result.ts` / `verfahrensablauf.ts` if no longer referenced (verify with grep before deletion). Sidebar/cmd-K unchanged (URL same). | No user-visible change. |
|
||||
|
||||
### §9.1 Constraint: T1 is the new floor
|
||||
|
||||
Because there's no flag, **T1 must not regress** from today's catalog UI in any non-trivial way. The catalog today serves four user workflows:
|
||||
|
||||
1. **Pick a proceeding, see its full Verfahrensablauf** → T1 covers via "Verfahren" pill click → that proceeding renders alone.
|
||||
2. **Search for an event** → T1 covers via search input + auto-pin.
|
||||
3. **Wizard from R1-R5** → T1 covers via Forum/Verfahren/Partei pills + search (the wizard's narrowing is just a sequence of filter applications).
|
||||
4. **Enter via Akte** → T1 covers via the Akte picker; full actuals overlay arrives in T3 (open/done badges may render partial in T1, but the Akte's scenario_flags + proceeding pre-load works).
|
||||
|
||||
If T1 reviewing exposes a regression, T1 holds (the issue blocks merge) — m's PR review gates the slice landing.
|
||||
|
||||
### §9.2 What stays unchanged
|
||||
|
||||
- URL: `/tools/procedures` keeps it.
|
||||
- Sidebar entry "Verfahren & Fristen" keeps it.
|
||||
- cmd-K palette keeps it.
|
||||
- All other tools, calendar, projects, deadlines surfaces — untouched.
|
||||
- Calculator (`pkg/litigationplanner.CalculateRule`) — untouched.
|
||||
|
||||
### §9.3 Out-of-band dependencies
|
||||
|
||||
- The compound-predecessors editorial column is owned by curie's t-paliad-333. Tracker reads whatever lands. If it slips past T4, compound rules render via their primary parent_id only (today's shape) — degraded but still correct on that path. No tracker re-render needed when curie ships.
|
||||
- The Akte actuals overlay (T3) reads `paliad.deadlines.sequencing_rule_id` — column exists, nothing new.
|
||||
|
||||
### §9.4 Test surface per slice
|
||||
|
||||
- **T1**: cold-open 6 curated defaults render; search narrows to single proceeding; pill toggles change render; `?project=` loads Akte filters (no actuals yet); URL deep-link `?event=` highlights matching node; legacy `?mode=` redirects.
|
||||
- **T2**: click-to-pin sets anchor with lime band; `[Fokus]` zoom collapses siblings; un-zoom restores; multi-proceeding auto-collapse when anchor active; URL state survives reload.
|
||||
- **T3**: Akte landing auto-pins latest done deadline; status badges render on each node from `paliad.deadlines`; fork tick writes to `scenario_flags`; "In Akte speichern" persists.
|
||||
- **T4**: appeal-target chips switch the rule set rendered on appeal proceedings; `choices_offered` per-node chip groups visible + functional; "Alle Optionen" reveals hidden conditional rules with greyed state.
|
||||
- **T5**: production deploy unchanged surface; no live regression; deleted files don't break build.
|
||||
|
||||
---
|
||||
|
||||
## §10 Open questions for m
|
||||
|
||||
Seven questions in 2 batches (4 + 3) for `AskUserQuestion`. Tier 1 = how the per-node fork UI feels + how zoom interacts with multi-proceeding pages. Tier 2 = cold-open content + Akte default + Stichtag scope + migration cadence.
|
||||
|
||||
m's picks fold back into §11 below before the "TRACKER DESIGN READY FOR REVIEW" signal.
|
||||
|
||||
### Batch 1 — fork UI + zoom + cross-party
|
||||
- **Q1 (Fork-cluster shape on a node)** — when a node has 2-4 forks (e.g. Klageerwiderung: `with_ccr` + `with_amend` + Vorl. Einwend.) — (a) inline checkbox list below the node header (current sketch), (b) collapsed "Optionen (3) ▾" affordance that expands on hover/click, (c) chip strip on the same line as the node header.
|
||||
- **Q2 (Zoom interaction)** — `[Fokus]` chip on the anchored node — (a) collapses siblings to one-line summaries (current sketch), (b) outright hides siblings, breadcrumb-only, (c) split-view (zoomed pane below original full tree).
|
||||
- **Q3 (Anchor scope on a multi-proceeding page)** — when 3 timelines are visible and the user pins an anchor in one — (a) the other 2 timelines stay expanded normally (no zoom effect on them), (b) the other 2 timelines auto-collapse to header-only ("upc.rev.cfi ▸ ausblenden — [zeigen]"), (c) the other 2 timelines reorder to bottom of page (anchored proceeding floats to top).
|
||||
- **Q4 (Cold-open default content)** — opening `/tools/procedures` with no URL params and no Akte — (a) the 6-curated-default-proceedings sketch (Verletzung UPC + DE LG, Nichtigkeit UPC, Berufung UPC, EPA-Einspruch, DPMA-Einspruch), (b) all ~46 proceedings rendered with all forks off (lots of scrolling), (c) empty state with a "Filter wählen, um Verfahren einzublenden" prompt.
|
||||
|
||||
### Batch 2 — Akte semantics + Stichtag + migration
|
||||
- **Q5 (Akte landing — default anchor)** — `?project=<uuid>` — (a) auto-pin to latest `status='done'` deadline (current sketch), (b) auto-pin to next-open deadline (forward-looking), (c) no auto-pin, just pre-fill filters + actuals overlay, user picks anchor.
|
||||
- **Q6 (Stichtag scope)** — date input in the find header — (a) global, feeds all visible proceedings' computed dates (current sketch — useful for browsing "if today were the trigger"), (b) per-proceeding (each timeline carries its own date input), (c) only valid in single-proceeding mode (hidden when the page shows >1 proceeding).
|
||||
- **Q7 (Migration cadence)** — (a) flag-gated dev under `?tracker=1`, T1-T4 ship, T5 hard-cut (current sketch, cronus precedent), (b) direct replace at T1 (no flag — every slice ships visibly to users), (c) parallel URL `/tools/procedures-v2` until hard-cut.
|
||||
|
||||
---
|
||||
|
||||
## §11 m's decisions (2026-05-27)
|
||||
|
||||
All 7 questions answered via `AskUserQuestion` in 2 batches (4 + 3) at 21:0?. 5 picks on-recommendation, 2 diverged. Decisions below; the underlying question list lives in §10 above as the historical record.
|
||||
|
||||
### Tier 1 — fork UI + zoom + cross-party
|
||||
|
||||
- **Q1 (Fork cluster on a node): Inline checkbox list below node header.** [= REC] **Locks §3.2.** Every fork on a given node renders as a checkbox in an "Optionen:" cluster line below the node header. Always visible, no hover, no extra click. Vertical real estate per node is acceptable because the default `Gewählt` mode keeps the tree compact (most events have zero forks).
|
||||
- **Q2 (Zoom interaction): Collapse siblings to one-line summaries.** [= REC] **Locks §6.2.** `[Fokus]` chip on the anchored node folds sibling branches at each ancestor depth to a `… 4 weitere Schritte verborgen — [zeigen]` line. The anchored node's subtree renders full. Breadcrumb at the top of the proceeding card. Toggle off restores everything.
|
||||
- **Q3 (Multi-proceeding anchor scope): Other timelines auto-collapse to header-only.** [≠ REC; m diverged from "stay expanded"] **Locks new §6.5.** When an anchor is pinned on a multi-proceeding page, the non-anchored proceedings fold to a one-line header card (`upc.rev.cfi ▸ ausblenden — [zeigen]`). Clicking the header line restores that proceeding's full timeline. Rationale (interpreted): with an anchor pinned, the page is *about* that anchor — having other proceedings full-render in parallel competes for attention without earning it. The header card preserves the find-header result count and offers a one-click escape if the user wants to compare.
|
||||
- **Q4 (Cold open content): 6 curated default proceedings.** [= REC] **Locks §8.** No URL params + no Akte → render `upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma` stacked vertically, all forks off, no anchor. Hint: "Suche oder filtere, um andere Verfahren zu sehen."
|
||||
|
||||
### Tier 2 — Akte + Stichtag + migration
|
||||
|
||||
- **Q5 (Akte default anchor): Latest `status='done'` deadline.** [= REC] **Locks §6.4 + §9.** `?project=<uuid>` → derive anchor by `SELECT … FROM paliad.deadlines WHERE project_id=$p AND sequencing_rule_id IS NOT NULL ORDER BY completed_at DESC NULLS LAST LIMIT 1`. Fallback: next open deadline → proceeding root. The backward chain reads as Akte history; the anchor itself is the most recently completed work; forward is upcoming.
|
||||
- **Q6 (Stichtag scope): Global, feeds all visible proceedings.** [= REC] **Locks §2.1 + §7.** One date input in the find header. All visible proceedings compute dates against it. When the user has an Akte loaded, the Stichtag pre-fills from the project's latest trigger date but is overrideable. When the anchor is pinned to a `status='done'` deadline, the date input shows that deadline's `completed_at` but can still be overridden for "what-if" exploration.
|
||||
- **Q7 (Migration cadence): Direct replace at T1 — no flag.** [≠ REC; m diverged from flag-gated dev] **Rewrites §9.** Every slice ships visibly to users at /tools/procedures. T1 must be at minimum equivalent to today's catalog browser (so the slicing has to front-load find header + multi-proceeding render + forks inline + aux inline). The flag-gated dev plan is dropped. cronus's Q11 hard-cut precedent extends here: m would rather ship per-slice visibly than carry a parallel surface during dev. Rationale (interpreted): partial-tracker > no-tracker, and ~50 internal lawyers absorb the per-slice deltas through release comms.
|
||||
|
||||
### §11.1 Changes triggered by m's divergences
|
||||
|
||||
**Q3 divergence — multi-proceeding anchor scope.** New §6.5 added below. The header-card-only render for non-anchored proceedings preserves filter compose (you can still see "upc.rev.cfi matched the filter") while clearing the page's vertical real estate for the anchor's full context.
|
||||
|
||||
**Q7 divergence — direct replace.** §9 rewritten end-to-end. T1 now ships the minimum-viable tracker (find header + multi-proceeding render + forks inline + aux subtrees inline + URL-anchor highlight), replacing the catalog UI at /tools/procedures from the moment it merges. T2-T4 layer zoom, Akte semantics, polish. T5 ("cleanup only") is now just dead-code removal.
|
||||
|
||||
### §11.2 What stays unchanged
|
||||
|
||||
The other 5 picks (Q1, Q2, Q4, Q5, Q6) ratified the inventor proposal. Inline checkbox forks per node, breadcrumb-collapse zoom, 6-curated cold open, latest-done-deadline Akte anchor, global Stichtag — all locked as drafted in §1-§8.
|
||||
|
||||
---
|
||||
|
||||
## §12 Out of scope
|
||||
|
||||
- Calculator changes.
|
||||
- Editorial backfill (curie's t-paliad-333). Compound rules render inline as parent_id-chained rules with curie's annotation; no special tracker treatment.
|
||||
- `/admin/procedural-events`, `/projects/{id}` Verlauf / SmartTimeline.
|
||||
- youpc.org / Outlook / PDF export.
|
||||
- Multi-project anchor comparison.
|
||||
- Free-text scenario flag i18n.
|
||||
|
||||
---
|
||||
|
||||
## §13 Synthesis links
|
||||
|
||||
- **mBrian** (after m's ratification): file as `[synthesis]` linked `triggered_by` t-paliad-337; `related_to` cronus's unified-procedural-events-tool design + atlas's deadline-system-revision + cronus's earlier Fristenrechner overhaul.
|
||||
- **Cross-refs in this repo**: `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus, U0-U4 shipped today), `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#152 (this design), m/paliad#151 (cronus U0-U4), m/paliad#149 (atlas Phase 2).
|
||||
- **Coder phase** (deferred per inventor SKILL): runs after m ratifies §10 + §11. Slice ordering per §9. NOT atlas (parked at "TRACKER DESIGN READY FOR REVIEW"). Pattern-fluent Sonnet coder picks up T1 first.
|
||||
568
docs/design-unified-procedural-events-tool-2026-05-27.md
Normal file
568
docs/design-unified-procedural-events-tool-2026-05-27.md
Normal file
@@ -0,0 +1,568 @@
|
||||
# Design — Unified procedural-events tool (m/paliad#151)
|
||||
|
||||
**Task:** t-paliad-334
|
||||
**Gitea:** m/paliad#151
|
||||
**Inventor:** cronus (shift-1, fresh context — name-recycled, not the cronus from earlier today)
|
||||
**Date:** 2026-05-27
|
||||
**Branch:** `mai/cronus/inventor-unified`
|
||||
**Status:** Draft — coder gate held; awaiting m's go on the unification approach
|
||||
|
||||
**Builds on:**
|
||||
- `docs/assessment-deadline-system-2026-05-27.md` (athena, Phase 1 audit — premises)
|
||||
- `docs/design-deadline-system-revision-2026-05-27.md` (atlas, model + per-surface revisions — pre-locked decisions)
|
||||
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26 inventor, Mode A + B + result shipped via t-paliad-322 / m/paliad#146 S1-S6)
|
||||
- `docs/design-event-card-choices-2026-05-25.md`, `docs/design-determinator-row-cascade-2026-05-13.md` (per-card choice + determinator routing — current Verfahrensablauf state)
|
||||
|
||||
m's framing (2026-05-27 19:13):
|
||||
|
||||
> There are many dimensions by which we can display and filter our procedural events. Maybe we should hire an inventor to find out the best methods from the ones we already have? It makes sense to narrow things, display them in sequence and context, make selections etc. It just needs to be done well, preferably in a unitary tool. There should be alternative means to derive at what you want to derive at.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises — what the inventor is and isn't doing
|
||||
|
||||
This is a **surface-layer** design. The **model layer** is locked by atlas's `design-deadline-system-revision-2026-05-27.md` (Q1-Q12 + 14:34/14:40 post-ratification additions, all m-decided 2026-05-27). The shipped Fristenrechner Mode A + Mode B + result view is the model-side foundation; the in-flight atlas P0-P5 train extends Verfahrensablauf and the scenario SSoT.
|
||||
|
||||
The inventor's question is **not** "how should the rule graph be modelled" — that's settled. It's: **of the 6 surfaces that read this model, do we have the right *surfaces*? Should they unify into one tool, two, or stay as today's set?**
|
||||
|
||||
Out of scope per the issue + paliadin/head brief:
|
||||
|
||||
- Calculator (`pkg/litigationplanner.CalculateRule`) — working.
|
||||
- `/admin/procedural-events` as an editorial **write** surface — different audience, different action set, must stay separate.
|
||||
- `/projects/{id}` Verlauf — per-Akte **actuals** surface, not the ablauf-tool. Sister tool, not subsumable.
|
||||
- SmartTimeline projection — per-project read view that composes actuals + projections; sister to Verlauf, project-bound. Not subsumable.
|
||||
- youpc.org/deadlines — cross-repo public surface. Snapshot consumer.
|
||||
- Outlook / Calendar sync UI.
|
||||
|
||||
In-scope unification candidates (4 surfaces): the three Fristenrechner modes (A search + B wizard + result) **and** Verfahrensablauf — these read the *same model* (sequencing_rules + procedural_events + scenario_flags) to answer questions about the *same underlying graph*. The question is whether they're best presented as one URL with multi-mode entry, two URLs with shared vocabulary, or as today's split.
|
||||
|
||||
---
|
||||
|
||||
## §1 Audit of the 6 surfaces
|
||||
|
||||
For each surface: question it answers, dimensions it filters/anchors on, what it does well, what it does poorly, overlap with neighbours.
|
||||
|
||||
### §1.1 `/tools/fristenrechner` Mode A — "Direkt suchen" (shipped t-paliad-322 S3)
|
||||
|
||||
**Question shape:** "I know a procedural event happened (e.g. *Klageerwiderung*). What follow-ups come next?"
|
||||
|
||||
**Dimensions used:**
|
||||
- *Filters* (top strip): `forum` (UPC/DE/EPA/DPMA), `proceeding_type`, `event_kind` (filing/hearing/decision/order), `primary_party`.
|
||||
- *Anchor* (the search result): one `procedural_event` row → lock as trigger.
|
||||
- *Inbox* secondary chip (CMS / beA / postal): auto-derives forum.
|
||||
|
||||
**Path:** Filter strip → free-text search → result row click → linear follow-up view (handed off to §1.3).
|
||||
|
||||
**Strengths:** Power-user surface; one box does everything; forgiving to misspellings via pg_trgm; deep-linkable via `?mode=search&q=…&forum=…`.
|
||||
|
||||
**Weaknesses:** Search returns *every* event including spawn-only and leaves (atlas §2.2 P0 fix in flight); no visualisation of *where* the picked event sits in the proceeding tree.
|
||||
|
||||
**Overlap:** Picks the same `procedural_event` rows that Mode B R4 lands on; picks the same proceeding chips that Verfahrensablauf shows. Filter strip is a subset of Verfahrensablauf's filter chips.
|
||||
|
||||
### §1.2 `/tools/fristenrechner` Mode B — "Geführt" wizard (shipped t-paliad-322 S4)
|
||||
|
||||
**Question shape:** Same as Mode A but for users who don't know how to phrase the question. Narrows by Q&A.
|
||||
|
||||
**Dimensions used:** All five Mode A filters reframed as wizard rows:
|
||||
- R1 `event_kind` (Filter badge)
|
||||
- R2 `forum` / jurisdiction (Filter, skipped if R1 narrows)
|
||||
- R3 `proceeding_type` (Qualifier, auto-skipped on single match)
|
||||
- R4 `procedural_event` (Qualifier — the landing question)
|
||||
- R5 `primary_party` (Qualifier, only when follow-ups differ by side)
|
||||
|
||||
**Path:** Q-by-Q chip pick → R4 lock → linear follow-up view (handed off to §1.3).
|
||||
|
||||
**Strengths:** Onboarding-friendly; auto-prefills from Akte (`projects.proceeding_type_id` → R3, `projects.our_side` → R5); preserves compatible downstream picks on back-nav.
|
||||
|
||||
**Weaknesses:** No tree-context view of the answer; the user lands on a flat result with no zoom-out.
|
||||
|
||||
**Overlap:** Same R4 event set as Mode A's search results. Same downstream result view.
|
||||
|
||||
### §1.3 `/tools/fristenrechner` result view (shipped t-paliad-322 S2)
|
||||
|
||||
**Question shape:** Given a locked event + trigger date, what dated follow-ups exist?
|
||||
|
||||
**Dimensions used:**
|
||||
- *Anchor:* one `sequencing_rule` (the trigger's anchor rule).
|
||||
- *Linear walk:* one hop down via `parent_id` — children of the anchor, grouped by priority.
|
||||
- *Display axes:* priority (4 groups: mandatory / recommended / optional / conditional), party, condition flag, court-set, spawn.
|
||||
- *Persistent state:* per-rule checkboxes (selection for write-back), per-rule date overrides.
|
||||
- *Write-back:* `POST /api/projects/{id}/deadlines/bulk` with audit_reason.
|
||||
|
||||
**Strengths:** Clear list + write-back footer; sticky trigger card; deep-linkable; cross-party detection in atlas P0 (S1 from t-paliad-327).
|
||||
|
||||
**Weaknesses:** Only shows *direct* children of the anchor. No visibility of where this slice fits in the proceeding's wider graph. No way to pivot to "show the whole ablauf around this".
|
||||
|
||||
**Overlap:** Selection state UI vocabulary (per-rule checkbox + chip) is conceptually identical to Verfahrensablauf's per-rule selection chips that atlas's P3 will ship.
|
||||
|
||||
### §1.4 `/tools/verfahrensablauf` (current state + atlas P3 in flight)
|
||||
|
||||
**Question shape (today):** "What does proceeding-type X look like in full?"
|
||||
|
||||
**Dimensions used:**
|
||||
- *Anchor:* one `proceeding_type` (chip-picked).
|
||||
- *Filters:* `side` (claimant/defendant), `target` (appeal-target — endentscheidung / kostenentscheidung / anordnung / schadensbemessung / bucheinsicht), `trigger_date`.
|
||||
- *Scenario flags:* CCR / inf_amend / rev_amend / rev_cci, plus per-card choices (appellant / include_ccr / skip).
|
||||
- *View toggle:* `columns` (3-column swimlane: Unsere Seite | Gericht | Gegnerseite) vs `timeline` (single-column chronological).
|
||||
- *Detail-mode toggle (shipped today via m/paliad#149 P3):* `mandatory_only` / `selected` / `all_options`.
|
||||
- *Per-card affordances:* `[Aufnehmen]` / `[Entfernen]` chips for optional/recommended rules, dotted-border for unselected, greyed for conditional-with-flag-off.
|
||||
|
||||
**Strengths:** The most data-rich surface — every rule for the proceeding rendered with computed dates against `trigger_date`. View-mode toggle gives detail-level control. URL params are clean (proceeding/side/target/trigger_date); noisy scenario flags live in localStorage (per `verfahrensablauf-state.ts`).
|
||||
|
||||
**Weaknesses:** The user must already know which proceeding to look at — no entry path from "an event happened" or "search by name". 3-column swimlane reads dense on desktop and unmanageably wide on mobile. Trigger-date is per-page (not per-rule), so the entire ablauf computes from one anchor — fine for kontextfrei browse, awkward for Akte where different rules have different real triggers.
|
||||
|
||||
**Overlap:** Detail-mode + per-rule selection chips share the design vocabulary that result view §1.3 *should* eventually adopt. Filter dimensions are a superset of Mode A's filter strip.
|
||||
|
||||
### §1.5 `/admin/procedural-events` (shipped, Slice B.5)
|
||||
|
||||
**Question shape:** "I need to edit / publish / audit rules."
|
||||
|
||||
**Dimensions used:** Lifecycle filter (draft/published/archived), proceeding chip, trigger-event filter, free-text. Per-row click → editor form. Separate tab for orphans (Slice 10 fuzzy-match staging).
|
||||
|
||||
**Strengths:** Lifecycle-aware; clone-publish workflow; audit log; orphan resolution.
|
||||
|
||||
**Weaknesses:** None for editors. *For readers,* it's the wrong tool — too much editor-state metadata in the table; no tree / sequence / dates / scenario filtering.
|
||||
|
||||
**Overlap:** None functional. Shares the rule corpus but its *action set* (edit/publish/audit/resolve-orphan) is disjoint from the reader surfaces.
|
||||
|
||||
**Verdict: keep separate.** Different audience (editors only — m today, the partner team eventually), different action set, different lifecycle vocabulary. Cross-linking is sufficient: every reader-surface row should have a "Diese Regel bearbeiten" link to `/admin/procedural-events/{id}/edit` for editor users.
|
||||
|
||||
### §1.6 `/projects/{id}` Verlauf — out of scope per brief
|
||||
|
||||
Project-bound timeline of *actual* deadlines + appointments + project_events for one Akte. Composes with SmartTimeline projections.
|
||||
|
||||
**Question shape:** "What's happened on my Akte and what's next *for this specific case*?"
|
||||
|
||||
This is conceptually downstream of the ablauf-tool: the ablauf-tool answers "what's the *shape* of proceeding X"; Verlauf answers "what's the *state* of *my Akte* that happens to be proceeding X". The shape becomes the actuals through user actions (write-back from Mode A result view, manual entry, CMS sync).
|
||||
|
||||
**Verdict: keep separate.** Different question, different data shape (instances vs templates).
|
||||
|
||||
### §1.7 SmartTimeline / `ProjectionService` — out of scope per brief
|
||||
|
||||
Per-project read view via `GET /api/projects/{id}/timeline` that returns merged actuals + projected future rows (via FristenrechnerService) + parent-node lane aggregation. The render shape is project-bound and lookahead-capped; the model knows about levels (Case / Patent / Litigation / Client) and bubble-up events.
|
||||
|
||||
**Verdict: keep separate.** SmartTimeline composes the ablauf-tool's output with project actuals; it's a consumer, not a peer.
|
||||
|
||||
### §1.8 `youpc.org/deadlines` — out of scope (cross-repo)
|
||||
|
||||
Public surface backed by the offline UPC snapshot (`cmd/gen-upc-snapshot`). Snapshot consumer only.
|
||||
|
||||
**Verdict: keep separate.** Different repo, different deploy.
|
||||
|
||||
---
|
||||
|
||||
## §2 The question→surface→dimension matrix
|
||||
|
||||
The single source of truth for "which dimension lives where". Two questions answer "which view does this surface show":
|
||||
|
||||
| User question | Today's surface | Anchor input | Output shape | Output detail |
|
||||
|---|---|---|---|---|
|
||||
| "What's the typical ablauf of upc.inf.cfi?" | Verfahrensablauf | `proceeding_type` | Tree-or-columns of all rules | Whole ablauf |
|
||||
| "Was passiert nach Klageerhebung?" | Fristenrechner Mode A | `procedural_event` | Linear follow-ups (priority groups) | Slice through tree |
|
||||
| "Was passiert nach… (don't know the event name)?" | Fristenrechner Mode B | Q&A → `procedural_event` | Same as Mode A | Same |
|
||||
| "Welche Fristen für meine Akte ergeben sich?" | Fristenrechner Mode A/B + `?project=` | Akte + `procedural_event` | Linear follow-ups + write-back | Same + actions |
|
||||
| "Wie sieht der gesamte Ablauf für meine Akte aus?" | Verfahrensablauf + `?project=` | Akte (derives `proceeding_type`) | Tree-or-columns + scenario | Whole ablauf + state |
|
||||
| "Welche Regeln gibt's? Wie bearbeite ich sie?" | /admin/procedural-events | — | Editor table | Editor metadata |
|
||||
| "Was steht auf meinem Akten-Plan?" | /projects/{id} Verlauf | Akte | Actuals timeline | Per-instance state |
|
||||
|
||||
Dimensions matrix — same dimension axis, varied surface presentation:
|
||||
|
||||
| Dimension | Cardinality | Mode A | Mode B | Result | Verfahrensablauf | Admin |
|
||||
|---|--:|---|---|---|---|---|
|
||||
| `forum` (jurisdiction) | 4 | top-chip filter | R2 | trigger-card badge | — (anchored by PT) | search facet |
|
||||
| `proceeding_type` | 23 | top-chip filter | R3 (auto-skip on single) | trigger-card chip | chip strip (the anchor) | dropdown filter |
|
||||
| `event_kind` | 5 | top-chip filter | R1 | trigger-card badge | — (in cards) | search facet |
|
||||
| `primary_party` | 5 | top-chip filter | R5 (when needed) | per-rule chip | swimlane column / per-card | — |
|
||||
| `priority` | 4 | — | — | group header | view-mode toggle + card style | column |
|
||||
| `condition_expr` (gating) | bool | — | — | conditional group | greyed cards + flag strip | rule editor field |
|
||||
| `is_spawn` | bool | hidden (atlas filter) | hidden | "⇲ Verfahren öffnen" CTA | leaf with ⇲ icon | column |
|
||||
| `is_court_set` | bool | — | — | "vom Gericht" badge | greyed-date card | column |
|
||||
| `parent_id` (chain depth) | derived | "Folgen: N" count | — | depth-1 only (children of anchor) | depth-N indentation / tree walk | "abhängig von" chip |
|
||||
| selection state (scenario_flags `rule:<uuid>`) | per-rule | — | — | checkbox (write-back) | `[Aufnehmen]`/`[Entfernen]` chips | — |
|
||||
| scenario flags (named: with_ccr, with_amend, …) | 3 | — | — | bound checkboxes (read-only) | flag strip (canonical edit surface) | rule editor field |
|
||||
| view-mode (detail level) | 3 | — | — | — (always "selected") | top toggle | — |
|
||||
| `trigger_date` | date | result view input | result view input | top of card | per-page input | — |
|
||||
|
||||
**Reading the matrix.** Every dimension lives at least two surfaces over. The user's mental model has to translate "the proceeding chip on Verfahrensablauf" to "R3 in Mode B" to "the proceeding filter strip in Mode A" — three names, same dimension. Same for forum, event_kind, party.
|
||||
|
||||
This is the friction m's framing pointed at: **the dimensions are shared, but the surface vocabulary is not.**
|
||||
|
||||
---
|
||||
|
||||
## §3 Consolidation proposal
|
||||
|
||||
### §3.1 The honest answer first
|
||||
|
||||
Of the 6 surfaces:
|
||||
|
||||
- **2 stay separate, correctly** — `/admin/procedural-events` (editorial audience) and `/projects/{id}` Verlauf + SmartTimeline (per-Akte actuals). They serve different question shapes and audiences. Cross-link liberally; do not merge.
|
||||
- **4 are candidates for unification** — Fristenrechner Mode A + Mode B + result + Verfahrensablauf. Same underlying data, same dimensions, two zoom levels on one graph. Today they sit at two URLs (`/tools/fristenrechner` + `/tools/verfahrensablauf`) with separate filter vocabularies.
|
||||
|
||||
### §3.2 The unified surface: `/tools/procedures`
|
||||
|
||||
**Proposal:** consolidate the 4 reader surfaces into one page at `/tools/procedures` (the more general name; both "Fristenrechner" and "Verfahrensablauf" are sub-modes inside).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ /tools/procedures │
|
||||
│ ┌─ Akte / kontextfrei ─┐ ┌─ Filterleiste ────────────────────────────┐│
|
||||
│ │ HL-2024-001 ▼ ohne │ │ Forum • Verfahren • event_kind • Partei ││
|
||||
│ └──────────────────────┘ └───────────────────────────────────────────┘│
|
||||
│ ┌─ Wie willst du einsteigen? ──────────────────────────────────────────┐│
|
||||
│ │ (•) Verfahren wählen ( ) Direkt suchen ( ) Geführt ( ) Aus Akte ││
|
||||
│ └─────────────────────────────────────────────────────────────────────┘│
|
||||
│ ┌─ Ausgabe ── (Anzeige: Gewählt) ──────────────────────────────────────┐│
|
||||
│ │ Either: TREE (proceeding-anchored) ││
|
||||
│ │ │ 📥 Klageerhebung [claimant · M] ││
|
||||
│ │ │ ├─ Klageerwiderung [defendant · M] ││
|
||||
│ │ │ │ └─ Replik [claimant · M · ?with_ccr] ││
|
||||
│ │ │ ├─ Widerklage [defendant · O · ?with_ccr] ││
|
||||
│ │ │ └─ ⇲ Berufungsverfahren öffnen [SPAWN] ││
|
||||
│ │ ││
|
||||
│ │ Or: LINEAR (event-anchored, after locking) ││
|
||||
│ │ │ 🎯 Klageerwiderung (defendant, 2026-04-01) ││
|
||||
│ │ │ ───────────────────────────────────────────── ││
|
||||
│ │ │ Pflicht: Replik (1 Monat) ☑ ││
|
||||
│ │ │ Empfohlen: Vorl. Einwendungen ☑ ││
|
||||
│ │ │ Optional: … ││
|
||||
│ │ │ Bedingt: … ││
|
||||
│ │ │ [In Akte speichern] ││
|
||||
│ │ ││
|
||||
│ │ Pivot: every card has "Im Ablauf zeigen" ↔ "Folge-Fristen anzeigen" ││
|
||||
│ └─────────────────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The page carries **one URL**, **one filter strip**, **one Akte picker**, **one selection-state store** (scenario_flags), **one view-mode toggle**, and **two output shapes** the user can toggle between:
|
||||
|
||||
1. **Tree output** (proceeding-anchored): the current Verfahrensablauf rendering — every rule of a proceeding, depth-indented via `parent_id`, with per-rule chips for selection and the three view-modes (Nur Pflicht / Gewählt / Alle Optionen).
|
||||
2. **Linear output** (event-anchored): the current Mode A/B result view — sticky trigger card + 4 priority groups of follow-ups + write-back footer.
|
||||
|
||||
The **entry mode** selects *which output you land on*:
|
||||
- "Verfahren wählen" + chip → tree of that proceeding.
|
||||
- "Direkt suchen" + search → linear follow-ups of the picked event.
|
||||
- "Geführt" wizard → linear follow-ups of the wizarded event.
|
||||
- "Aus Akte" → tree of the Akte's proceeding, with scenario_flags pre-loaded.
|
||||
|
||||
The two outputs **share** the filter strip, the Akte context, the scenario state, the per-card UI vocabulary. Cross-pivoting is one click: from any rule card in the tree, "Folge-Fristen anzeigen" pivots to linear-from-that-anchor; from the linear view, "Im Ablauf zeigen" pivots back to the tree with the anchor highlighted.
|
||||
|
||||
### §3.3 Alternative — keep the URLs split, tighten alignment
|
||||
|
||||
The *minimum* unification, if m balks at folding two pages into one: keep `/tools/fristenrechner` and `/tools/verfahrensablauf` as distinct URLs but:
|
||||
|
||||
- Standardise the filter strip vocabulary (same chip names, same order, same colour coding).
|
||||
- Share the entry-mode dropdown / tab UI components.
|
||||
- Mutual deep-links: every result-view row has "Im Ablauf zeigen" → Verfahrensablauf URL with anchor; every Verfahrensablauf tree node has "Folge-Fristen" → Fristenrechner URL with event locked.
|
||||
- Selection state already shared via `projects.scenario_flags` from atlas P0.
|
||||
|
||||
This is the conservative path. It preserves URL stability but accepts that "which tool for which question" remains a learned concept rather than a single-doorway tool.
|
||||
|
||||
### §3.4 Inventor's recommendation
|
||||
|
||||
**Unify (§3.2)** — m's framing ("preferably in a unitary tool") + the dimension matrix showing 6+ shared filters argue strongly. The cost of two URLs is two filter vocabularies, two mental models, two cmd-K targets. Folding them is a few weeks of frontend work after atlas's P3 lands; the data layer is already ready.
|
||||
|
||||
The risk is *not* the merge — it's the rename. `/tools/fristenrechner` is the name lawyers know. Naming choices in §11.Q2 below.
|
||||
|
||||
---
|
||||
|
||||
## §4 Multi-dimensional filter spec
|
||||
|
||||
Where each dimension lives in the unified surface. Categories: **anchor** (the thing the output is rooted on), **filter** (narrows what's rendered), **qualifier** (refines the anchor), **display** (per-card affordance), **state** (persists across surface).
|
||||
|
||||
| Dimension | Category | Where (entry mode) | Where (tree output) | Where (linear output) |
|
||||
|---|---|---|---|---|
|
||||
| `forum` | filter | top strip chip | top strip chip (narrows PT chips) | top strip chip + trigger-card badge |
|
||||
| `proceeding_type` | anchor (tree) / filter (linear) | "Verfahren wählen" chip-grid; "Direkt"/"Geführt" filter strip | The anchor — header above tree | trigger-card chip |
|
||||
| `event_kind` | filter | "Geführt" R1; Mode A filter chip | per-card icon | per-rule row icon |
|
||||
| `primary_party` | filter | "Geführt" R5; Mode A filter chip; Akte (`our_side`) | swimlane column OR per-card chip (view-mode-dependent) | per-rule chip + Gegenseitig badge |
|
||||
| `priority` | display | — | view-mode toggle + per-card style | group header (4 groups) |
|
||||
| `condition_expr` (gating) | state | — | greyed + flag-strip activation | conditional group + read-only checkbox |
|
||||
| `is_spawn` | display | filtered out of pickers (atlas §2.2) | leaf with ⇲ icon | "⇲ Verfahren öffnen" CTA, no date |
|
||||
| `is_court_set` | display | — | greyed-date card with "vom Gericht" badge | "vom Gericht" badge, no date |
|
||||
| `parent_id` (chain depth) | display | — | tree indentation | hidden (linear shows depth-1 only) |
|
||||
| selection state `rule:<uuid>` | state | — | `[Aufnehmen]`/`[Entfernen]` chips | checkbox (write-back) |
|
||||
| named scenario flags (`with_ccr`, …) | state | — | flag strip above tree | read-only mirror in conditional group |
|
||||
| view-mode (detail level) | display | — | three-way segmented top toggle | — (always Gewählt) |
|
||||
| `trigger_date` | anchor (linear) / display (tree) | linear: result view date input | tree: optional per-page input, defaults today | linear: top-card date input (canonical) |
|
||||
| `is_cross_party` (derived) | display | — | muted style + Gegenseitig badge | muted style + Gegenseitig badge |
|
||||
|
||||
**Design principle:** dimensions stay in the **same chip / control**, regardless of which output is showing. The user learns the filter strip once. The output reacts.
|
||||
|
||||
---
|
||||
|
||||
## §5 Alternative paths spec — four ways to derive at the same outcome
|
||||
|
||||
m's "alternative means to derive at what you want" rendered explicitly. All four paths converge on the same underlying rule-set view; only the *entry experience* differs.
|
||||
|
||||
```
|
||||
Path 1: PROCEEDING-FIRST (German-lawyer approach)
|
||||
"Ich öffne ein UPC-Verletzungsverfahren — wie sieht das aus?"
|
||||
1. Page open → "Verfahren wählen" tab (default if no Akte)
|
||||
2. Chip-grid: pick `upc.inf.cfi`
|
||||
3. Tree renders. User sees full ablauf.
|
||||
4. (Optional) Click rule → drill to linear follow-ups of that rule.
|
||||
|
||||
Path 2: EVENT-FIRST (UPC-lawyer / paralegal)
|
||||
"Das Gericht hat einen Hinweisbeschluss erlassen — was bedeutet das?"
|
||||
1. Page open → "Direkt suchen" tab
|
||||
2. Filter strip: Forum=UPC + event_kind=order
|
||||
3. Search "Hinweis" → 3 hits
|
||||
4. Click `upc.inf.cfi.cmo_review` → linear follow-ups (Antrag CMO-Überprüfung etc.)
|
||||
|
||||
Path 3: GUIDED (trainee PA)
|
||||
"Es ist etwas passiert; ich weiß nicht wie die Frist heißt"
|
||||
1. Page open → "Geführt" tab
|
||||
2. R1 event_kind: filing
|
||||
3. R2 forum: UPC (or skipped if R1 narrowed)
|
||||
4. R3 proceeding_type: upc.inf.cfi (auto-skipped if only one)
|
||||
5. R4 event chip-strip: pick the relevant event
|
||||
6. R5 perspective (only if follow-ups differ)
|
||||
7. Linear follow-ups render.
|
||||
|
||||
Path 4: AKTE-FIRST (senior partner / paralegal with project context)
|
||||
"Auf HL-2024-001 ist heute Klageerwiderung zugegangen — was nun?"
|
||||
1. Page open → Akte picker → HL-2024-001
|
||||
2. Page auto-derives `proceeding_type` + `our_side` + `scenario_flags`
|
||||
3. Default landing: TREE of upc.inf.cfi, scenario flags pre-loaded
|
||||
4. Click "Klageerwiderung" card → linear follow-ups, write-back footer enabled
|
||||
5. Tick rules → "In Akte speichern" → POST /api/projects/.../deadlines/bulk
|
||||
```
|
||||
|
||||
All four paths share:
|
||||
- the same filter strip (forum / proceeding / event_kind / party — values persist across paths in URL)
|
||||
- the same view-mode toggle (when tree is showing)
|
||||
- the same scenario_flags (when Akte is loaded)
|
||||
- the same per-card vocabulary (`[Aufnehmen]` / `[Entfernen]` / `[Bedingt]` / `[Gegenseitig]` / `⇲`)
|
||||
- the same cross-pivot affordance ("Im Ablauf zeigen" / "Folge-Fristen anzeigen")
|
||||
|
||||
The user can switch paths mid-task: started in Path 4, lost in the Akte's tree, jump to Path 2 (search) to find a specific event, then jump back to the tree via the cross-pivot. Tab state preserved.
|
||||
|
||||
---
|
||||
|
||||
## §6 Selection state spec
|
||||
|
||||
Already locked by atlas's `design-deadline-system-revision-2026-05-27.md` §2.3 + §2.4a. Briefly, in the unified tool's context:
|
||||
|
||||
- **Named flags** (`with_ccr`, `with_amend`, `with_cci`, plus catalog extensions) — top "Szenario-Flags" strip when proceeding is locked. Edits write to `projects.scenario_flags` (Akte) or localStorage (kontextfrei) and dispatch `scenario-flag-changed` CustomEvent. Both tree and linear views listen and re-render.
|
||||
- **Per-rule deviations** (`rule:<uuid> = true|false`) — `[Aufnehmen]` / `[Entfernen]` chips on each tree card; identical to the result-view checkboxes in linear mode (linear's "checked" state literally is `rule:<uuid>=true`).
|
||||
- **Default population:** none on project create. The flat-map only stores deviations from priority defaults.
|
||||
|
||||
**Cross-view sync.** When the user toggles "Klageerwiderung" in linear write-back, the tree's corresponding card immediately re-renders with the chip state updated — same CustomEvent. When the user clicks `[Aufnehmen]` on the tree's "Antrag CMO-Überprüfung", switching to linear shows it pre-checked.
|
||||
|
||||
**Kontextfrei vs Akte:** kontextfrei writes to `localStorage["paliad.verfahren.scenario.<proceeding_code>"]` (per-proceeding key — different proceedings have different selection sets, matching the existing `paliad.verfahrensablauf.scenario.*` convention). Akte writes to the DB column.
|
||||
|
||||
---
|
||||
|
||||
## §7 Sequence visualisation
|
||||
|
||||
Three candidate shapes. Issue brief lists "vertical tree, horizontal timeline, collapsible groups, per-priority lanes" as options. Today's surfaces use:
|
||||
|
||||
| Shape | Where today | What it does well | What it does poorly |
|
||||
|---|---|---|---|
|
||||
| **3-column swimlane** (Unsere / Gericht / Gegenseite) | Verfahrensablauf default view | Reads side-of-table cleanly; left = our action, right = opponent's | Dense at depth; mobile-hostile; cross-party hops zig-zag across columns |
|
||||
| **Single-column linear timeline** | Verfahrensablauf alt view | Mobile-friendly; chronological | Loses parent-chain structure visually |
|
||||
| **Vertical tree (indented)** | atlas P3 proposal; ASCII trees in design docs | Shows chain depth; clean on desktop + mobile; matches mental model | Less easy to read date-order at a glance |
|
||||
| **Priority groups** | Mode A/B result view | Highlights what's urgent | Loses sequence; only works for one anchor |
|
||||
|
||||
**Recommendation:** make the tree the canonical desktop shape (atlas P3); the 3-column swimlane becomes an optional view ("Schwimmbahnen") when the user wants side-comparison; mobile defaults to the single-column linear timeline collapsed by depth. Per-priority groups stay as the linear-output sub-shape (only when an event is locked).
|
||||
|
||||
This is a strict superset of today's options — no shape is removed.
|
||||
|
||||
**Concrete rendering rules:**
|
||||
- Each card carries 4 axes: priority, selection state, conditional gate, cross-party. Visual style composes them: priority = colour stripe; selection = solid vs dotted border; conditional-flag-off = greyed; cross-party = muted + Gegenseitig badge.
|
||||
- Spawn rules render as **leaf chips** with `⇲` icon. In Akte mode, the chip becomes a CTA: click → create child project of the spawn target's PT, link via `parent_project_id`. Already wired via `/api/projects/{id}/timeline/counterclaim` for the CCR case.
|
||||
- Court-set rules carry a "vom Gericht bestimmt" badge in place of the computed date. The card is still rendered (it's still part of the ablauf), just without a date column entry.
|
||||
- Chain depth is rendered via **indentation + connector lines**, capped at depth-5 (today's max is 4 for the upc.inf.cfi CCR branch). Beyond depth-3 the lines fold to a "in 3 weiteren Schritten" collapsible hint — keeps long chains from running off the screen.
|
||||
|
||||
---
|
||||
|
||||
## §8 Context preservation when drilling
|
||||
|
||||
m: "when a user drills into a single rule from one entry, how to keep the surrounding sequence visible".
|
||||
|
||||
Three options:
|
||||
|
||||
1. **Split-pane** — left: tree of the proceeding; right: linear follow-ups of the focused rule. Tree highlights the focused node.
|
||||
2. **Inline drawer** — clicking a rule expands an inline drawer beneath it showing follow-ups; tree stays in place; drawer is collapsible.
|
||||
3. **Breadcrumb pivot** — single output shape at a time; pivoting linear→tree shows a breadcrumb chain "upc.inf.cfi > Klageerhebung > Klageerwiderung > [Klageerwiderung is here]"; tree renders with the breadcrumb highlighted.
|
||||
|
||||
**Inventor pick: option 2 (inline drawer)** for desktop, **option 3 (breadcrumb)** for mobile. Reasons:
|
||||
- Split-pane (option 1) is the cleanest visualisation but burns half the screen on context the user might not want. Optional via a "Zwei Spalten" toggle for power users.
|
||||
- Inline drawer (option 2) keeps everything in one column with progressive disclosure; the user scrolls through the tree, expands the rule they care about, sees follow-ups, collapses, moves on. Matches how the existing `<details>` flow already works on /admin pages.
|
||||
- Breadcrumb (option 3) is the only sensible mobile pattern — split panes can't, drawers nest awkwardly.
|
||||
|
||||
When in the inline drawer, the focused rule's follow-ups render in the same priority-group shape as the linear view; the per-rule `[Aufnehmen]` / `[Entfernen]` chips work identically; write-back to Akte works identically. The drawer is the linear view embedded.
|
||||
|
||||
---
|
||||
|
||||
## §9 Mobile / narrow viewport
|
||||
|
||||
Today's Verfahrensablauf 3-column swimlane is desktop-heavy. The tree-output proposal collapses better, but still needs careful narrow-viewport rules.
|
||||
|
||||
Layout breakpoints:
|
||||
|
||||
- **< 640px (phone):** single-column. Filter strip collapses to a sticky "Filter" button → bottom-sheet panel with the same chips. Entry-mode picker collapses to a sticky dropdown ("Verfahren wählen ▾"). Tree renders with no indentation lines; depth-N items get a leading "└ ".indent decoration only. Per-card chips ([Aufnehmen] etc.) move to a "..." menu on each card. View-mode toggle moves to a single icon button cycling Pflicht→Gewählt→Alle.
|
||||
- **640-1024px (tablet):** filter strip stays at top but wraps; entry-mode picker becomes tabs; tree renders with proper indentation. View-mode toggle and Akte picker stay inline.
|
||||
- **> 1024px (desktop):** full layout per §3.2. Optional "Zwei Spalten" toggle for the split-pane variant (§8.1).
|
||||
|
||||
**Mobile drill-down (§8 option 3):** clicking a card on phone pushes a new route `?focus=<rule_id>` and renders the linear follow-up view full-screen with a back-arrow breadcrumb. Back arrow restores the tree at the previous scroll position.
|
||||
|
||||
**Filter persistence across viewports:** URL params survive resize, the bottom-sheet panel reflects the same state as the desktop top-strip — same state machine.
|
||||
|
||||
---
|
||||
|
||||
## §10 Worked examples — 3 personas
|
||||
|
||||
### §10.1 Trainee PA — "what's next after Klageerwiderung?"
|
||||
|
||||
Persona: Anna, 6-month PA trainee, doesn't know which proceeding "Klageerwiderung" belongs to.
|
||||
|
||||
1. Opens `/tools/procedures`. No Akte. Lands on "Verfahren wählen" tab (default) but she doesn't want to browse — she wants to find one event.
|
||||
2. Clicks "Geführt" tab. R1: was hat sich ereignet → **filing**. R2: forum → **UPC**. R3: proceeding_type → **upc.inf.cfi** (the only filing-forum option that has "Klage" in its events). R4: event chip-strip → **Klageerwiderung**. R5: perspective — wizard asks because the follow-ups differ → **defendant**.
|
||||
3. Lands on linear follow-ups view. Sees: Pflicht: Replik (claimant, 1 Monat); Empfohlen: Vorl. Einwendungen; Optional: Widerklage; Bedingt: Antrag auf Patentänderung (greyed, with_amend off).
|
||||
4. Wants to know: where does Klageerwiderung sit in the bigger picture? Clicks "Im Ablauf zeigen". Tree renders, with Klageerwiderung highlighted; she sees the SoC root above it, the CCR branch beside it, the cascade of Replik/Duplik below.
|
||||
5. Anna learns the shape. Back to her task — she copies the Replik date into her notes.
|
||||
|
||||
### §10.2 Senior partner — brief client on full upc.inf.cfi ablauf
|
||||
|
||||
Persona: Dr. Becker, senior litigator, briefing a client on Friday about a new UPC matter that hasn't been filed yet.
|
||||
|
||||
1. Opens `/tools/procedures`. No Akte (matter not in Paliad yet).
|
||||
2. Tab: "Verfahren wählen" → clicks `upc.inf.cfi` chip.
|
||||
3. Tree renders. View-mode at default **Gewählt** — shows mandatory + recommended. Becker flips to **Alle Optionen** to brief the client on the full set including conditional branches.
|
||||
4. CCR branch greyed (with_ccr off by default in kontextfrei). Becker ticks `with_ccr` in the flag strip. Tree re-renders; CCR branch lights up.
|
||||
5. Becker wants to print this. Cmd-P / "PDF exportieren" (out of scope for this design but flagged). Tree-with-current-state renders cleanly because nothing depends on viewport hover.
|
||||
6. After the call, Becker creates the Akte in Paliad. Returns to the page with `?project=HL-2025-031`. Same state preserved into the new project — `scenario_flags = {with_ccr: true}` writes to DB on first PATCH.
|
||||
|
||||
### §10.3 Paralegal — enter CMS-received Hinweisbeschluss into Akte
|
||||
|
||||
Persona: Sandra, paralegal, daily CMS triage. Today: a Hinweisbeschluss arrived on HL-2024-001 (upc.inf.cfi).
|
||||
|
||||
1. Opens `/tools/procedures` → picks HL-2024-001 from Akte picker.
|
||||
2. Page auto-derives proceeding = upc.inf.cfi, our_side = claimant, scenario_flags = {with_ccr: true} (already on this matter).
|
||||
3. Default landing: TREE of upc.inf.cfi, scenario state loaded. Sandra sees the full ablauf with the matter's actual selections.
|
||||
4. She knows the event is a Hinweisbeschluss → uses the search box (top right corner of the unified page, available in any mode) → types "Hinweis".
|
||||
5. Search popover shows 1 result: `upc.inf.cfi.cmo_review` (Antrag auf CMO-Überprüfung). Sandra clicks → tree scrolls + highlights the rule; drawer expands beneath it showing the follow-up rule `upc.inf.cfi.cmo_review_resp` with computed date (today + R.333.2 duration).
|
||||
6. Drawer footer has "In Akte speichern" button. Sandra ticks the follow-up rule, sets trigger date = today, audit reason = "CMS-Hinweisbeschluss eingegangen", saves.
|
||||
7. Deadline inserted into HL-2024-001. Sandra returns to her queue.
|
||||
|
||||
Total clicks: 5 (open tool, search, click result, tick, save). No mode-switching, no URL-jumping, no two-tab juggling.
|
||||
|
||||
---
|
||||
|
||||
## §11 Migration plan
|
||||
|
||||
Five-slice train. Each slice ships as one PR. P0 is the model layer atlas already designed; everything below is surface-layer on top.
|
||||
|
||||
| Slice | Mig | What ships | Reversible? |
|
||||
|---|---|---|---|
|
||||
| **U0 — Shared filter-strip component** | — | Extract Mode A's filter strip + Verfahrensablauf's filter chips into one `<FilterStrip>` component used by both pages (still two URLs). Standardise chip names, order, colour. Cross-link buttons in both directions. | Yes — code-only |
|
||||
| **U1 — New unified page at `/tools/procedures`** | — | New route + page shell. Carries Akte picker, filter strip, entry-mode tab control. Initially shows TREE view only (lifts from /tools/verfahrensablauf without removing the original). | Yes — route addition |
|
||||
| **U2 — Linear output + drawer + cross-pivot** | — | Embed the Mode A/B result-view rendering as an inline drawer in U1. Cross-pivot "Im Ablauf zeigen" / "Folge-Fristen anzeigen" wired. Search box top-right available in all modes. | Yes — code-only |
|
||||
| **U3 — Entry mode tabs (Direkt / Geführt / Verfahren / Aus Akte)** | — | Wire Mode A search + Mode B wizard as additional entry tabs on `/tools/procedures`. All four entry paths converge on either tree or linear output depending on what the user picked. | Yes — code-only |
|
||||
| **U4 — Redirects + deprecation** | — | **Per m's Q11 (§11.5): hard cut, no dual-shipping.** `/tools/fristenrechner?…` → 301 → `/tools/procedures?mode=direkt&…` (preserve query params). `/tools/verfahrensablauf?…` → 301 → `/tools/procedures?mode=ablauf&…`. Sidebar + cmd-K updated in the same PR. Old `*.tsx` files deleted. No `?legacy=1` escape. | Reversible only by revert PR |
|
||||
|
||||
**Constraint:** U0-U3 are independent of atlas P0-P3 and can ship in parallel (different files). U4 should land after atlas P3 (`/tools/verfahrensablauf` tree) so the redirect target carries the full tree shape from day 1. If atlas P3 slips, U4 stays in the queue.
|
||||
|
||||
**No DB migration.** All state lives in `projects.scenario_flags` (atlas P0) + localStorage. URL param schema is additive.
|
||||
|
||||
**Pre-deploy gauntlet:** kontextfrei + Akte modes × each entry path × tree + linear output = 16 path/output combinations. Plus mobile narrow viewport for all 4 entry paths. Plus URL deep-link restore for each saved-state shape.
|
||||
|
||||
---
|
||||
|
||||
## §11.5 m's decisions (2026-05-27)
|
||||
|
||||
All 12 questions answered via `AskUserQuestion` in 3 batches of 4. 9 picks on-recommendation; 3 diverged from the inventor pick. Decisions below; raw question list preserved in §12 as the historical record.
|
||||
|
||||
### Tier 1 — does the unification happen at all & what does it look like?
|
||||
|
||||
- **Q1 (Unify vs Align): Full unification — one URL.** [= recommendation] **Locks §3.2.** The four reader surfaces (Fristenrechner Mode A + Mode B + result + Verfahrensablauf) fold into a single page with entry-mode tabs and two output shapes. Aligned-but-separate (§3.3) is dropped from the plan.
|
||||
- **Q2 (URL/Name): `/tools/procedures` — English.** [≠ recommendation; m diverged from inventor's `/tools/verfahren` pick] m's verbatim:
|
||||
> just one, but english name - call it tools/procedures ...
|
||||
**Locks §3.2 + §11 (renames `/tools/verfahren` → `/tools/procedures` throughout).** Rationale: the codebase convention is "English in code, German in UI" (project CLAUDE.md: "All code, table names, Go types, service names, URL paths, API endpoints, file names — English"). `/tools/procedures` follows that rule; the inventor's `/tools/verfahren` strawman broke it. The German sidebar entry stays "Verfahren & Fristen" (Q12) — the URL is the developer surface, the label is the user surface.
|
||||
- **Q3 (Default entry / search shape): All entry modes as tabs + text search combined with dimension filters.** [≠ recommendation; m reframed the question] m's verbatim:
|
||||
> yeah, different tabs, right?! I think we need to have all of the named ones. And we can combine a text search with filters for the dimensions of the event
|
||||
**Locks §3.2 + §5 + reshapes §4.** All four named entry paths (Verfahren wählen / Direkt suchen / Geführt / Aus Akte) are visible as tabs simultaneously. The search box is part of the filter strip at the top of the page and composes with the chip filters (Forum / Verfahren / event_kind / Partei) at all times. The "Direkt suchen" tab still exists for the explicit search-first workflow, but the search input is also live in tree mode (top-of-page filter strip) — meaning a user browsing a proceeding can refine the tree's rendered set by typing into the same search box that filters Mode A. The default landing question ("which tab is active first") becomes a secondary concern: any of the four tabs is one click away. Default behaviour: first tab in the strip ("Verfahren wählen") is selected on cold open with no Akte, but the URL preserves the user's last-active tab if returning via a deep-link.
|
||||
- **Q4 (Akte default behaviour): TREE of the Akte's proceeding.** [= recommendation] **Locks §3.2 + §10.3.** Akte picker triggers auto-derivation of `proceeding_type` + `our_side` + `scenario_flags`, lands on the tree view with the matter's state loaded.
|
||||
|
||||
### Tier 2 — tree mechanics + visual style
|
||||
|
||||
- **Q5 (Tree shape): Both vertical tree + 3-column swimlane, with a toggle.** [= recommendation] **Locks §7.** Default desktop = vertical indented tree (clean chain depth, mobile-translatable); "Schwimmbahnen" toggle reveals the 3-column swimlane (Unsere Seite | Gericht | Gegnerseite) for side-comparison. Toggle state in `localStorage["procedures:tree_shape"]` (per-user, not per-Akte).
|
||||
- **Q6 (Cross-pivot): Inline drawer beneath the card.** [= recommendation] **Locks §8.** Clicking a rule card expands an inline drawer with the linear follow-up view (priority groups + write-back footer). Tree stays in place above. Multiple drawers can be open. Drawer carries the same per-rule selection chips as the tree, so writes propagate to scenario_flags identically.
|
||||
- **Q7 (Search position): Always-visible search bar in the filter strip.** [= recommendation] **Locks §4 + §3.2.** Search input lives in the top filter strip next to the chip groups; available in every output mode. Composes with chip filters via AND semantics (chip filters narrow the corpus, search ranks within the narrowed set). This is what m's Q3 reframe asked for.
|
||||
- **Q8 (Cross-party rows in tree): Show with Gegenseitig badge + muted style.** [= recommendation] **Locks §7.** Tree renders the full graph including opponent rows, muted + badged consistently with the linear view. Identical to atlas's locked treatment for the linear view (`design-deadline-system-revision-2026-05-27.md` §2.4).
|
||||
|
||||
### Tier 3 — mobile + migration
|
||||
|
||||
- **Q9 (Mobile tree shape): Single-column with `└` indent decorator.** [= recommendation] **Locks §9.** Phone-narrow render keeps depth via leading-marker indentation; SVG connector lines drop; cards stack vertically. Resize back to tablet/desktop restores the full tree with connector lines.
|
||||
- **Q10 (Mobile drill): Push new route with breadcrumb back.** [= recommendation] **Locks §9.** Clicking a card on phone pushes `?focus=<rule_id>` and renders the full-screen linear follow-up view with a back-arrow breadcrumb. Tree scroll position preserved on back. Inline drawer is desktop-only.
|
||||
- **Q11 (Migration window): Hard cut — no dual-shipping window.** [≠ recommendation; m diverged from "2 weeks 302"] m's verbatim:
|
||||
> not at all
|
||||
**Locks §11 (rewrites the U4 slice).** When `/tools/procedures` ships, `/tools/fristenrechner` and `/tools/verfahrensablauf` flip directly to redirects (301 permanent, no `?legacy=1` escape hatch). Sidebar entries swap to the new entry in the same release. cmd-K palette swaps to the new entry. No 2-week dual-shipping window. Rationale (interpreted): the audience is internal HLC lawyers (~50 users, all on the same release rhythm). A 2-week dual ship adds complexity for almost no benefit; m would rather flip and fix any broken bookmark via direct comm.
|
||||
- **Q12 (Sidebar): One entry "Verfahren & Fristen".** [= recommendation] **Locks §11.** Single sidebar item (German label) pointing at `/tools/procedures` (English URL). cmd-K palette updated to one entry "Verfahren & Fristen" with `/tools/procedures` as the action.
|
||||
|
||||
### §11.5.1 Changes triggered by m's divergences
|
||||
|
||||
Three picks changed the design beyond ratification. Summarised here so the coder reads the *current* design, not the pre-grilling strawman.
|
||||
|
||||
1. **URL rename `/tools/verfahren` → `/tools/procedures`** (Q2). Replaces every URL reference in §3.2, §4, §5, §10, §11, §14. Page name in the codebase: `frontend/src/procedures.tsx`. Sidebar label stays German ("Verfahren & Fristen"). Internal Go types stay English (`ProceduresPage`, etc.).
|
||||
2. **All-tabs-visible + search-as-filter** (Q3). Replaces the strawman's "pick a single default tab" wording in §3.2 + §4. The unified page now renders all four entry-mode tabs at all times (Verfahren wählen / Direkt suchen / Geführt / Aus Akte). The search box is in the filter strip alongside the chip filters and composes with them in every output mode (tree + linear). The "Direkt suchen" tab remains, but its function shifts: it's the *search-first cold start* tab; once the user has any output (tree or linear), the search box at the top of the page is the canonical re-narrowing affordance. The wizard tab ("Geführt") and the Akte tab still exist as explicit workflows.
|
||||
3. **Hard cut, no dual-ship** (Q11). Slice U4 in §11 is rewritten: 301 redirects on `/tools/fristenrechner` + `/tools/verfahrensablauf` to the new page; no `?legacy=1` escape; the old `*.tsx` files are deleted in the same PR. Bookmarks resolve via the 301; no in-product affordance points at the legacy URL after the merge.
|
||||
|
||||
### §11.5.2 What stays unchanged
|
||||
|
||||
The other 9 picks (Q1, Q4-Q10, Q12) ratified the inventor proposal. The full unification at a single URL with two output shapes (tree + linear drawer), four entry paths, shared selection state via `projects.scenario_flags`, vertical tree + swimlane toggle, mobile `└` decorator + breadcrumb-back drill-down, single sidebar entry — all locked as drafted in §1-§11.
|
||||
|
||||
---
|
||||
|
||||
## §12 Open questions for m
|
||||
|
||||
Twelve questions, batched 4 + 4 + 4 for `AskUserQuestion`. The first batch is **must-answer** (decides the unification's existence + URL shape); the second is **shape** (tree mechanics + visual style); the third is **mobile + migration** (operational).
|
||||
|
||||
Will be answered via `AskUserQuestion` per the inventor SKILL; m's picks fold back into a `§12.5 m's decisions (2026-05-27)` section at the top of this file before the "DESIGN READY FOR REVIEW" signal.
|
||||
|
||||
### Batch 1 — does the unification happen at all & what does it look like?
|
||||
|
||||
- **Q1 (Unify vs Align):** Fold the four reader surfaces into `/tools/procedures` (full unification §3.2), or keep `/tools/fristenrechner` and `/tools/verfahrensablauf` as separate URLs and just tighten alignment (§3.3)?
|
||||
- **Q2 (Naming):** If unifying — what's the page name? `/tools/verfahren` (generic German, my original pick), `/tools/fristenrechner` (lawyers know this one — repurpose as the supermarket), or `/tools/ablauf` (closest to what it does)? (m diverged with `/tools/procedures` — see §11.5.)
|
||||
- **Q3 (Default entry mode):** When the user opens `/tools/procedures` with no URL params and no Akte, which entry tab is active? "Verfahren wählen" (browse, my pick), "Direkt suchen" (power), "Geführt" (onboarding).
|
||||
- **Q4 (Akte default behaviour):** When user picks an Akte from the picker, default landing — TREE of the Akte's proceeding (my pick) or "remember last view" per-user.
|
||||
|
||||
### Batch 2 — tree mechanics + visual style
|
||||
|
||||
- **Q5 (Tree shape):** Desktop tree rendering — vertical indented tree (my pick), 3-column swimlane (current Verfahrensablauf default), or both with a "Schwimmbahnen" toggle.
|
||||
- **Q6 (Cross-pivot affordance):** When clicking a rule card in the tree to see its follow-ups — inline drawer beneath the card (my pick), split-pane (tree left + linear right), or full-page push (replaces tree, breadcrumb back).
|
||||
- **Q7 (Mode A search location):** The free-text "Direkt suchen" entry — only as a top-tab (my pick, with a small search icon always available in tree mode), always-visible search bar at top, or only inside the "Direkt" tab.
|
||||
- **Q8 (Cross-party rows in linear):** Atlas locked "show with Gegenseitig badge, unchecked default, unconditionally excluded from write-back". In tree mode, same treatment (my pick) or hide cross-party rows entirely by default and surface via "Gegenseite einblenden" toggle.
|
||||
|
||||
### Batch 3 — mobile + migration
|
||||
|
||||
- **Q9 (Mobile tree shape):** On phones (< 640px) — single-column indented list with leading "└" decorator (my pick), single-column flat list (no indentation), or chronological-timeline view (auto-pivots when narrow).
|
||||
- **Q10 (Mobile drill-down):** Clicking a card on phone — push new route with breadcrumb-back (my pick), inline drawer (cramped on small screens), or modal sheet.
|
||||
- **Q11 (Migration window):** After the unified page ships — 2-week dual-shipping with 302 redirects (my pick, matches t-paliad-322 S5 pattern), 1-week, or 4-week.
|
||||
- **Q12 (Sidebar entries):** Sidebar today has "Fristenrechner" + "Verfahrensablauf" as separate items. Post-merge — one entry "Verfahren & Fristen" (my pick), keep both with both → same URL, or pick one ("Fristenrechner" or "Verfahrensablauf") as the canonical name.
|
||||
|
||||
---
|
||||
|
||||
## §13 Out of scope
|
||||
|
||||
- Calculator changes (`pkg/litigationplanner.CalculateRule`). Working.
|
||||
- Editorial backfill (curie owns t-paliad-333 in parallel).
|
||||
- /admin/procedural-events as a read surface — different audience.
|
||||
- /projects/{id} Verlauf — per-Akte actuals; sister tool.
|
||||
- SmartTimeline / `ProjectionService` — per-project read view, downstream consumer.
|
||||
- youpc.org/deadlines — cross-repo snapshot consumer.
|
||||
- Outlook / Calendar sync UI.
|
||||
- PDF export of the tree (mentioned in §10.2 but not designed here).
|
||||
- Bulk-write affordances beyond the existing `/deadlines/bulk` endpoint.
|
||||
- Multi-project comparison views (would belong in SmartTimeline at Patent / Litigation / Client level, not in `/tools/procedures`).
|
||||
- Translation between languages of free-text scenario flag names.
|
||||
|
||||
---
|
||||
|
||||
## §14 Synthesis links
|
||||
|
||||
- mBrian: file as `[synthesis]` linked `triggered_by` t-paliad-334; `related_to` athena's assessment + atlas's deadline-system-revision design + cronus's earlier Fristenrechner overhaul design.
|
||||
- Cross-refs in this repo: `docs/assessment-deadline-system-2026-05-27.md` (athena), `docs/design-deadline-system-revision-2026-05-27.md` (atlas), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26), `docs/design-event-card-choices-2026-05-25.md` (existing per-card choice).
|
||||
- Gitea: m/paliad#151 (this design), m/paliad#149 (atlas Phase 2), m/paliad#146 (cronus 2026-05-26 Fristenrechner overhaul, S1-S6 shipped).
|
||||
- Coder phase (deferred per inventor SKILL): runs after m ratifies via AskUserQuestion. Slice ordering per §11. NOT cronus (parked at "DESIGN READY FOR REVIEW"). A pattern-fluent Sonnet coder picks up U0 first; U1-U3 sequential; U4 gated on atlas P3 landing.
|
||||
495
docs/plans/prd-docforge-2026-05-29.md
Normal file
495
docs/plans/prd-docforge-2026-05-29.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# PRD — `docforge`: a modular document-generator engine
|
||||
|
||||
**Task:** t-paliad-349 (m/paliad#157) · **Author:** leibniz (inventor) · **Date:** 2026-05-29
|
||||
**Status:** DESIGN — awaiting head's go/no-go on the coder shift.
|
||||
**Supersedes nothing.** Extends and re-homes the submission generator designed in
|
||||
`docs/design-submission-generator-2026-05-19.md`, `…-v2-2026-05-26.md`, and
|
||||
`docs/design-submission-page-2026-05-22.md`.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises
|
||||
|
||||
### 0.1 What this is
|
||||
|
||||
m wants the paliad "doc generator" pulled apart into a clean, reusable engine.
|
||||
Verbatim direction (2026-05-29):
|
||||
|
||||
> I want to be able to create and modify word documents, using variables inside
|
||||
> the documents, "editing them live" and preview the results, export in the end.
|
||||
> We should have all that modular to keep it clean. The editor is something else
|
||||
> than the importing, exporting, variable exchange, data fetching etc.
|
||||
>
|
||||
> Currently I can't upload the base document to insert variables into to create a
|
||||
> template — and then later I want to fill the template using data, modifying it
|
||||
> manually where necessary, then exporting.
|
||||
|
||||
Two distinct user surfaces fall out of that:
|
||||
|
||||
- **Authoring** — upload a base `.docx` → place variable slots into it → save as a
|
||||
reusable template. *This is the gap that does not exist today.*
|
||||
- **Generation** — pick a template → bind variables to project data → manually edit
|
||||
where needed (live editor + preview) → export `.docx`.
|
||||
|
||||
### 0.2 Today's state (audited 2026-05-29, verified against the live tree)
|
||||
|
||||
The current submission generator is ~250 KB of Go plus a 115 KB editor bundle:
|
||||
|
||||
- `internal/services/submission_vars.go` — variable resolution across **7 namespaces**
|
||||
(`firm.*`, `today.*`, `user.*`, `project.*`, `parties.*`, `procedural_event.*`
|
||||
+ `rule.*` legacy aliases, `deadline.*`). Resolution is a **push** model: each
|
||||
namespace is a hardcoded `addXxxVars(bag PlaceholderMap, …)` function mutating a
|
||||
shared `map[string]string`. There is **no interface and no registry** — adding a
|
||||
namespace means hand-editing `Build` to call a new function.
|
||||
- `internal/services/submission_merge.go` — placeholder substitution. The regex
|
||||
(line 95, verified) is `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`.
|
||||
Two-pass: single-run replace inside each `<w:t>`, then
|
||||
cross-run merge for fragmented placeholders. HTML preview wraps `(key,value)` in
|
||||
Private-Use-Area sentinels so `emitTextWithDraftVars` can reconstruct
|
||||
`<span class="draft-var" data-var="key">…</span>` for click-to-jump.
|
||||
- `internal/services/submission_md.go` — Markdown → OOXML runs. `parseInlineSpans`
|
||||
(lines 393–446) tokenises bold/italic and **preserves `{{…}}` verbatim**.
|
||||
- `internal/services/submission_compose.go` — assembles the final `.docx`: unzip base,
|
||||
render each included section's Markdown to OOXML, splice between
|
||||
`{{#section:KEY}}…{{/section:KEY}}` anchors, patch hyperlink rels, repack, then run
|
||||
the placeholder pass.
|
||||
- `internal/services/submission_{draft,section,building_block,base}_service.go` — the
|
||||
draft/section/building-block/base data model + CRUD.
|
||||
- `internal/handlers/submission_{drafts,sections,building_blocks,bases}.go` — the HTTP
|
||||
wire (the 53 KB `submission_drafts.go` is the bulk).
|
||||
- `frontend/src/client/submission-draft.ts` — the editor UI (**one `.ts` bundle; there is
|
||||
no `submission-draft.tsx`** — the brief was wrong on this point).
|
||||
|
||||
**OOXML approach (verified):** pure `archive/zip` + string manipulation of
|
||||
`word/document.xml`. **No third-party docx library** — `go.mod` has none.
|
||||
`lukasjarosch/go-docx` appears *only in a comment* (`submission_merge.go:13`)
|
||||
documenting why it was rejected (it refuses sibling placeholders in one run). The base
|
||||
stays byte-for-byte identical outside the regions we touch.
|
||||
|
||||
**Reference model:** `pkg/litigationplanner/` (t-paliad-292). The package **owns its
|
||||
types** and exposes **interfaces for stateful inputs** (`Catalog`, `HolidayCalendar`,
|
||||
`CourtRegistry`); paliad implements them against Postgres, youpc.org against an embedded
|
||||
JSON snapshot. `doc.go` is the package doc; `types_wire_test.go` locks the JSON contract.
|
||||
**docforge mirrors this packaging discipline exactly.**
|
||||
|
||||
### 0.3 Premise correction (load-bearing)
|
||||
|
||||
The brief lists **two consumers in scope: paliad + upc-commentary**. Verified against the
|
||||
live repo: **`UPCommentary/upc-kommentar` is Bun + SvelteKit + TypeScript + PLpgSQL —
|
||||
zero Go.** A SvelteKit app cannot `import` a Go `pkg/`. m's resolution (2026-05-29):
|
||||
**upc-kommentar is out of scope as a live consumer for now.** docforge is a pure Go
|
||||
package; paliad imports it in-process like `litigationplanner`. The interfaces are
|
||||
designed so an HTTP veneer (for a future TS consumer) is *addable later* without rework —
|
||||
but none is built now. See §4 D-P1 and §8.
|
||||
|
||||
### 0.4 Locked constraints (m, confirmed)
|
||||
|
||||
- One Go module: `pkg/docforge`. Same packaging model as `pkg/litigationplanner`.
|
||||
- docforge **owns no database tables** — data flows in via interfaces.
|
||||
- `.docx` first; engine designed format-pluggable for `.pdf`/`.html`/`.md` later.
|
||||
- Authoring and Generation are **distinct pages**, but share the engine + the generic
|
||||
editor plumbing.
|
||||
- Generation must support **minor manual content edits** (live editor, not just
|
||||
data-binding).
|
||||
- Editor stays per-consumer; the **generic UX plumbing** is extracted into a reusable UI
|
||||
package now.
|
||||
- The neutral model must be **lossless for our own `.docx`** (the uploaded base is an
|
||||
opaque carrier, preserved byte-for-byte outside touched regions).
|
||||
|
||||
### 0.5 Contracts that MUST survive the refactor
|
||||
|
||||
These are invariants. The migration (§6) protects each by moving it *with its file and its
|
||||
test*, unchanged:
|
||||
|
||||
1. **`placeholderRegex`** = `` `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}` `` — underscores
|
||||
and dots legal in keys; whitespace inside braces trimmed; case-sensitive.
|
||||
2. **Last night's underscore fix** (commit `b78a984`): `parseInlineSpans` short-circuits
|
||||
the inline scanner on `{{` and copies the placeholder literally to `}}`, so
|
||||
`{{project.case_number}}` is never mangled to `{{project.casenumber}}`.
|
||||
3. **`data-var` contract** — `data-var="<key>"` on both `.draft-var` preview spans and
|
||||
`.submission-draft-var-input` sidebar inputs; the click-to-jump and focus-highlight are
|
||||
bijective across repaints.
|
||||
4. **Missing-value markers** — `[KEIN WERT: key]` (DE) / `[NO VALUE: key]` (EN) render
|
||||
inline, never an error.
|
||||
5. **Legacy aliases** — `procedural_event.X ≡ rule.X` resolve identically
|
||||
(`submission_vars_aliases_test.go`); party variables emit comma-joined, indexed, and
|
||||
flat-legacy forms (`submission_vars_parties_test.go`).
|
||||
6. **Section anchor syntax** — `{{#section:KEY}}…{{/section:KEY}}`, `KEY` matched against
|
||||
`[A-Za-z0-9_]+`.
|
||||
7. **No binary retention** — exported `.docx` is regenerable from inputs; only audit rows
|
||||
persist (`system_audit_log` `submission.exported` + `project_events`).
|
||||
8. **V1 fallback path** — pre-Composer drafts (`base_id IS NULL`, no section rows) render
|
||||
via the pure-placeholder path. No auto-upgrade.
|
||||
9. **`{{…}}` pass-through** — the Markdown walker emits placeholders verbatim; the merge
|
||||
pass substitutes them afterward. Order is load-bearing (substitution runs *inside*
|
||||
compose, after section splicing).
|
||||
|
||||
---
|
||||
|
||||
## §1 Goals
|
||||
|
||||
**G1.** Extract the format-neutral document machinery (Markdown→OOXML walker, OOXML
|
||||
merge/compose, placeholder engine, `.dotm`→`.docx`) into `pkg/docforge` with a clean
|
||||
public surface and zero behavior change at the extraction step.
|
||||
|
||||
**G2.** Introduce a **neutral document/template model** so importers produce it, the engine
|
||||
binds variables on it, and exporters render it out — with `.docx` as the first
|
||||
importer+exporter pair, not the universe. Lossless for our own `.docx`.
|
||||
|
||||
**G3.** Replace the hardcoded `addXxxVars` push with a **`VariableResolver` interface per
|
||||
namespace** + a `ResolverSet` that composes them, preserves aliases, and exposes the key
|
||||
catalogue (label + group) so the frontend variable form/palette becomes data-driven
|
||||
instead of hardcoded in TS.
|
||||
|
||||
**G4.** Build the **Authoring surface**: upload `.docx` → WYSIWYG render → click/select →
|
||||
insert `{{slot}}` → save template. Closes the gap m named.
|
||||
|
||||
**G5.** Refactor **Generation** onto docforge + uploaded templates, preserving the live
|
||||
editor, preview, manual-edit, and export — and every contract in §0.5.
|
||||
|
||||
**G6.** Extract the **generic editor UX** into `frontend/src/lib/docforge-editor/`,
|
||||
consumed by both the generation and authoring shells.
|
||||
|
||||
**Non-goals (this PRD):** implementation, migration SQL, code. Formats beyond `.docx`
|
||||
(interface only). Live upc-kommentar integration. Multi-user concurrent editing of one
|
||||
draft. An HTTP service veneer.
|
||||
|
||||
---
|
||||
|
||||
## §2 User journeys
|
||||
|
||||
### 2.1 Authoring (new)
|
||||
|
||||
1. m opens **`/admin/templates`** (or `/templates/new`) and uploads a base `.docx`
|
||||
(firm letterhead with caption layout, signature block, etc.).
|
||||
2. docforge's `.docx` importer parses the upload into a **carrier** (opaque OOXML kept
|
||||
intact) + a renderable preview. The page shows a **WYSIWYG-ish render** of the document.
|
||||
3. m highlights a piece of text — e.g. `Az. 4c O 12/23` — and a **variable palette**
|
||||
(sourced from the `ResolverSet.Keys()` catalogue, grouped DE/EN) lets him pick
|
||||
`project.case_number`. The selection is **replaced with a `{{project.case_number}}`
|
||||
slot**; a `template_slots` row records the slot key + its anchor position.
|
||||
4. He repeats for every variable region, saves, and the template becomes pickable in
|
||||
Generation. (Editing the template later creates a new **version** — see §4 D-A3.)
|
||||
|
||||
**Scope guard:** v1 authoring places **text-level slots in body paragraphs**. Slots in
|
||||
headers/footers/tables/text-boxes are a flagged follow-up (§7 note), because the
|
||||
click→OOXML-run mapping there is materially harder.
|
||||
|
||||
### 2.2 Generation (refactor of today)
|
||||
|
||||
1. Lawyer picks a template (uploaded template *or* a legacy Gitea base — both supported
|
||||
during transition) for a submission code, optionally project-scoped.
|
||||
2. A **draft** is created. Its template **structure is snapshotted** at create
|
||||
(§4 D-A3) so later template edits don't shift an in-flight draft.
|
||||
3. The sidebar shows the variable form (data-driven from `ResolverSet.Keys()`); the
|
||||
resolved bag is merged with the lawyer's overrides; the live preview renders with
|
||||
`data-var` click-to-jump; manual prose edits autosave (500 ms debounce).
|
||||
4. Export → docforge binds the model + carrier + resolved variables → `.docx` bytes
|
||||
stream as a download. Audit rows written. No binary retained.
|
||||
|
||||
### 2.3 upc-kommentar parallel journey (deferred — validates the abstractions)
|
||||
|
||||
Not built now, but the abstractions are sized for it: upc-kommentar authors work in
|
||||
**Markdown** (and want to import **foreign doc/docx** as input — m, 2026-05-29 Q4). When
|
||||
it becomes a consumer, it would: implement its own `VariableResolver`(s) over its Postgres
|
||||
(commentary metadata), feed Markdown through docforge's **markdown importer** into the
|
||||
neutral model, edit live in its own Svelte shell (reusing the *wire contract*, not Go
|
||||
code), and export. The Go engine is reached over an HTTP veneer added at that point. This
|
||||
journey is the litmus test for §3's seams: **a new consumer adds resolvers + a transport,
|
||||
touches no engine internals.**
|
||||
|
||||
---
|
||||
|
||||
## §3 Module shape
|
||||
|
||||
### 3.1 Package tree
|
||||
|
||||
```
|
||||
pkg/docforge/
|
||||
doc.go // package doc (litigationplanner-style)
|
||||
model.go // neutral model: Document, Block, InlineSpan, Slot
|
||||
template.go // Template, TemplateSlot, Carrier
|
||||
variables.go // VariableResolver interface, VariableKey, ResolverSet, alias registry
|
||||
bind.go // binding engine: walk model, resolve slots, apply missing-marker policy
|
||||
render.go // RenderHTML (preview w/ data-var spans) — format-neutral entry
|
||||
importer.go // Importer interface
|
||||
exporter.go // Exporter interface
|
||||
store.go // TemplateStore interface (carrier bytes + slot persistence contract)
|
||||
errors.go // sentinel errors (ErrUnknownTemplate, ErrUnboundSlot, …)
|
||||
placeholder.go // placeholderRegex + substitution primitives (THE locked grammar)
|
||||
types_wire_test.go // locks the JSON wire shape consumed by the TS editor
|
||||
docx/ // the .docx adapter — first importer + exporter
|
||||
importer.go // DocxImporter: parse .docx -> Carrier + detect/locate slots
|
||||
exporter.go // DocxExporter: (model + carrier + vars) -> .docx bytes [today's compose+merge]
|
||||
ooxml.go // archive/zip + document.xml manipulation [today's submission_merge/compose internals]
|
||||
md_to_ooxml.go // Markdown -> OOXML runs [today's submission_md walker + the b78a984 fix]
|
||||
dotm.go // ConvertDotmToDocx [today's pre-pass]
|
||||
markdown/ // markdown importer (input content; foreign-docx import is a later sibling)
|
||||
importer.go // parse Markdown -> neutral blocks
|
||||
```
|
||||
|
||||
**What lives in docforge vs paliad:**
|
||||
|
||||
| Concern | Home | Why |
|
||||
|---|---|---|
|
||||
| Neutral model, binding, preview-render | `docforge` | format-neutral core |
|
||||
| `VariableResolver` interface + `ResolverSet` | `docforge` | the seam m wants clean |
|
||||
| Placeholder grammar + substitution | `docforge` | shared invariant (§0.5.1) |
|
||||
| `.docx` importer + exporter, MD→OOXML walker | `docforge/docx` | first format adapter (ships *inside* the pkg, like litigationplanner's embedded snapshot) |
|
||||
| Markdown importer | `docforge/markdown` | input-format adapter |
|
||||
| Concrete resolvers (`project`, `parties`, `firm`, `user`, `today`, `deadline`, `procedural_event`) | **paliad** `internal/…` | they read paliad's DB/services |
|
||||
| `TemplateStore` impl (Postgres bytea) | **paliad** | docforge owns no tables |
|
||||
| Section / building-block model, submission codes | **paliad** | consumer-specific composition concepts |
|
||||
| HTTP handlers, editor UI, authoring page | **paliad** | wire + per-consumer UI |
|
||||
|
||||
### 3.2 The neutral model + the carrier (resolving "intermediate, but lossless docx")
|
||||
|
||||
```go
|
||||
// A Document is the format-neutral content model importers produce and exporters consume.
|
||||
type Document struct {
|
||||
Blocks []Block
|
||||
}
|
||||
type Block struct {
|
||||
Kind BlockKind // paragraph | heading | list_item | blockquote | section_marker
|
||||
Style string // logical style key (mapped to a base stylemap on export)
|
||||
Spans []InlineSpan // text runs (bold/italic/link) + Slots
|
||||
// …list level, section key, etc.
|
||||
}
|
||||
type InlineSpan struct {
|
||||
Text string
|
||||
Bold bool
|
||||
Italic bool
|
||||
Link string
|
||||
Slot *Slot // non-nil => this span is a variable slot, not literal text
|
||||
}
|
||||
type Slot struct {
|
||||
Key string // e.g. "project.case_number" — the placeholder grammar key
|
||||
}
|
||||
```
|
||||
|
||||
**The carrier keeps the lossless guarantee.** The uploaded `.docx` chrome
|
||||
(letterhead, styles, caption, signature) is **never round-tripped through `Document`**.
|
||||
It is held as an opaque `Carrier` (the original OOXML), and the exporter splices the
|
||||
rendered neutral content into the carrier's named anchors, then substitutes slots — exactly
|
||||
today's compose mechanism, now formalised:
|
||||
|
||||
```go
|
||||
type Carrier struct {
|
||||
Format string // "docx"
|
||||
Bytes []byte // original upload, preserved byte-for-byte outside anchor regions
|
||||
Anchors []Anchor // {{#section:KEY}}…{{/section:KEY}} positions + slot positions
|
||||
}
|
||||
```
|
||||
|
||||
So **two layers**: editable content = `Document` (neutral, format-pluggable); base chrome =
|
||||
`Carrier` (opaque, lossless). Foreign-docx *import as input content* (Q4) does parse into
|
||||
`Document` and **is inherently lossy** — flagged as a boundary (§8), distinct from the
|
||||
lossless export of *our* templates.
|
||||
|
||||
### 3.3 The variable resolver seam (G3)
|
||||
|
||||
```go
|
||||
// VariableResolver answers keys within one dotted namespace.
|
||||
type VariableResolver interface {
|
||||
Namespace() string // e.g. "project"
|
||||
Resolve(key string) (value string, ok bool)// ok=false => unknown key => missing marker
|
||||
Keys() []VariableKey // catalogue for the palette + sidebar form
|
||||
}
|
||||
type VariableKey struct {
|
||||
Key, LabelDE, LabelEN, Group string
|
||||
}
|
||||
|
||||
// ResolverSet composes namespaced resolvers, registers canonical<->legacy aliases,
|
||||
// and offers BOTH a pull path (Resolve, used during binding) and a push path
|
||||
// (BuildBag, preserving today's resolved_bag/merged_bag wire).
|
||||
type ResolverSet struct{ /* … */ }
|
||||
func (s *ResolverSet) Resolve(key string) (string, bool)
|
||||
func (s *ResolverSet) BuildBag() map[string]string // == today's PlaceholderMap
|
||||
func (s *ResolverSet) Catalogue() []VariableKey // drives the data-driven form/palette
|
||||
func (s *ResolverSet) RegisterAlias(canonical, legacy string)
|
||||
```
|
||||
|
||||
paliad's seven `addXxxVars` functions become seven resolver types implementing this
|
||||
interface. `BuildBag()` reproduces today's flat map exactly (alias parity tests pin it).
|
||||
`Catalogue()` kills the hardcoded `VARIABLE_GROUPS`/`VARIABLE_LABELS` in the TS bundle.
|
||||
**Resolver model = hybrid** (pull-capable interface, push-driven `BuildBag` default —
|
||||
inventor pick, §4 D-I1).
|
||||
|
||||
### 3.4 Wire contract (Go ↔ TS) — preserved, locked by test
|
||||
|
||||
The editor wire stays as-is; `types_wire_test.go` pins it:
|
||||
|
||||
- `GET draft` → `{ draft, resolved_bag, merged_bag, preview_html, rule, parties, sections }`
|
||||
- preview HTML carries `<span class="draft-var" data-var="<key>">…</span>` (built by
|
||||
docforge's `RenderHTML`, today's `emitTextWithDraftVars`).
|
||||
- `PATCH draft` ← `{ variables: PlaceholderMap, … }` (presence-tracked optional fields).
|
||||
- export/preview endpoints unchanged.
|
||||
- **New (authoring):** `POST /api/templates` (upload), `GET /api/templates/:id` (carrier
|
||||
preview + slots), `POST /api/templates/:id/slots` (place slot), `GET /api/docforge/variables`
|
||||
(the `Catalogue()`).
|
||||
|
||||
---
|
||||
|
||||
## §4 Decisions (m's picks, 2026-05-29)
|
||||
|
||||
### Prose-grill resolutions (core metaphor)
|
||||
|
||||
| # | Question | m's decision | Note |
|
||||
|---|---|---|---|
|
||||
| P1 | Cross-language sharing model | **Go pkg only; upc-kommentar out of scope for now, "reuse later somehow"** | Interfaces sized so an HTTP veneer is addable without rework. No service built. |
|
||||
| P2 | Intermediate model? | **Yes — but lossless for our .docx** | → carrier (opaque OOXML) + neutral Document (editable content). §3.2. |
|
||||
| P3 | Authoring slot mechanic | **(b) click-to-insert** | Upload → render → click/select → inject `{{…}}`. |
|
||||
| P4 | Input formats | **Markdown primary; foreign doc/docx import later** | Markdown importer first; foreign-docx import is lossy (§8). |
|
||||
| P5 | Editor sharing | **Build paliad's UI; extract generic UX into a UI package** | `frontend/src/lib/docforge-editor/`. |
|
||||
|
||||
### Structured decisions
|
||||
|
||||
| # | Decision | m's pick | Rationale / divergence |
|
||||
|---|---|---|---|
|
||||
| A1 | Authoring UX | **WYSIWYG inline** | Matches "insert variables into the document". Hardest part — render fidelity + click→run mapping — flagged §7. |
|
||||
| A2 | Template storage | **Postgres bytea (interface-backed)** | m leans (1); flagged Supabase Storage as viable. Resolved: behind a `TemplateStore` interface, bytea impl now, Supabase Storage a one-impl swap later. No schema churn either way. |
|
||||
| A3 | Versioning of existing drafts | **Snapshot at draft-create** | Lawyer's in-flight draft won't shift under them; matches today's section-seeding. |
|
||||
| A4 | Migration strategy | **Extract-in-place, then extend** | Lowest risk to the recent fixes — they move with their files + tests; behavior identical at each step. |
|
||||
| B1 | Package name | **`docforge`** | — |
|
||||
| B2 | Schema scope | **New generic tables** (`templates`, `template_slots`, `template_versions`) | Authoring is domain-neutral; submission_bases (Gitea/section_spec) stays for legacy bases with a converge path. |
|
||||
| B3 | UI package extraction | **Extract now** | Authoring reuses it this cycle — earns its keep, not speculative. |
|
||||
| B4 | Exporter pluggability | **Interface now, docx-only impl** | Cheap insurance; matches "pluggable for later". |
|
||||
|
||||
### Inventor picks (m delegated — "whatever works best")
|
||||
|
||||
| # | Pick | Reasoning |
|
||||
|---|---|---|
|
||||
| I1 | `VariableResolver` = pull-capable interface, push `BuildBag()` default | Preserves today's flat-map wire while enabling on-demand resolution + the `Catalogue()` that data-drives the form. |
|
||||
| I2 | `.docx` adapter ships **inside** `pkg/docforge/docx` | Mirrors litigationplanner shipping its embedded snapshot in-package; keeps the first adapter co-located with the engine it proves. |
|
||||
| I3 | Carrier-vs-Document split (§3.2) | Only way to satisfy "intermediate model" AND "lossless our .docx" simultaneously. |
|
||||
|
||||
---
|
||||
|
||||
## §5 Data model deltas (paliad-side — docforge owns none)
|
||||
|
||||
**New tables** (additive; SQL drafted by the coder, not here):
|
||||
|
||||
- **`paliad.templates`** — `id`, `slug`, `name_de/en`, `kind` (`'submission'` | generic),
|
||||
`source_format` (`'docx'`), `firm`, `is_active`, `created/updated_by`, timestamps,
|
||||
`current_version_id` FK.
|
||||
- **`paliad.template_versions`** — immutable snapshots: `id`, `template_id` FK,
|
||||
`version` int, `carrier_blob` bytea (the `.docx`; or storage ref via `TemplateStore`),
|
||||
`created_at`, `created_by`. Editing a template inserts a new version row.
|
||||
- **`paliad.template_slots`** — `id`, `template_version_id` FK, `slot_key` (the variable
|
||||
key, e.g. `project.case_number`), `anchor` (position encoding — see flag below),
|
||||
`label`, `order_index`. Versioned alongside the carrier.
|
||||
|
||||
**Snapshot semantics (A3):** a draft pins `template_version_id`. Template edits create a
|
||||
new version; existing drafts keep their pinned version. *(Flag for coder: pin
|
||||
`template_version_id` on the draft vs. copy a `template_snapshot` jsonb onto the draft —
|
||||
both satisfy A3; the version-table approach is preferred for auditability but the coder
|
||||
picks based on query ergonomics.)*
|
||||
|
||||
**Touched existing tables:**
|
||||
|
||||
- `submission_drafts` — add nullable `template_version_id` for uploaded-template drafts;
|
||||
**legacy `base_id` path preserved** (extract-in-place ⇒ no data migration of the 11
|
||||
existing drafts; §0.5.8 fallback intact).
|
||||
- `submission_bases`, `submission_sections`, `submission_building_blocks` — **unchanged**.
|
||||
They remain paliad consumer-specific concepts that map onto docforge's neutral model at
|
||||
render time. submission_bases (Gitea-backed) coexists with the new uploaded-template
|
||||
tables during transition; convergence is a later, separate task.
|
||||
|
||||
**Slot anchor encoding (flag for coder):** how a `template_slots.anchor` records *where*
|
||||
in the carrier OOXML the slot sits (run index + offset, vs. a stable sentinel token
|
||||
injected into the carrier at authoring time). The sentinel-token approach is likely
|
||||
simpler and reuses the existing cross-run substitution machinery — resolve in
|
||||
implementation chat.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration plan (protects working code + the recent fixes)
|
||||
|
||||
**Principle:** extract-in-place (A4). Each step **compiles, passes the moved tests, and
|
||||
leaves observable behavior identical.** The recent fixes travel *with their files*:
|
||||
|
||||
- The **b78a984 underscore fix** → `pkg/docforge/docx/md_to_ooxml.go` (was
|
||||
`submission_md.go` `parseInlineSpans`), `submission_md_test.go` moves alongside.
|
||||
- **`placeholderRegex`** → `pkg/docforge/placeholder.go`; its tests move.
|
||||
- **`data-var` / `emitTextWithDraftVars`** → `pkg/docforge/render.go` (`RenderHTML`);
|
||||
wire test moves and is pinned in `types_wire_test.go`.
|
||||
- **Cross-run merge, `.dotm`→`.docx`, anchor splicing** → `pkg/docforge/docx/`; tests move.
|
||||
- **Building-block + section model, submission codes, the 7 concrete resolvers** stay in
|
||||
`internal/` (consumer-specific) — now calling into docforge.
|
||||
|
||||
**Safety rails per step:** (1) `go build ./...` green; (2) the moved test files green; (3)
|
||||
a golden-export check — generate a known draft before and after the step, assert byte-equal
|
||||
`.docx`; (4) the live preview HTML for a fixture draft is string-equal (the `data-var`
|
||||
contract). No step ships until all four hold.
|
||||
|
||||
**What is explicitly NOT migrated:** the 11 pre-Composer drafts (`base_id IS NULL`) keep
|
||||
the v1 fallback render path; no auto-upgrade (§0.5.8).
|
||||
|
||||
---
|
||||
|
||||
## §7 Slice train
|
||||
|
||||
Tracer-bullet vertical slices, each independently shippable. Slices 1–3 are pure
|
||||
behavior-preserving refactors (the risky-to-working-code part, front-loaded under golden
|
||||
checks); 4–7 build the new capability; 8 sets up the future.
|
||||
|
||||
1. **Extract the docx engine** — move MD→OOXML walker, OOXML merge/compose, placeholder
|
||||
grammar, `.dotm`→`.docx` into `pkg/docforge/{placeholder.go, render.go, docx/}`.
|
||||
paliad's `submission_*` services become thin adapters. Golden-export + preview checks
|
||||
green. *Protects b78a984, the regex, the data-var contract.*
|
||||
2. **Neutral model + binding** — introduce `Document`/`Block`/`Slot`/`Carrier` + `bind.go`;
|
||||
refactor the docx exporter to consume the neutral model (sections → blocks → OOXML
|
||||
spliced into carrier). Behavior identical (golden checks).
|
||||
3. **`VariableResolver` interface** — refactor the 7 `addXxxVars` into resolver types +
|
||||
`ResolverSet`; `BuildBag()` reproduces today's map (alias-parity tests pin it);
|
||||
`Catalogue()` exposed. Frontend form switched to consume `Catalogue()` (kills hardcoded
|
||||
`VARIABLE_GROUPS`).
|
||||
4. **Template store + schema** — `templates`/`template_versions`/`template_slots` +
|
||||
Postgres-bytea `TemplateStore` impl. No UI yet. Additive migrations.
|
||||
5. **UI package extraction** — pull generic plumbing (debounced autosave, data-var wiring,
|
||||
preview/export round-trip, focus preservation, sticky collapse) into
|
||||
`frontend/src/lib/docforge-editor/`; submission editor consumes it. Refactor, behavior
|
||||
identical.
|
||||
6. **Authoring page** — upload `.docx` → docforge docx-importer → WYSIWYG render → select
|
||||
text → pick variable from `Catalogue()` palette → inject slot (writes
|
||||
`template_slots` + new `template_version`). Reuses the UI package + docforge importer.
|
||||
*(v1: body-paragraph text slots only.)*
|
||||
7. **Generation on uploaded templates** — generation page picks an uploaded template
|
||||
(`template_version_id` path) alongside legacy bases; snapshot-at-create; data-bind +
|
||||
manual edit + export via docforge. Legacy base path still works.
|
||||
8. **Markdown importer + exporter-interface finalisation** — `docforge/markdown` importer
|
||||
as input; `Exporter` interface locked (docx-only impl). Sets up future formats +
|
||||
eventual upc-kommentar reuse.
|
||||
|
||||
**Flagged follow-ups (post-train, separate tasks):** slots in headers/footers/tables;
|
||||
foreign-docx import fidelity; the HTTP veneer + a TS consumer; submission_bases →
|
||||
templates convergence; auto-upgrade of pre-Composer drafts.
|
||||
|
||||
---
|
||||
|
||||
## §8 Out of scope
|
||||
|
||||
- **Implementation, migration SQL, code.** PRD only.
|
||||
- **upc-kommentar as a live consumer** — deferred; abstractions sized for it, nothing built.
|
||||
- **An HTTP service veneer** — addable later without engine rework; not now.
|
||||
- **Formats beyond `.docx`** — `Exporter` interface defined (B4), only the docx impl built.
|
||||
- **Lossless import of *foreign* `.docx`** — our own templates export losslessly via the
|
||||
carrier; importing an arbitrary third-party Word doc as input content is best-effort and
|
||||
inherently lossy. Distinct guarantee.
|
||||
- **Multi-user concurrent editing** of one draft.
|
||||
- **Re-proposing the current `submission_*.go` shape** — the point is to extract + clean it.
|
||||
- **Slots outside body paragraphs** (headers/footers/tables/text-boxes) in authoring v1.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — open flags for the coder (resolve in implementation chat)
|
||||
|
||||
1. **Slot anchor encoding** — run-index+offset vs. injected sentinel token (§5). Lean
|
||||
sentinel.
|
||||
2. **Snapshot mechanism** — pinned `template_version_id` vs. `template_snapshot` jsonb on
|
||||
the draft (§5). Lean version-pin.
|
||||
3. **Authoring render fidelity** — reuse the existing lossy `docXMLToHTML` preview for the
|
||||
WYSIWYG surface, or invest in higher fidelity. Lean reuse for v1, accept that
|
||||
complex layouts render approximately while slots still anchor correctly.
|
||||
4. **Storage backend** — Postgres bytea now; Supabase Storage is a clean `TemplateStore`
|
||||
swap if template volume/size grows.
|
||||
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.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='pending', 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`~~ — KEEP. Builder imports `filterByDetailMode` from it; per-triplet Detailgrad reuses this module.
|
||||
- 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.
|
||||
280
exports/gen-deadline-list.py
Executable file
280
exports/gen-deadline-list.py
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a markdown deadline-list export for UPC PA training (work/head delegation #2572).
|
||||
|
||||
Sorts by proceeding-type display_order then sequence_order. Sections by proceeding.
|
||||
|
||||
t-paliad-348 / yoUPC#178 update: matches the engine's `IncludeOptional=false`
|
||||
default (`pkg/litigationplanner/engine.go`). Optional rules (priority='optional')
|
||||
are SUPPRESSED by default so the manuscript shows the same "naked proceeding
|
||||
backbone" the UI now renders. Pass `--include-optional` to opt back in for an
|
||||
exhaustive catalog dump.
|
||||
|
||||
Usage:
|
||||
uv run exports/gen-deadline-list.py [--include-optional] [-o OUT]
|
||||
"""
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["psycopg2-binary"]
|
||||
# ///
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
DSN = os.environ.get(
|
||||
"PALIAD_DEADLINE_EXPORT_DSN",
|
||||
"postgres://postgres:rpsak3yf4lu1izgefx9p9xweg3qroojw@100.99.98.201:11833/postgres?sslmode=disable",
|
||||
)
|
||||
|
||||
# `priority` filter is wired at the SQL level (not post-filter in Python) so
|
||||
# the row counter in the markdown header reflects what's actually in the
|
||||
# manuscript — matching what the lawyer sees on /tools/procedures.
|
||||
SQL_TEMPLATE = """
|
||||
SELECT
|
||||
pt.code AS pt_code,
|
||||
pt.display_order,
|
||||
COALESCE(pt.name_en, pt.name) AS pt_label_en,
|
||||
pt.name AS pt_label_de,
|
||||
COALESCE(pe.name_en, pe.name) AS event_en,
|
||||
pe.name AS event_de,
|
||||
sr.duration_value,
|
||||
sr.duration_unit,
|
||||
sr.timing,
|
||||
sr.alt_duration_value,
|
||||
sr.alt_duration_unit,
|
||||
sr.combine_op,
|
||||
sr.rule_code,
|
||||
COALESCE(te.name, te.name_de) AS trigger_label,
|
||||
te.code AS trigger_code,
|
||||
sr.primary_party,
|
||||
sr.is_court_set,
|
||||
sr.is_spawn,
|
||||
sr.priority,
|
||||
sr.deadline_notes_en,
|
||||
sr.deadline_notes,
|
||||
sr.condition_expr,
|
||||
sr.sequence_order
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
|
||||
LEFT JOIN paliad.trigger_events te ON te.id = sr.trigger_event_id
|
||||
WHERE sr.lifecycle_state = 'published'
|
||||
AND sr.is_active = true
|
||||
AND pt.id IS NOT NULL
|
||||
{priority_filter}
|
||||
ORDER BY pt.display_order NULLS LAST, pt.code, sr.sequence_order NULLS LAST, sr.rule_code, pe.name;
|
||||
"""
|
||||
|
||||
|
||||
def format_frist(duration_value, duration_unit, timing, alt_value, alt_unit, combine_op):
|
||||
"""Format the deadline duration cleanly."""
|
||||
if duration_value is None or duration_unit is None:
|
||||
return ""
|
||||
unit_map = {
|
||||
"days": "d",
|
||||
"weeks": "w",
|
||||
"months": "M",
|
||||
"years": "y",
|
||||
"calendar_days": "CD",
|
||||
"working_days": "WD",
|
||||
}
|
||||
unit = unit_map.get(duration_unit, duration_unit)
|
||||
main = f"{duration_value} {unit}"
|
||||
if alt_value is not None and alt_unit is not None:
|
||||
alt_unit_short = unit_map.get(alt_unit, alt_unit)
|
||||
op = combine_op or "or"
|
||||
main = f"{main} {op} {alt_value} {alt_unit_short}"
|
||||
if timing == "before":
|
||||
main = f"{main} before"
|
||||
elif timing == "after":
|
||||
main = f"{main} after"
|
||||
return main
|
||||
|
||||
|
||||
def format_party(primary_party, is_court_set):
|
||||
if is_court_set:
|
||||
return "court-set"
|
||||
if primary_party == "claimant":
|
||||
return "claimant"
|
||||
if primary_party == "defendant":
|
||||
return "defendant"
|
||||
if primary_party == "both":
|
||||
return "either"
|
||||
if primary_party == "court":
|
||||
return "court"
|
||||
return primary_party or "—"
|
||||
|
||||
|
||||
def detect_r94(notes_en, notes_de):
|
||||
"""Flag R.9.4 non-extendable from notes text (heuristic — no DB field)."""
|
||||
blobs = " ".join(filter(None, [notes_en or "", notes_de or ""])).lower()
|
||||
if "r.9.4" in blobs or "r 9.4" in blobs or "r9.4" in blobs:
|
||||
return "✗"
|
||||
if "non-extendable" in blobs or "nicht verlängerbar" in blobs or "nicht verlaengerbar" in blobs:
|
||||
return "✗"
|
||||
return ""
|
||||
|
||||
|
||||
def conditional_marker(condition_expr):
|
||||
if condition_expr in (None, "", {}):
|
||||
return ""
|
||||
# condition_expr is JSONB → returns dict
|
||||
if isinstance(condition_expr, dict):
|
||||
if "flag" in condition_expr:
|
||||
return f"if `{condition_expr['flag']}`"
|
||||
if condition_expr.get("op") == "and" and "args" in condition_expr:
|
||||
flags = [a.get("flag", "?") for a in condition_expr["args"]]
|
||||
return "if " + " & ".join(f"`{f}`" for f in flags)
|
||||
if condition_expr.get("op") == "or" and "args" in condition_expr:
|
||||
flags = [a.get("flag", "?") for a in condition_expr["args"]]
|
||||
return "if " + " | ".join(f"`{f}`" for f in flags)
|
||||
return "cond"
|
||||
|
||||
|
||||
def md_escape(s):
|
||||
if s is None:
|
||||
return ""
|
||||
return str(s).replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def render(rows, *, include_optional: bool, generated_for: str) -> str:
|
||||
by_pt = {}
|
||||
for r in rows:
|
||||
key = (r["display_order"] or 9999, r["pt_code"], r["pt_label_de"], r["pt_label_en"])
|
||||
by_pt.setdefault(key, []).append(r)
|
||||
|
||||
out = []
|
||||
today = date.today().isoformat()
|
||||
out.append(f"# UPC + DE/EP Deadline Catalog — Stand {today}")
|
||||
out.append("")
|
||||
out.append(f"Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).")
|
||||
out.append(f"Generated for {generated_for}. {len(rows)} rules across {len(by_pt)} proceedings.")
|
||||
if include_optional:
|
||||
out.append("")
|
||||
out.append(
|
||||
"**Mode:** `--include-optional` — every published rule, including "
|
||||
"`priority='optional'` rules suppressed by the engine's default "
|
||||
"(`IncludeOptional=false`). This is the exhaustive catalog dump."
|
||||
)
|
||||
else:
|
||||
out.append("")
|
||||
out.append(
|
||||
"**Mode:** default — matches the engine's `IncludeOptional=false` "
|
||||
"behaviour (pkg/litigationplanner/engine.go). `priority='optional'` "
|
||||
"rules are suppressed; the manuscript shows only the mandatory "
|
||||
"backbone the lawyer sees by default on /tools/procedures. "
|
||||
"Re-run with `--include-optional` for the full catalog. "
|
||||
"(t-paliad-348 / yoUPC#178)"
|
||||
)
|
||||
out.append("")
|
||||
out.append("**Spalten:**")
|
||||
out.append("- **Phase/Event** = procedural event (German primary)")
|
||||
out.append("- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)")
|
||||
out.append("- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)")
|
||||
out.append("- **Anchor** = trigger event the deadline runs from")
|
||||
out.append("- **Seite** = filing party (claimant / defendant / either / court-set)")
|
||||
out.append("- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)")
|
||||
out.append("- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)")
|
||||
out.append("- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)")
|
||||
out.append("")
|
||||
out.append("---")
|
||||
out.append("")
|
||||
|
||||
for (order, pt_code, pt_de, pt_en) in sorted(by_pt.keys()):
|
||||
prules = by_pt[(order, pt_code, pt_de, pt_en)]
|
||||
out.append(f"## {pt_de} · `{pt_code}`")
|
||||
out.append("")
|
||||
if pt_en and pt_en != pt_de:
|
||||
out.append(f"*{pt_en}*")
|
||||
out.append("")
|
||||
if include_optional:
|
||||
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | Priorität | R.9.4 | Bedingung |")
|
||||
out.append("|---:|---|---|---|---|---|---|:---:|---|")
|
||||
else:
|
||||
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |")
|
||||
out.append("|---:|---|---|---|---|---|:---:|---|")
|
||||
for i, r in enumerate(prules, 1):
|
||||
event = md_escape(r["event_de"] or r["event_en"] or "")
|
||||
frist = md_escape(
|
||||
format_frist(
|
||||
r["duration_value"], r["duration_unit"], r["timing"],
|
||||
r["alt_duration_value"], r["alt_duration_unit"], r["combine_op"],
|
||||
)
|
||||
)
|
||||
rule = md_escape(r["rule_code"] or "")
|
||||
anchor = md_escape(r["trigger_label"] or "")
|
||||
party = format_party(r["primary_party"], r["is_court_set"])
|
||||
r94 = detect_r94(r["deadline_notes_en"], r["deadline_notes"])
|
||||
cond = md_escape(conditional_marker(r["condition_expr"]))
|
||||
spawn_marker = " ⤴" if r["is_spawn"] else ""
|
||||
if include_optional:
|
||||
priority = md_escape(r["priority"] or "")
|
||||
out.append(
|
||||
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {priority} | {r94} | {cond} |"
|
||||
)
|
||||
else:
|
||||
out.append(
|
||||
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {r94} | {cond} |"
|
||||
)
|
||||
out.append("")
|
||||
|
||||
out.append("---")
|
||||
out.append("")
|
||||
out.append("**Lesehilfe:**")
|
||||
out.append("- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)")
|
||||
out.append("- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)")
|
||||
out.append("- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--include-optional",
|
||||
action="store_true",
|
||||
help="Include priority='optional' rules. Default false matches the engine's IncludeOptional=false default.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--out",
|
||||
default="exports/upc-deadlines-2026-05-28.md",
|
||||
help="Output path (relative to repo root).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--generated-for",
|
||||
default="PA-Schulung 2026-05-28",
|
||||
help="Free-text label rendered in the markdown header.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
priority_filter = "" if args.include_optional else "AND sr.priority != 'optional'"
|
||||
sql = SQL_TEMPLATE.format(priority_filter=priority_filter)
|
||||
|
||||
conn = psycopg2.connect(DSN)
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
md = render(rows, include_optional=args.include_optional, generated_for=args.generated_for)
|
||||
# Resolve out path relative to the repo root (= the script's grandparent).
|
||||
out_path = Path(args.out)
|
||||
if not out_path.is_absolute():
|
||||
out_path = Path(__file__).resolve().parent.parent / out_path
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(md)
|
||||
n_pt = len({(r["display_order"] or 9999, r["pt_code"]) for r in rows})
|
||||
print(
|
||||
f"WROTE {out_path} ({len(rows)} rules, {n_pt} proceedings, "
|
||||
f"include_optional={args.include_optional})"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
BIN
exports/screenshots/paliad-348-after-upc-inf-cfi.png
Normal file
BIN
exports/screenshots/paliad-348-after-upc-inf-cfi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
exports/screenshots/paliad-348-before-upc-inf-cfi.png
Normal file
BIN
exports/screenshots/paliad-348-before-upc-inf-cfi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 490 KiB |
378
exports/upc-deadlines-2026-05-28.md
Normal file
378
exports/upc-deadlines-2026-05-28.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# UPC + DE/EP Deadline Catalog — Stand 2026-05-28
|
||||
|
||||
Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).
|
||||
Generated for PA-Schulung 2026-05-28. 178 rules across 25 proceedings.
|
||||
|
||||
**Mode:** default — matches the engine's `IncludeOptional=false` behaviour (pkg/litigationplanner/engine.go). `priority='optional'` rules are suppressed; the manuscript shows only the mandatory backbone the lawyer sees by default on /tools/procedures. Re-run with `--include-optional` for the full catalog. (t-paliad-348 / yoUPC#178)
|
||||
|
||||
**Spalten:**
|
||||
- **Phase/Event** = procedural event (German primary)
|
||||
- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)
|
||||
- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)
|
||||
- **Anchor** = trigger event the deadline runs from
|
||||
- **Seite** = filing party (claimant / defendant / either / court-set)
|
||||
- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)
|
||||
- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)
|
||||
- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)
|
||||
|
||||
---
|
||||
|
||||
## Verletzungsverfahren · `upc.inf.cfi`
|
||||
|
||||
*Infringement Action*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klageerhebung | 0 M after | RoP.013.1 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 3 M after | RoP.023 | | defendant | | |
|
||||
| 3 | Replik | 2 M or 2 M after | RoP.029.b | | claimant | | if `with_ccr` |
|
||||
| 4 | Duplik | 1 M or 2 M after | RoP.029.c | | defendant | | if `with_ccr` |
|
||||
| 5 | Erwiderung auf Nichtigkeitswiderklage | 2 M after | RoP.029.a | | claimant | | if `with_ccr` |
|
||||
| 6 | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 M after | RoP.029.d | | defendant | | if `with_ccr` |
|
||||
| 7 | Duplik auf Replik zur Erwiderung Nichtigkeitswiderklage | 1 M after | RoP.029.e | | claimant | | if `with_ccr` |
|
||||
| 8 | Antrag auf Patentänderung | 2 M after | RoP.030.1 | | claimant | | if `with_ccr` & `with_amend` |
|
||||
| 9 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.032.1 | | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 10 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_ccr` & `with_amend` |
|
||||
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 12 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
|
||||
| 13 | Mitteilung Dolmetscherkosten | 2 w before | RoP.109.4 | Oral hearing | court | | |
|
||||
| 14 | Übersetzungen einreichen | 2 w after | RoP.109.5 | | either | | |
|
||||
| 15 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
|
||||
| 16 | Entscheidung | 0 M after | RoP.118.1 | | court-set | | |
|
||||
| 17 | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | Reply to the Defence to an Application to amend the patent | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 18 | Einreichung von Übersetzungen von Schriftstücken | 1 M after | RoP.007.4 | Order of the judge-rapporteur to lodge translations | either | | |
|
||||
| 19 | Antrag auf Simultanübersetzung | 1 M before | RoP.109.5 | Oral hearing | either | | |
|
||||
| 20 | Antrag auf Folgemaßnahmen aus einer rechtskräftigen Validitätsentscheidung | 2 M after | RoP.118.4 | Final decision of the central division, Court of Appeal or EPO on the validity of the patent | either | | if `with_ccr` |
|
||||
| 21 | Antrag auf Überprüfung einer verfahrensleitenden Anordnung | 15 d after | RoP.333 | Case management order (Service) | either | | |
|
||||
| 22 | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d after | RoP.019 | Preliminary Objection | either | | |
|
||||
| 23 | Mängelbeseitigung / Zahlung | 14 d after | RoP.016 | Notification by the Registry to correct deficiencies | either | | |
|
||||
| 24 | Antrag auf Verweisung an die Zentralkammer | 10 d after | RoP.323 | Information by the Court not to approve Application to use the patent's language as language of the proceedings | either | | |
|
||||
| 25 | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 w before | RoP.109.5 | Oral hearing | either | | |
|
||||
| 26 | Klärung von Übersetzungsfragen | 2 w after | RoP.109 | Summons to Oral Hearing | court | | |
|
||||
| 27 | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d after | RoP.262.2 | Opponent Submission | either | | |
|
||||
| 28 | Wiedereinsetzungsantrag (UPC R.320) | 2 M after | RoP.320 | Removal of obstacle (UPC R.320) | either | | |
|
||||
|
||||
## Verletzungsverfahren (LG) · `de.inf.lg`
|
||||
|
||||
*Infringement (Regional Court)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klageerhebung | 0 M after | § 253 ZPO | | claimant | | |
|
||||
| 2 | Anzeige der Verteidigungsbereitschaft | 2 w after | § 276 ZPO | | defendant | | |
|
||||
| 3 | Klageerwiderung | 6 w after | § 276 ZPO | | court-set | | |
|
||||
| 4 | Replik | 4 w after | § 282 ZPO | | court-set | | |
|
||||
| 5 | Duplik | 4 w after | § 282 ZPO | | court-set | | |
|
||||
| 6 | Haupttermin | 0 M after | § 279 ZPO | | court-set | | |
|
||||
| 7 | Urteil | 0 M after | § 300 ZPO | | court-set | | |
|
||||
| 8 | Berufungsfrist | 1 M after | § 517 ZPO | | either | | |
|
||||
| 9 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
|
||||
| 10 | Wiedereinsetzungsantrag (§ 233 ZPO) | 2 w after | § 233 ZPO | Removal of obstacle (ZPO §233) | — | | |
|
||||
| 11 | Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 2 w after | § 339 ZPO | Service of default judgment | — | | |
|
||||
| 12 | Schriftsatznachreichung (§ 296a ZPO) | 3 w after | § 296a ZPO | End of oral hearing | — | | |
|
||||
|
||||
## Nichtigkeitsverfahren · `upc.rev.cfi`
|
||||
|
||||
*Revocation Action*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Nichtigkeitsklage | 0 M after | RoP.044 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.049.1 | | defendant | | |
|
||||
| 3 | Antrag auf Patentänderung | 0 M after | RoP.049.2.a | | defendant | | if `with_amend` |
|
||||
| 4 | Verletzungswiderklage | 0 M after | RoP.049.2.b | | defendant | | if `with_cci` |
|
||||
| 5 | Replik | 2 M after | RoP.051 | | claimant | | |
|
||||
| 6 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.043.3 | | claimant | | if `with_amend` |
|
||||
| 7 | Erwiderung auf Verletzungswiderklage | 2 M after | RoP.056.1 | | claimant | | if `with_cci` |
|
||||
| 8 | Duplik | 1 M after | RoP.052 | | defendant | | |
|
||||
| 9 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_amend` |
|
||||
| 10 | Replik auf Erwiderung zur Verletzungswiderklage | 1 M after | RoP.056.3 | | defendant | | if `with_cci` |
|
||||
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_amend` |
|
||||
| 12 | Duplik auf Replik zur Erwiderung Verletzungswiderklage | 1 M after | RoP.056.4 | | claimant | | if `with_cci` |
|
||||
| 13 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
|
||||
| 14 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
|
||||
| 15 | Entscheidung | 0 M after | RoP.118.3 | | court-set | | |
|
||||
| 16 | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 M after | RoP.052 | Reply to the Defence to revocation | — | | |
|
||||
| 17 | Verletzungswiderklage | 2 M after | RoP.053 | Statement for Revocation | — | | |
|
||||
| 18 | Antrag auf Patentänderung | 2 M after | RoP.050 | Statement for Revocation | — | | |
|
||||
|
||||
## Nichtigkeitsverfahren (BPatG) · `de.null.bpatg`
|
||||
|
||||
*Nullity (Federal Patent Court)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Nichtigkeitsklage | 0 M after | § 81 PatG | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | § 82 Abs. 3 PatG | | defendant | | |
|
||||
| 3 | Replik | 2 M after | § 83 PatG | | claimant | | |
|
||||
| 4 | Hinweisbeschluss | 0 M after | § 83 PatG | | court-set | | |
|
||||
| 5 | Stellungnahme zum Hinweisbeschluss | 0 M after | § 83 PatG | | either | | |
|
||||
| 6 | Duplik | 1 M after | § 83 PatG | | defendant | | |
|
||||
| 7 | Mündliche Verhandlung | 0 M after | § 80 PatG | | court-set | | |
|
||||
| 8 | Urteil | 0 M after | § 84 PatG | | court-set | | |
|
||||
| 9 | Berufungsfrist | 1 M after | § 110 PatG | | either | | |
|
||||
| 10 | Berufungsbegründung | 3 M after | § 111 PatG | | either | | |
|
||||
| 11 | Wiedereinsetzungsantrag (§ 123 PatG) | 2 M after | § 123 PatG | Removal of obstacle (PatG §123) | — | | |
|
||||
|
||||
## Einspruchsverfahren · `epa.opp.opd`
|
||||
|
||||
*Opposition Proceedings*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Veröffentlichung der Erteilung | 0 M after | Art. 97 EPÜ | | either | | |
|
||||
| 2 | Einspruchsfrist | 9 M after | Art. 99 EPÜ | | either | | |
|
||||
| 3 | Erwiderung des Patentinhabers | 4 M after | R. 79(1) EPÜ | | court-set | | |
|
||||
| 4 | Entscheidung | 0 M after | Art. 102 EPÜ | | court-set | | |
|
||||
| 5 | Beschwerdefrist | 2 M after | Art. 108 EPÜ | | either | | |
|
||||
| 6 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
|
||||
| 7 | Stellungnahme weiterer Beteiligter | 0 M after | R. 79 EPÜ | | either | | |
|
||||
| 8 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
|
||||
| 9 | Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 M after | Art. 122 EPÜ | Removal of obstacle (EPC Art.122) | — | | |
|
||||
|
||||
## Beschwerdeverfahren · `epa.opp.boa`
|
||||
|
||||
*Appeal Proceedings*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung der Beschwerdeentscheidung | 0 M after | R. 124 EPÜ | | either | | |
|
||||
| 2 | Beschwerdeeinlegung | 2 M after | Art. 108 EPÜ | | either | | |
|
||||
| 3 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
|
||||
| 4 | Beschwerdeerwiderung | 4 M after | RPBA Art. 12 | | either | | |
|
||||
| 5 | Mündliche Verhandlung | 0 M after | Art. 116 EPÜ | | court-set | | |
|
||||
| 6 | Entscheidung | 0 M after | Art. 111 EPÜ | | court-set | | |
|
||||
| 7 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
|
||||
| 8 | Antrag auf Überprüfung | 2 M after | Art. 112a EPÜ | | either | | |
|
||||
|
||||
## Einspruchsverfahren DPMA · `dpma.opp.dpma`
|
||||
|
||||
*Opposition DPMA*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Veröffentlichung der Erteilung | 0 M after | § 58 PatG | | either | | |
|
||||
| 2 | Einspruchsfrist | 9 M after | § 59 PatG | | either | | |
|
||||
| 3 | Erwiderung des Patentinhabers | 4 M after | § 59(2) PatG | | court-set | | |
|
||||
| 4 | DPMA-Entscheidung | 0 M after | § 61 PatG | | court-set | | |
|
||||
| 5 | Wiedereinsetzungsantrag (DPMA) | 2 M after | § 123 PatG | Removal of obstacle (DPMA, PatG §123) | — | | |
|
||||
|
||||
## Berufungsverfahren · `upc.apl.merits`
|
||||
|
||||
*Appeal*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Berufungseinlegung | 2 M after | RoP.224.1.a | | either | | |
|
||||
| 2 | Berufungsbegründung | 4 M after | RoP.224.2.a | | either | | |
|
||||
| 3 | Berufungserwiderung | 3 M after | RoP.235.1 | | either | | |
|
||||
| 4 | Mündliche Verhandlung | 0 M after | RoP.240 | | court-set | | |
|
||||
| 5 | Entscheidung | 0 M after | RoP.235.4 | | court-set | | |
|
||||
| 6 | Anschlussberufung | 3 M after | RoP.237 | | either | | |
|
||||
| 7 | Erwiderung Anschlussberufung | 2 M after | RoP.238.1 | | either | | |
|
||||
| 8 | Berufungsschrift gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 2 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
|
||||
| 9 | Berufungsbegründung gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 4 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
|
||||
| 10 | Anfechtung einer Entscheidung über die Verwerfung der Berufung als unzulässig | 1 M after | RoP.245 | Decision to reject an appeal as inadmissible | — | | |
|
||||
| 11 | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 M after | RoP.247.2 | Final decision (Service) / Discovery of the fundamental defect (whichever is later) | — | | |
|
||||
| 12 | Antrag auf Wiederaufnahme (Straftat) | 2 M after | RoP.247.1 | Final decision (Service) / Court decision on criminal offence (whichever is later) | — | | |
|
||||
|
||||
## Berufungsverfahren OLG (Verletzung) · `de.inf.olg`
|
||||
|
||||
*Appeal OLG (Infringement)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung LG-Urteil | 0 M after | § 540 ZPO | | either | | |
|
||||
| 2 | Berufungsschrift | 1 M after | § 517 ZPO | | either | | |
|
||||
| 3 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
|
||||
| 4 | Berufungserwiderung | 1 M after | § 521 ZPO | | either | | |
|
||||
| 5 | Anschlussberufung | 0 M after | § 524 ZPO | | either | | |
|
||||
| 6 | Mündliche Verhandlung | 0 M after | § 540 ZPO | | court-set | | |
|
||||
| 7 | OLG-Urteil | 0 M after | § 540 ZPO | | court-set | | |
|
||||
|
||||
## Revisions-/NZB-Verfahren BGH (Verletzung) · `de.inf.bgh`
|
||||
|
||||
*Revision / Non-admission Appeal BGH (Infringement)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung OLG-Urteil | 0 M after | § 555 ZPO | | either | | |
|
||||
| 2 | Nichtzulassungsbeschwerde | 1 M after | § 544 ZPO | | either | | |
|
||||
| 3 | Nichtzulassungsbeschwerde-Begründung | 2 M after | § 544 ZPO | | either | | |
|
||||
| 4 | Revisionsfrist | 1 M after | § 548 ZPO | | either | | |
|
||||
| 5 | Revisionsbegründung | 2 M after | § 551 ZPO | | either | | |
|
||||
| 6 | Revisionserwiderung | 1 M after | § 554 ZPO | | either | | |
|
||||
| 7 | Mündliche Verhandlung BGH | 0 M after | § 555 ZPO | | court-set | | |
|
||||
| 8 | BGH-Urteil | 0 M after | § 555 ZPO | | court-set | | |
|
||||
|
||||
## Berufungsverfahren BGH (Nichtigkeit) · `de.null.bgh`
|
||||
|
||||
*Appeal BGH (Nullity)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung BPatG-Urteil | 0 M after | § 110 PatG | | either | | |
|
||||
| 2 | Berufungsschrift | 1 M after | § 110 PatG | | either | | |
|
||||
| 3 | Berufungsbegründung | 3 M after | § 520 Abs. 2 ZPO i.V.m. § 117 PatG | | either | | |
|
||||
| 4 | Berufungserwiderung | 2 M after | § 521 Abs. 2 ZPO i.V.m. § 117 PatG | | court-set | | |
|
||||
| 5 | Mündliche Verhandlung BGH | 0 M after | § 121 PatG | | court-set | | |
|
||||
| 6 | BGH-Urteil | 0 M after | § 122 PatG | | court-set | | |
|
||||
|
||||
## EP-Erteilungsverfahren · `epa.grant.exa`
|
||||
|
||||
*EP Grant Procedure*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Anmeldung | 0 M after | Art. 75 EPÜ | | claimant | | |
|
||||
| 2 | Recherchenbericht | 6 M after | Art. 92 EPÜ | | court-set | | |
|
||||
| 3 | Veröffentlichung (A1) | 18 M after | Art. 93 EPÜ | | court-set | | |
|
||||
| 4 | Prüfungsantrag | 6 M after | R. 70(1) EPÜ | | claimant | | |
|
||||
| 5 | Mitteilung nach R. 71(3) | 0 M after | R. 71(3) EPÜ | | court-set | | |
|
||||
| 6 | Zustimmung + Übersetzung | 4 M after | R. 71(3) EPÜ | | claimant | | |
|
||||
| 7 | Erteilung (B1) | 0 M after | Art. 97 EPÜ | | court-set | | |
|
||||
|
||||
## Beschwerdeverfahren BPatG (DPMA) · `dpma.appeal.bpatg`
|
||||
|
||||
*Appeal BPatG (against DPMA Decision)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung DPMA-Entscheidung | 0 M after | § 65 PatG | | either | | |
|
||||
| 2 | Beschwerde | 1 M after | § 73 PatG | | either | | |
|
||||
| 3 | Beschwerdebegründung | 1 M after | § 75 PatG | | court-set | | |
|
||||
| 4 | Mündliche Verhandlung BPatG | 0 M after | § 78 PatG | | court-set | | |
|
||||
| 5 | BPatG-Entscheidung | 0 M after | § 78 PatG | | court-set | | |
|
||||
|
||||
## Rechtsbeschwerdeverfahren BGH · `dpma.appeal.bgh`
|
||||
|
||||
*Legal Appeal BGH*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung BPatG-Entscheidung | 0 M after | § 100 PatG | | either | | |
|
||||
| 2 | Rechtsbeschwerde | 1 M after | § 100 PatG | | either | | |
|
||||
| 3 | Rechtsbeschwerdebegründung | 1 M after | § 102 PatG | | either | | |
|
||||
| 4 | BGH-Entscheidung | 0 M after | § 100 PatG | | court-set | | |
|
||||
|
||||
## Berufungsverfahren Anordnungen · `upc.apl.order`
|
||||
|
||||
*Order Appeal (15-day track)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Anordnung / angegriffene Entscheidung | 0 M after | RoP.220 | | court-set | | |
|
||||
| 2 | Berufung mit Zulassung | 15 d after | RoP.220.2 | | either | | |
|
||||
| 3 | Antrag auf Ermessensüberprüfung | 15 d after | RoP.220.3 | | either | | |
|
||||
| 4 | Berufungsbegründung (Orders Track) | 15 d after | RoP.224.2.b | | either | | |
|
||||
| 5 | Anschlussberufung | 15 d after | RoP.237 | | either | | |
|
||||
| 6 | Erwiderung Anschlussberufung | 15 d after | RoP.238.2 | | either | | |
|
||||
| 7 | Berufungsschrift gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
|
||||
| 8 | Berufungsbegründung gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
|
||||
|
||||
## Schadensbemessungsverfahren · `upc.dmgs.cfi`
|
||||
|
||||
*Damages Determination*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Schadensbemessung | 0 M after | RoP.125 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.137.2 | | defendant | | |
|
||||
| 3 | Replik | 1 M after | RoP.139 | | claimant | | |
|
||||
| 4 | Duplik | 1 M after | RoP.139 | | defendant | | |
|
||||
|
||||
## Bucheinsichtsverfahren · `upc.disc.cfi`
|
||||
|
||||
*Lay-open Books / Discovery*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Bucheinsicht | 0 M after | RoP.190 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.142.2 | | defendant | | |
|
||||
| 3 | Replik | 14 d after | RoP.142.3 | | claimant | | |
|
||||
| 4 | Duplik | 14 d after | RoP.142.3 | | defendant | | |
|
||||
|
||||
## Einstweilige Maßnahmen · `upc.pi.cfi`
|
||||
|
||||
*Provisional Measures*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag | 0 M after | RoP.206 | | claimant | | |
|
||||
| 2 | Erwiderung | 0 M after | RoP.211.2 | | court-set | | |
|
||||
| 3 | Mündliche Verhandlung | 0 M after | RoP.195 | | court-set | | |
|
||||
| 4 | Mängelbeseitigung Antrag | 14 d after | RoP.207.6.a | | claimant | | |
|
||||
| 5 | Beschluss | 0 M after | RoP.211 | | court-set | | |
|
||||
| 6 | Klage in der Hauptsache erheben | 31 d max 20 WD after | RoP.213 | | claimant | | |
|
||||
|
||||
## Schutzschrift · `upc.pl.cfi`
|
||||
|
||||
*Protective Letter*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Einreichung der Schutzschrift | 0 M after | RoP.207 | | defendant | | |
|
||||
| 2 | Erneuerung der Schutzschrift | 6 M after | RoP.207.9 | Protective Letter | — | | |
|
||||
|
||||
## Berufungsverfahren Kosten · `upc.apl.cost`
|
||||
|
||||
*Cost-Decision Appeal*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Kostenfestsetzungsbeschluss | 0 M after | RoP.221.4 | | court-set | | |
|
||||
| 2 | Antrag auf Berufungszulassung | 15 d after | RoP.221.1 | | either | | |
|
||||
| 3 | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d after | RoP.220.2 | Decision on fixation of costs (Rule 157) | — | | |
|
||||
|
||||
## Negative Feststellungsklage · `upc.dni.cfi`
|
||||
|
||||
*Declaration of Non-Infringement*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klage auf negative Feststellung der Nichtverletzung | 0 M after | RoP.063 | | claimant | | |
|
||||
| 2 | Erwiderung auf die negative Feststellungsklage | 2 M after | RoP.066 | Statement for a declaration of non-infringement | — | | |
|
||||
| 3 | Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.067 | Defence to the Statement for a declaration of non-infringement | — | | |
|
||||
| 4 | Duplik zur Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.068 | Reply to the Defence to the Statement for a declaration of non-infringement | — | | |
|
||||
|
||||
## Überprüfung von EPA-Entscheidungen · `upc.epo.review`
|
||||
|
||||
*Review of EPO decisions*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Überprüfung der EPA-Entscheidung | 0 M after | RoP.088 | | — | | |
|
||||
| 2 | Antrag auf Aufhebung einer Entscheidung des EPA, mit der ein Antrag auf einheitliche Wirkung zurückgewiesen wurde | 3 w after | RoP.097 | Decision of the EPO not to grant unitary effect | — | | |
|
||||
|
||||
## Separate Kostenentscheidung · `upc.costs.cfi`
|
||||
|
||||
*Separate Cost Decision*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Kostenfestsetzung | 1 M after | RoP.151 | | claimant | | |
|
||||
|
||||
## Beweissicherung / saisie · `upc.bsv.cfi`
|
||||
|
||||
*Evidence Preservation*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Beweissicherung | 0 M after | RoP.192 | | court-set | | |
|
||||
| 2 | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d after | RoP.197.3 | Execution of measures to preserve evidence | — | | |
|
||||
| 3 | Beginn des Hauptsacheverfahrens | 31 d max 20 WD after | RoP.198 | Date specified in the Court's order to preserve evidence | — | | |
|
||||
|
||||
## Widerklage auf Nichtigkeit · `upc.ccr.cfi`
|
||||
|
||||
*Counterclaim for Revocation*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Widerklage auf Nichtigkeit | 3 M after | RoP.025 | | defendant | | |
|
||||
|
||||
---
|
||||
|
||||
**Lesehilfe:**
|
||||
- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)
|
||||
- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)
|
||||
- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).
|
||||
@@ -3,8 +3,7 @@ import { join, relative } from "path";
|
||||
import { renderIndex } from "./src/index";
|
||||
import { renderLogin } from "./src/login";
|
||||
import { renderKostenrechner } from "./src/kostenrechner";
|
||||
import { renderFristenrechner } from "./src/fristenrechner";
|
||||
import { renderVerfahrensablauf } from "./src/verfahrensablauf";
|
||||
import { renderProcedures } from "./src/procedures";
|
||||
import { renderDownloads } from "./src/downloads";
|
||||
import { renderLinks } from "./src/links";
|
||||
import { renderGlossary } from "./src/glossary";
|
||||
@@ -19,6 +18,7 @@ import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderSubmissionDraft } from "./src/submission-draft";
|
||||
import { renderTemplatesAuthoring } from "./src/templates-authoring";
|
||||
import { renderSubmissionsIndex } from "./src/submissions-index";
|
||||
import { renderSubmissionsNew } from "./src/submissions-new";
|
||||
import { renderEvents } from "./src/events";
|
||||
@@ -241,8 +241,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/index.ts"),
|
||||
join(import.meta.dir, "src/client/login.ts"),
|
||||
join(import.meta.dir, "src/client/kostenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/fristenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/verfahrensablauf.ts"),
|
||||
join(import.meta.dir, "src/client/procedures.ts"),
|
||||
join(import.meta.dir, "src/client/downloads.ts"),
|
||||
join(import.meta.dir, "src/client/links.ts"),
|
||||
join(import.meta.dir, "src/client/glossary.ts"),
|
||||
@@ -257,6 +256,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/submission-draft.ts"),
|
||||
join(import.meta.dir, "src/client/templates-authoring.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-index.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-new.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
@@ -369,8 +369,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "index.html"), renderIndex());
|
||||
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
|
||||
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
|
||||
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
|
||||
await Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf());
|
||||
await Bun.write(join(DIST, "procedures.html"), renderProcedures());
|
||||
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
|
||||
await Bun.write(join(DIST, "links.html"), renderLinks());
|
||||
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
|
||||
@@ -385,6 +384,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
|
||||
await Bun.write(join(DIST, "templates-authoring.html"), renderTemplatesAuthoring());
|
||||
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
|
||||
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
|
||||
@@ -77,9 +77,9 @@ export function renderAdminRulesList(): string {
|
||||
<div className="admin-rules-filter admin-rules-filter-chips">
|
||||
<span className="admin-rules-filter-label" data-i18n="admin.rules.filter.lifecycle">Lifecycle</span>
|
||||
<div className="admin-rules-chips" id="rules-filter-lifecycle">
|
||||
<button type="button" className="admin-rules-chip active" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="draft" data-i18n="admin.rules.lifecycle.draft">Draft</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
|
||||
<button type="button" className="admin-rules-chip active" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="archived" data-i18n="admin.rules.lifecycle.archived">Archived</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,8 +106,10 @@ function fmtDateTime(iso: string): string {
|
||||
}
|
||||
|
||||
function parseRuleIDFromPath(): string {
|
||||
// /admin/procedural-events/{uuid}/edit
|
||||
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
|
||||
// /admin/procedural-events/{uuid}/edit (canonical, post Slice B.6 rename)
|
||||
// /admin/rules/{uuid}/edit (legacy, 301-redirected by the backend but
|
||||
// still matched here in case a stale tab or bookmark hits it).
|
||||
const m = /^\/admin\/(?:procedural-events|rules)\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
|
||||
return m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ let triggerEvents: TriggerEvent[] = [];
|
||||
|
||||
let activeProceeding = "";
|
||||
let activeTrigger = "";
|
||||
let activeLifecycle = "";
|
||||
let activeLifecycle = "published";
|
||||
let activeQuery = "";
|
||||
let searchDebounce: number | undefined;
|
||||
|
||||
|
||||
262
frontend/src/client/builder-akte.ts
Normal file
262
frontend/src/client/builder-akte.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
// Akte-mode wiring for the Litigation Builder (m/paliad#153 B4,
|
||||
// t-paliad-347).
|
||||
//
|
||||
// PRD §2.3 + §3.1 + §3.2: the page-header Akte picker lists every
|
||||
// project (`type='case'`) the user can see. Picking one POSTs to
|
||||
// /api/builder/scenarios/from-project, which mints a project-backed
|
||||
// scenario (origin_project_id pinned) seeded with the project's
|
||||
// proceeding + scenario_flags + completed deadlines. Subsequent
|
||||
// builder edits dual-write through to paliad.deadlines + projects.
|
||||
// scenario_flags via the server-side dual-write hooks.
|
||||
//
|
||||
// The picker is its own module so the builder.ts orchestrator only
|
||||
// has to expose two hooks:
|
||||
//
|
||||
// - `onProjectChosen(projectId)` — called when the user picks a
|
||||
// project. Builder calls the from-project endpoint and loads the
|
||||
// returned scenario.
|
||||
// - `setSelectedProject(scenario)` — called after a scenario loads
|
||||
// so the picker reflects the current Akte (or "— ohne —" for
|
||||
// kontextfrei scenarios).
|
||||
//
|
||||
// Cross-surface scenario-flag-changed (mig 154 ssoT, m/paliad#149):
|
||||
// the builder listens to the existing CustomEvent so any peer surface
|
||||
// that PATCHes /api/projects/{id}/scenario-flags triggers a re-fetch
|
||||
// on the builder's active proceeding when the projectId matches the
|
||||
// scenario's origin_project_id. The dispatch direction is already
|
||||
// covered by patchScenarioFlags inside scenario-flags.ts — the
|
||||
// builder's own PATCH /api/projects/.../scenario-flags goes through
|
||||
// that helper so peer surfaces stay in sync without a separate dispatch.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface AkteProjectMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
}
|
||||
|
||||
export type OnProjectChosen = (projectId: string) => void | Promise<void>;
|
||||
|
||||
interface State {
|
||||
projects: AkteProjectMeta[];
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
projects: [],
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
// fetchAkteProjects pulls every type=case project the caller can see.
|
||||
// Visibility is enforced by /api/projects via the project_teams /
|
||||
// can_see_project predicate. We filter client-side to projects with a
|
||||
// proceeding_type_id — those are the ones the builder can render. We
|
||||
// don't filter server-side because /api/projects' filter param doesn't
|
||||
// accept proceeding_type_id_not_null and round-tripping for that one
|
||||
// reason isn't worth a new endpoint.
|
||||
export async function fetchAkteProjects(): Promise<AkteProjectMeta[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects?type=case", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("builder-akte: /api/projects", resp.status);
|
||||
return [];
|
||||
}
|
||||
const rows = (await resp.json()) as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
status?: string;
|
||||
}>;
|
||||
return rows
|
||||
.filter((r) => r.proceeding_type_id != null && (r.status ?? "active") === "active")
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
reference: r.reference ?? null,
|
||||
case_number: r.case_number ?? null,
|
||||
proceeding_type_id: r.proceeding_type_id ?? null,
|
||||
our_side: r.our_side ?? null,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("builder-akte: fetch projects failed", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// formatProjectLabel renders the dropdown row for a project. Reference
|
||||
// + title are the primary anchors; the case_number tail disambiguates
|
||||
// when two cases share a reference family.
|
||||
function formatProjectLabel(p: AkteProjectMeta): string {
|
||||
const parts: string[] = [];
|
||||
if (p.reference) parts.push(p.reference);
|
||||
parts.push(p.title);
|
||||
if (p.case_number) parts.push("(" + p.case_number + ")");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// renderAktePicker fills the existing <select id="builder-akte-picker">
|
||||
// with the project list + a "— ohne —" sentinel. Idempotent.
|
||||
function renderAktePicker(selectedId: string | null): void {
|
||||
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const none = t("builder.akte.none");
|
||||
const opts: string[] = [`<option value="" data-i18n="builder.akte.none">${escHtml(none)}</option>`];
|
||||
for (const p of state.projects) {
|
||||
const selected = p.id === selectedId ? " selected" : "";
|
||||
opts.push(
|
||||
`<option value="${escAttr(p.id)}"${selected}>${escHtml(formatProjectLabel(p))}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
}
|
||||
|
||||
// mountAktePicker is the entry point. It fetches the project list once,
|
||||
// wires the dropdown change event to the supplied callback, and
|
||||
// returns a controller exposing setSelectedProject so the builder can
|
||||
// keep the picker reflective of the active scenario's Akte.
|
||||
//
|
||||
// The picker re-enables itself the moment projects load. While
|
||||
// loading, the existing `disabled` attribute (set in procedures.tsx)
|
||||
// stays so users don't pick during the fetch — but if the user lands
|
||||
// on the page after the catalog is cached this is essentially
|
||||
// instantaneous.
|
||||
export interface AktePickerHandle {
|
||||
setSelectedProject: (projectId: string | null) => void;
|
||||
isAkteMode: () => boolean;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function mountAktePicker(onChosen: OnProjectChosen): Promise<AktePickerHandle> {
|
||||
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
|
||||
if (!sel) {
|
||||
return {
|
||||
setSelectedProject: () => {},
|
||||
isAkteMode: () => false,
|
||||
reload: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// First load — fill the dropdown, enable it, wire change.
|
||||
state.projects = await fetchAkteProjects();
|
||||
state.loaded = true;
|
||||
renderAktePicker(null);
|
||||
sel.disabled = false;
|
||||
|
||||
sel.addEventListener("change", () => {
|
||||
const id = sel.value;
|
||||
if (!id) {
|
||||
// "— ohne —" reset is intentional; the builder treats this as
|
||||
// "leave the current scenario alone, just clear the picker".
|
||||
// Switching the active scenario to a non-Akte one happens via
|
||||
// the scenario picker, not by clicking the empty Akte option.
|
||||
return;
|
||||
}
|
||||
void onChosen(id);
|
||||
});
|
||||
|
||||
return {
|
||||
setSelectedProject: (projectId: string | null) => {
|
||||
const next = projectId ?? "";
|
||||
// Renderless quick-sync when the option is present; otherwise
|
||||
// re-render so the option appears (covers freshly created
|
||||
// projects since this picker last loaded).
|
||||
const optEl = sel.querySelector<HTMLOptionElement>(`option[value="${cssEscape(next)}"]`);
|
||||
if (next && !optEl) {
|
||||
renderAktePicker(next);
|
||||
} else {
|
||||
sel.value = next;
|
||||
}
|
||||
},
|
||||
isAkteMode: () => sel.value !== "",
|
||||
reload: async () => {
|
||||
state.projects = await fetchAkteProjects();
|
||||
renderAktePicker(sel.value || null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// createScenarioFromProject posts to the B4 entry point. Returns the
|
||||
// new scenario's deep payload on success (id + proceedings + events),
|
||||
// null on failure. Caller is expected to load the returned scenario
|
||||
// via the builder's existing fetchScenarioDeep / state.active path.
|
||||
export async function createScenarioFromProject(projectId: string): Promise<{ id: string } | null> {
|
||||
try {
|
||||
const resp = await fetch("/api/builder/scenarios/from-project", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ project_id: projectId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("builder-akte: from-project", resp.status, await resp.text().catch(() => ""));
|
||||
return null;
|
||||
}
|
||||
const out = await resp.json();
|
||||
return out && typeof out.id === "string" ? { id: out.id } : null;
|
||||
} catch (e) {
|
||||
console.error("builder-akte: from-project failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// renderAkteBanner toggles the "Aus Akte: <code>" badge next to the
|
||||
// scenario picker. The badge is a <span class="builder-akte-banner">
|
||||
// inserted/removed by this helper; CSS gives it a lime tint to match
|
||||
// the Akte affordance throughout the app. Pass `null` (or omit
|
||||
// projectId) to hide.
|
||||
export function renderAkteBanner(projectId: string | null): void {
|
||||
const host = document.querySelector(".builder-pageheader") as HTMLElement | null;
|
||||
if (!host) return;
|
||||
let badge = document.getElementById("builder-akte-banner");
|
||||
if (!projectId) {
|
||||
if (badge) badge.remove();
|
||||
return;
|
||||
}
|
||||
const meta = state.projects.find((p) => p.id === projectId);
|
||||
const label = meta ? formatProjectLabel(meta) : projectId.slice(0, 8);
|
||||
const text =
|
||||
t("builder.akte.banner.prefix") + " " + label;
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.id = "builder-akte-banner";
|
||||
badge.className = "builder-akte-banner";
|
||||
badge.setAttribute("role", "note");
|
||||
host.appendChild(badge);
|
||||
}
|
||||
badge.textContent = text;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// cssEscape is a small fallback for browsers that don't yet expose
|
||||
// CSS.escape. UUIDs only contain [0-9a-f-] so even the naïve replacer
|
||||
// keeps us safe; the function exists to make intent obvious.
|
||||
function cssEscape(s: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof (CSS as { escape?: (s: string) => string }).escape === "function") {
|
||||
return (CSS as { escape: (s: string) => string }).escape(s);
|
||||
}
|
||||
return s.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
||||
}
|
||||
147
frontend/src/client/builder-picker.ts
Normal file
147
frontend/src/client/builder-picker.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Add-proceeding inline picker for the Litigation Builder.
|
||||
//
|
||||
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
|
||||
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
|
||||
// gates the Verfahren chip row, click → callback. Designed for B1's
|
||||
// single-triplet flow and B2's multi-triplet stacking with no shape
|
||||
// change between slices.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface ProceedingTypeMeta {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
// group / jurisdiction. The proceeding-types API returns "UPC" /
|
||||
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
|
||||
// only renders UPC.
|
||||
group?: string;
|
||||
jurisdiction?: string;
|
||||
}
|
||||
|
||||
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
|
||||
|
||||
let activePopover: HTMLElement | null = null;
|
||||
|
||||
export function mountAddProceedingPicker(
|
||||
anchor: HTMLElement,
|
||||
types: ProceedingTypeMeta[],
|
||||
onPick: OnPick,
|
||||
): void {
|
||||
closeActive();
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "builder-picker-popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", t("builder.picker.aria"));
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "builder-picker-header";
|
||||
header.innerHTML = `
|
||||
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
|
||||
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
|
||||
`;
|
||||
pop.appendChild(header);
|
||||
|
||||
// Forum row — UPC only for v1. Disabled chips render greyed.
|
||||
const forumRow = document.createElement("div");
|
||||
forumRow.className = "builder-picker-row";
|
||||
forumRow.innerHTML = `
|
||||
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
|
||||
<div class="builder-picker-chips">
|
||||
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
|
||||
</div>
|
||||
`;
|
||||
pop.appendChild(forumRow);
|
||||
|
||||
const procRow = document.createElement("div");
|
||||
procRow.className = "builder-picker-row";
|
||||
procRow.innerHTML = `
|
||||
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
|
||||
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
|
||||
`;
|
||||
pop.appendChild(procRow);
|
||||
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "builder-picker-empty";
|
||||
empty.hidden = true;
|
||||
empty.textContent = t("builder.picker.empty");
|
||||
pop.appendChild(empty);
|
||||
|
||||
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
|
||||
const lang = document.documentElement.lang === "en" ? "en" : "de";
|
||||
for (const meta of types) {
|
||||
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-picker-chip builder-picker-chip--proc";
|
||||
chip.setAttribute("data-code", meta.code);
|
||||
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
|
||||
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
|
||||
chip.addEventListener("click", () => {
|
||||
closeActive();
|
||||
void onPick(meta);
|
||||
});
|
||||
procHost.appendChild(chip);
|
||||
}
|
||||
if (types.length === 0) empty.hidden = false;
|
||||
|
||||
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
|
||||
closeActive();
|
||||
});
|
||||
|
||||
// Position the popover under the anchor button.
|
||||
positionUnder(pop, anchor);
|
||||
document.body.appendChild(pop);
|
||||
activePopover = pop;
|
||||
document.addEventListener("click", onOutsideClick, true);
|
||||
document.addEventListener("keydown", onEscape, true);
|
||||
}
|
||||
|
||||
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
pop.style.position = "absolute";
|
||||
const top = rect.bottom + window.scrollY + 6;
|
||||
// Default left = anchor's left; clamp so popover stays in viewport.
|
||||
const left = Math.max(8, rect.left + window.scrollX);
|
||||
pop.style.top = `${top}px`;
|
||||
pop.style.left = `${left}px`;
|
||||
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
|
||||
pop.style.zIndex = "60";
|
||||
}
|
||||
|
||||
function onOutsideClick(ev: Event): void {
|
||||
if (!activePopover) return;
|
||||
const target = ev.target as Node;
|
||||
if (activePopover.contains(target)) return;
|
||||
closeActive();
|
||||
}
|
||||
|
||||
function onEscape(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Escape") closeActive();
|
||||
}
|
||||
|
||||
function closeActive(): void {
|
||||
if (activePopover) {
|
||||
activePopover.remove();
|
||||
activePopover = null;
|
||||
}
|
||||
document.removeEventListener("click", onOutsideClick, true);
|
||||
document.removeEventListener("keydown", onEscape, true);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
370
frontend/src/client/builder-promote.ts
Normal file
370
frontend/src/client/builder-promote.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// Litigation Builder — promote-to-project wizard (m/paliad#153 PRD §2.4
|
||||
// + §5.4, B5).
|
||||
//
|
||||
// 3 steps: Bestätigen (read-only summary) → Parteien ergänzen (party
|
||||
// names) → Akte-Metadaten (title, reference, case number, our_side,
|
||||
// litigation parent, team). Commit POSTs the merged payload to
|
||||
// /api/builder/scenarios/{id}/promote — a single server-side transaction
|
||||
// (no partial promotions) that creates the paliad.projects 'case' row,
|
||||
// cascades deadlines, and flips the scenario to 'promoted'. On success
|
||||
// the wizard navigates to /projects/{new-id}.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
interface ProjectOption {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
office?: string;
|
||||
}
|
||||
|
||||
interface PartyRow {
|
||||
name: string;
|
||||
role: string;
|
||||
representative: string;
|
||||
}
|
||||
|
||||
export interface PromoteContext {
|
||||
scenarioId: string;
|
||||
ownerId?: string;
|
||||
proceedingLabel: string;
|
||||
filedCount: number;
|
||||
plannedCount: number;
|
||||
flagCount: number;
|
||||
extraTopLevel: number;
|
||||
defaultOurSide: "claimant" | "defendant" | null;
|
||||
defaultTitle: string;
|
||||
onSuccess: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export async function openPromoteWizard(ctx: PromoteContext): Promise<void> {
|
||||
// Parallel fetch: litigation parents + HLC users (both optional pickers).
|
||||
const [parents, users] = await Promise.all([
|
||||
fetchProjects("litigation"),
|
||||
fetchUsers(),
|
||||
]);
|
||||
|
||||
let step = 1;
|
||||
const parties: PartyRow[] = [];
|
||||
const meta = {
|
||||
title: ctx.defaultTitle || "",
|
||||
reference: "",
|
||||
caseNumber: "",
|
||||
clientNumber: "",
|
||||
ourSide: (ctx.defaultOurSide ?? "") as "" | "claimant" | "defendant",
|
||||
parentId: "",
|
||||
teamIds: new Set<string>(),
|
||||
};
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "builder-modal-backdrop";
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "builder-modal builder-promote-modal";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-label", t("builder.promote.title"));
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onEsc, true);
|
||||
backdrop.remove();
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
document.addEventListener("keydown", onEsc, true);
|
||||
|
||||
function stepHeader(): string {
|
||||
const steps = [
|
||||
t("builder.promote.step1"),
|
||||
t("builder.promote.step2"),
|
||||
t("builder.promote.step3"),
|
||||
];
|
||||
const dots = steps.map((label, i) => {
|
||||
const n = i + 1;
|
||||
const cls = n === step ? " is-active" : n < step ? " is-done" : "";
|
||||
return `<li class="builder-promote-step${cls}"><span class="builder-promote-step-n">${n}</span>` +
|
||||
`<span class="builder-promote-step-label">${escHtml(label)}</span></li>`;
|
||||
}).join("");
|
||||
return `<ol class="builder-promote-steps">${dots}</ol>`;
|
||||
}
|
||||
|
||||
function renderStep1(): string {
|
||||
const rows = [
|
||||
`<li><span>${escHtml(t("builder.promote.summary.proceeding"))}</span><strong>${escHtml(ctx.proceedingLabel)}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.events_filed"))}</span><strong>${ctx.filedCount}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.events_planned"))}</span><strong>${ctx.plannedCount}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.flags"))}</span><strong>${ctx.flagCount}</strong></li>`,
|
||||
].join("");
|
||||
const extra = ctx.extraTopLevel > 0
|
||||
? `<p class="builder-promote-note">${escHtml(
|
||||
t("builder.promote.summary.note_extra").replace("{n}", String(ctx.extraTopLevel)),
|
||||
)}</p>`
|
||||
: "";
|
||||
return (
|
||||
`<h3 class="builder-promote-section-title">${escHtml(t("builder.promote.summary.heading"))}</h3>` +
|
||||
`<ul class="builder-promote-summary">${rows}</ul>${extra}`
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep2(): string {
|
||||
const list = parties.length === 0
|
||||
? `<p class="builder-promote-empty">${escHtml(t("builder.promote.parties.empty"))}</p>`
|
||||
: parties.map((p, i) => (
|
||||
`<div class="builder-promote-party" data-idx="${i}">` +
|
||||
`<input class="builder-promote-party-name" placeholder="${escAttr(t("builder.promote.parties.name"))}" value="${escAttr(p.name)}" />` +
|
||||
`<input class="builder-promote-party-role" placeholder="${escAttr(t("builder.promote.parties.role"))}" value="${escAttr(p.role)}" />` +
|
||||
`<input class="builder-promote-party-rep" placeholder="${escAttr(t("builder.promote.parties.representative"))}" value="${escAttr(p.representative)}" />` +
|
||||
`<button type="button" class="builder-promote-party-remove" aria-label="${escAttr(t("builder.promote.parties.remove"))}">×</button>` +
|
||||
`</div>`
|
||||
)).join("");
|
||||
return (
|
||||
`<p class="builder-promote-hint">${escHtml(t("builder.promote.parties.hint"))}</p>` +
|
||||
`<div class="builder-promote-parties">${list}</div>` +
|
||||
`<button type="button" class="builder-promote-party-add">${escHtml(t("builder.promote.parties.add"))}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep3(): string {
|
||||
const parentOpts = [`<option value="">${escHtml(t("builder.promote.meta.parent.none"))}</option>`]
|
||||
.concat(parents.map((p) => {
|
||||
const sel = p.id === meta.parentId ? " selected" : "";
|
||||
const label = p.reference ? `${p.title} (${p.reference})` : p.title;
|
||||
return `<option value="${escAttr(p.id)}"${sel}>${escHtml(label)}</option>`;
|
||||
})).join("");
|
||||
const sideSel = (v: string) => (meta.ourSide === v ? " selected" : "");
|
||||
const team = users
|
||||
.filter((u) => u.id !== ctx.ownerId)
|
||||
.slice(0, 40)
|
||||
.map((u) => {
|
||||
const checked = meta.teamIds.has(u.id) ? " checked" : "";
|
||||
const label = (u.display_name || "").trim()
|
||||
? ((u.office ? `${u.display_name} · ${u.office}` : u.display_name) as string)
|
||||
: u.email;
|
||||
return (
|
||||
`<label class="builder-promote-team-item">` +
|
||||
`<input type="checkbox" class="builder-promote-team-cb" data-user-id="${escAttr(u.id)}"${checked} />` +
|
||||
`<span>${escHtml(label)}</span></label>`
|
||||
);
|
||||
}).join("");
|
||||
return (
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.title"))}</span>` +
|
||||
`<input class="builder-promote-title" placeholder="${escAttr(t("builder.promote.meta.title.placeholder"))}" value="${escAttr(meta.title)}" /></label>` +
|
||||
`<div class="builder-promote-field-row">` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.reference"))}</span>` +
|
||||
`<input class="builder-promote-reference" value="${escAttr(meta.reference)}" /></label>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.case_number"))}</span>` +
|
||||
`<input class="builder-promote-casenumber" value="${escAttr(meta.caseNumber)}" /></label>` +
|
||||
`</div>` +
|
||||
`<div class="builder-promote-field-row">` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.client_number"))}</span>` +
|
||||
`<input class="builder-promote-clientnumber" value="${escAttr(meta.clientNumber)}" /></label>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.our_side"))}</span>` +
|
||||
`<select class="builder-promote-ourside">` +
|
||||
`<option value=""${sideSel("")}>${escHtml(t("builder.promote.meta.our_side.none"))}</option>` +
|
||||
`<option value="claimant"${sideSel("claimant")}>${escHtml(t("builder.promote.meta.our_side.claimant"))}</option>` +
|
||||
`<option value="defendant"${sideSel("defendant")}>${escHtml(t("builder.promote.meta.our_side.defendant"))}</option>` +
|
||||
`</select></label>` +
|
||||
`</div>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.parent"))}</span>` +
|
||||
`<select class="builder-promote-parent">${parentOpts}</select></label>` +
|
||||
`<div class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.team"))}</span>` +
|
||||
`<p class="builder-promote-team-hint">${escHtml(t("builder.promote.meta.team.hint"))}</p>` +
|
||||
`<div class="builder-promote-team">${team}</div></div>` +
|
||||
`<p class="builder-promote-error" hidden></p>`
|
||||
);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
let body = "";
|
||||
if (step === 1) body = renderStep1();
|
||||
else if (step === 2) body = renderStep2();
|
||||
else body = renderStep3();
|
||||
|
||||
const backLabel = t("builder.promote.back");
|
||||
const cancelLabel = t("builder.promote.cancel");
|
||||
const nextLabel = step < 3 ? t("builder.promote.next") : t("builder.promote.commit");
|
||||
|
||||
modal.innerHTML = `
|
||||
<header class="builder-modal-header">
|
||||
<h2 class="builder-modal-title">${escHtml(t("builder.promote.title"))}</h2>
|
||||
<button type="button" class="builder-modal-close" aria-label="${escAttr(cancelLabel)}">×</button>
|
||||
</header>
|
||||
${stepHeader()}
|
||||
<div class="builder-promote-body">${body}</div>
|
||||
<footer class="builder-promote-footer">
|
||||
<button type="button" class="builder-promote-cancel">${escHtml(cancelLabel)}</button>
|
||||
<span class="builder-promote-footer-spacer"></span>
|
||||
${step > 1 ? `<button type="button" class="builder-promote-backbtn">${escHtml(backLabel)}</button>` : ""}
|
||||
<button type="button" class="builder-promote-nextbtn builder-action-btn--primary">${escHtml(nextLabel)}</button>
|
||||
</footer>`;
|
||||
wire();
|
||||
}
|
||||
|
||||
function captureStep2(): void {
|
||||
modal.querySelectorAll<HTMLElement>(".builder-promote-party").forEach((row) => {
|
||||
const idx = Number(row.getAttribute("data-idx"));
|
||||
if (Number.isNaN(idx) || !parties[idx]) return;
|
||||
parties[idx].name = (row.querySelector(".builder-promote-party-name") as HTMLInputElement).value;
|
||||
parties[idx].role = (row.querySelector(".builder-promote-party-role") as HTMLInputElement).value;
|
||||
parties[idx].representative = (row.querySelector(".builder-promote-party-rep") as HTMLInputElement).value;
|
||||
});
|
||||
}
|
||||
|
||||
function captureStep3(): void {
|
||||
const get = (sel: string) => (modal.querySelector(sel) as HTMLInputElement | null)?.value ?? "";
|
||||
meta.title = get(".builder-promote-title");
|
||||
meta.reference = get(".builder-promote-reference");
|
||||
meta.caseNumber = get(".builder-promote-casenumber");
|
||||
meta.clientNumber = get(".builder-promote-clientnumber");
|
||||
meta.ourSide = ((modal.querySelector(".builder-promote-ourside") as HTMLSelectElement)?.value || "") as typeof meta.ourSide;
|
||||
meta.parentId = (modal.querySelector(".builder-promote-parent") as HTMLSelectElement)?.value || "";
|
||||
meta.teamIds = new Set(
|
||||
Array.from(modal.querySelectorAll<HTMLInputElement>(".builder-promote-team-cb:checked"))
|
||||
.map((cb) => cb.getAttribute("data-user-id") || "")
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function wire(): void {
|
||||
modal.querySelector(".builder-modal-close")?.addEventListener("click", close);
|
||||
modal.querySelector(".builder-promote-cancel")?.addEventListener("click", close);
|
||||
modal.querySelector(".builder-promote-backbtn")?.addEventListener("click", () => {
|
||||
if (step === 2) captureStep2();
|
||||
if (step === 3) captureStep3();
|
||||
step = Math.max(1, step - 1);
|
||||
render();
|
||||
});
|
||||
modal.querySelector(".builder-promote-nextbtn")?.addEventListener("click", () => {
|
||||
if (step === 2) captureStep2();
|
||||
if (step < 3) {
|
||||
step += 1;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
captureStep3();
|
||||
void commit();
|
||||
});
|
||||
if (step === 2) {
|
||||
modal.querySelector(".builder-promote-party-add")?.addEventListener("click", () => {
|
||||
captureStep2();
|
||||
parties.push({ name: "", role: "", representative: "" });
|
||||
render();
|
||||
});
|
||||
modal.querySelectorAll<HTMLElement>(".builder-promote-party-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
captureStep2();
|
||||
const row = btn.closest(".builder-promote-party") as HTMLElement;
|
||||
const idx = Number(row?.getAttribute("data-idx"));
|
||||
if (!Number.isNaN(idx)) parties.splice(idx, 1);
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(): Promise<void> {
|
||||
const errEl = modal.querySelector(".builder-promote-error") as HTMLElement | null;
|
||||
const showErr = (msg: string) => {
|
||||
if (errEl) {
|
||||
errEl.textContent = msg;
|
||||
errEl.hidden = false;
|
||||
}
|
||||
};
|
||||
if (!meta.title.trim()) {
|
||||
showErr(t("builder.promote.error.title_required"));
|
||||
return;
|
||||
}
|
||||
const nextBtn = modal.querySelector(".builder-promote-nextbtn") as HTMLButtonElement | null;
|
||||
if (nextBtn) nextBtn.disabled = true;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: meta.title.trim(),
|
||||
reference: meta.reference.trim() || undefined,
|
||||
case_number: meta.caseNumber.trim() || undefined,
|
||||
client_number: meta.clientNumber.trim() || undefined,
|
||||
our_side: meta.ourSide || undefined,
|
||||
parent_id: meta.parentId || undefined,
|
||||
parties: parties
|
||||
.filter((p) => p.name.trim())
|
||||
.map((p) => ({
|
||||
name: p.name.trim(),
|
||||
role: p.role.trim() || undefined,
|
||||
representative: p.representative.trim() || undefined,
|
||||
})),
|
||||
team_members: Array.from(meta.teamIds).map((id) => ({ user_id: id })),
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(ctx.scenarioId) + "/promote",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
showErr(t("builder.promote.error.generic"));
|
||||
return;
|
||||
}
|
||||
const out = (await resp.json()) as { project_id: string };
|
||||
const body = modal.querySelector(".builder-promote-body") as HTMLElement;
|
||||
if (body) body.innerHTML = `<p class="builder-promote-success">${escHtml(t("builder.promote.success"))}</p>`;
|
||||
ctx.onSuccess(out.project_id);
|
||||
} catch {
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
showErr(t("builder.promote.error.generic"));
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
document.body.appendChild(backdrop);
|
||||
(modal.querySelector(".builder-promote-nextbtn") as HTMLElement | null)?.focus();
|
||||
}
|
||||
|
||||
async function fetchProjects(type: string): Promise<ProjectOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects?type=" + encodeURIComponent(type));
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as ProjectOption[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers(): Promise<UserOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as UserOption[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
412
frontend/src/client/builder-search.ts
Normal file
412
frontend/src/client/builder-search.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
// Universal search dropdown for the Litigation Builder (m/paliad#153 B3).
|
||||
//
|
||||
// PRD §2.2 + §3.1 + §6.3: the page-header search box ("Suche") drives
|
||||
// a typed dropdown returning grouped event / scenario / project hits.
|
||||
// Picking an event lands the user on a scratch scenario with one
|
||||
// triplet anchored on that event's proceeding type. Picking a scenario
|
||||
// loads it; picking a project (Akte) is deferred to B4 (the dropdown
|
||||
// row renders but pick falls through to a console hint until B4 wires
|
||||
// project-backed scenarios).
|
||||
//
|
||||
// The controller is owned by builder.ts; this module exports
|
||||
// `mountBuilderSearch` which wires the input + dropdown lifecycle and
|
||||
// invokes the supplied callbacks. No module-level state — re-mounting
|
||||
// is safe.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface EventSearchHit {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string | null;
|
||||
primary_party?: string | null;
|
||||
anchor_rule_id: string;
|
||||
follow_up_count: number;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScenarioSearchHit {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectSearchHit {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
matter_number?: string | null;
|
||||
client_number?: string | null;
|
||||
}
|
||||
|
||||
export interface UniversalSearchResponse {
|
||||
query: string;
|
||||
events: EventSearchHit[];
|
||||
scenarios: ScenarioSearchHit[];
|
||||
projects: ProjectSearchHit[];
|
||||
counts: { events: number; scenarios: number; projects: number };
|
||||
}
|
||||
|
||||
export interface BuilderSearchCallbacks {
|
||||
onPickEvent: (hit: EventSearchHit) => void | Promise<void>;
|
||||
onPickScenario: (hit: ScenarioSearchHit) => void | Promise<void>;
|
||||
onPickProject?: (hit: ProjectSearchHit) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface Controller {
|
||||
input: HTMLInputElement;
|
||||
dropdown: HTMLElement;
|
||||
open: boolean;
|
||||
abort: AbortController | null;
|
||||
debounceTimer: number | null;
|
||||
lang: "de" | "en";
|
||||
}
|
||||
|
||||
let active: Controller | null = null;
|
||||
|
||||
// mountBuilderSearch wires the universal search behavior onto an
|
||||
// existing <input>. Idempotent — re-calling tears down the previous
|
||||
// dropdown and rebinds. Returns a controller exposing focus() so the
|
||||
// entry-mode toggle in builder.ts can land on the search input.
|
||||
export function mountBuilderSearch(
|
||||
input: HTMLInputElement,
|
||||
cb: BuilderSearchCallbacks,
|
||||
): { focus: () => void; close: () => void } {
|
||||
teardown();
|
||||
|
||||
const lang: "de" | "en" = document.documentElement.lang === "en" ? "en" : "de";
|
||||
|
||||
// Single dropdown container, anchored under the input. Positioned
|
||||
// absolutely so it floats above the canvas without reflowing layout.
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "builder-search-dropdown";
|
||||
dropdown.setAttribute("role", "listbox");
|
||||
dropdown.hidden = true;
|
||||
document.body.appendChild(dropdown);
|
||||
|
||||
active = {
|
||||
input,
|
||||
dropdown,
|
||||
open: false,
|
||||
abort: null,
|
||||
debounceTimer: null,
|
||||
lang,
|
||||
};
|
||||
|
||||
input.addEventListener("input", onInput);
|
||||
input.addEventListener("focus", onFocus);
|
||||
input.addEventListener("keydown", onKeydown);
|
||||
document.addEventListener("click", onOutsideClick, true);
|
||||
window.addEventListener("resize", reposition);
|
||||
window.addEventListener("scroll", reposition, true);
|
||||
|
||||
// Click handler is wired once on the dropdown root via event
|
||||
// delegation; per-row data attributes identify the hit type.
|
||||
dropdown.addEventListener("click", (ev) => {
|
||||
const row = (ev.target as HTMLElement).closest<HTMLElement>(".builder-search-row");
|
||||
if (!row) return;
|
||||
const kind = row.getAttribute("data-hit-kind");
|
||||
const payload = row.getAttribute("data-hit-payload");
|
||||
if (!kind || !payload) return;
|
||||
try {
|
||||
const hit = JSON.parse(payload);
|
||||
ev.stopPropagation();
|
||||
closeDropdown();
|
||||
if (kind === "event") void cb.onPickEvent(hit);
|
||||
else if (kind === "scenario") void cb.onPickScenario(hit);
|
||||
else if (kind === "project" && cb.onPickProject) void cb.onPickProject(hit);
|
||||
} catch (err) {
|
||||
console.error("builder-search: bad payload", err);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
focus: () => {
|
||||
input.focus();
|
||||
// Open the dropdown on focus even when input is empty — show the
|
||||
// "start typing" hint per PRD §2.2 (search box auto-focuses).
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
},
|
||||
close: closeDropdown,
|
||||
};
|
||||
}
|
||||
|
||||
function teardown(): void {
|
||||
if (!active) return;
|
||||
if (active.abort) active.abort.abort();
|
||||
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
|
||||
active.dropdown.remove();
|
||||
active.input.removeEventListener("input", onInput);
|
||||
active.input.removeEventListener("focus", onFocus);
|
||||
active.input.removeEventListener("keydown", onKeydown);
|
||||
document.removeEventListener("click", onOutsideClick, true);
|
||||
window.removeEventListener("resize", reposition);
|
||||
window.removeEventListener("scroll", reposition, true);
|
||||
active = null;
|
||||
}
|
||||
|
||||
function onInput(): void {
|
||||
if (!active) return;
|
||||
const q = active.input.value.trim();
|
||||
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
|
||||
if (q.length === 0) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
return;
|
||||
}
|
||||
if (q.length < 2) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.short"));
|
||||
return;
|
||||
}
|
||||
active.debounceTimer = window.setTimeout(() => {
|
||||
void runSearch(q);
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function onFocus(): void {
|
||||
if (!active) return;
|
||||
const q = active.input.value.trim();
|
||||
if (q.length === 0) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
} else if (q.length >= 2) {
|
||||
void runSearch(q);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent): void {
|
||||
if (!active) return;
|
||||
if (ev.key === "Escape") {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
||||
const rows = Array.from(active.dropdown.querySelectorAll<HTMLElement>(".builder-search-row"));
|
||||
if (rows.length === 0) return;
|
||||
ev.preventDefault();
|
||||
const current = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
|
||||
let idx = current ? rows.indexOf(current) : -1;
|
||||
idx = ev.key === "ArrowDown"
|
||||
? Math.min(rows.length - 1, idx + 1)
|
||||
: Math.max(0, idx - 1);
|
||||
rows.forEach((r) => r.classList.remove("is-focus"));
|
||||
rows[idx].classList.add("is-focus");
|
||||
rows[idx].scrollIntoView({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
const focused = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
|
||||
if (focused) {
|
||||
ev.preventDefault();
|
||||
focused.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onOutsideClick(ev: Event): void {
|
||||
if (!active) return;
|
||||
const target = ev.target as Node;
|
||||
if (active.input.contains(target)) return;
|
||||
if (active.dropdown.contains(target)) return;
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
async function runSearch(q: string): Promise<void> {
|
||||
if (!active) return;
|
||||
// Cancel any in-flight request so a slow earlier query can't clobber
|
||||
// a faster newer one.
|
||||
if (active.abort) active.abort.abort();
|
||||
const ctl = new AbortController();
|
||||
active.abort = ctl;
|
||||
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.loading"));
|
||||
|
||||
try {
|
||||
const url = "/api/builder/search?q=" + encodeURIComponent(q);
|
||||
const resp = await fetch(url, { signal: ctl.signal });
|
||||
if (!resp.ok) {
|
||||
renderHint(t("builder.search.hint.error"));
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as UniversalSearchResponse;
|
||||
if (active.abort !== ctl) return;
|
||||
renderResults(data);
|
||||
} catch (err) {
|
||||
if ((err as { name?: string })?.name === "AbortError") return;
|
||||
console.error("builder-search error:", err);
|
||||
renderHint(t("builder.search.hint.error"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderHint(message: string): void {
|
||||
if (!active) return;
|
||||
active.dropdown.innerHTML = `<div class="builder-search-hint">${escHtml(message)}</div>`;
|
||||
reposition();
|
||||
}
|
||||
|
||||
function renderResults(data: UniversalSearchResponse): void {
|
||||
if (!active) return;
|
||||
const lang = active.lang;
|
||||
|
||||
const total = data.events.length + data.scenarios.length + data.projects.length;
|
||||
if (total === 0) {
|
||||
renderHint(t("builder.search.hint.empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Result-count summary per PRD §2.2: "N Ereignisse · M Szenarios · K Akten"
|
||||
const counts = `<div class="builder-search-summary">` +
|
||||
escHtml(tCount("builder.search.summary.events", data.events.length)) +
|
||||
` · ` +
|
||||
escHtml(tCount("builder.search.summary.scenarios", data.scenarios.length)) +
|
||||
` · ` +
|
||||
escHtml(tCount("builder.search.summary.projects", data.projects.length)) +
|
||||
`</div>`;
|
||||
|
||||
const sections: string[] = [counts];
|
||||
|
||||
if (data.events.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.events"),
|
||||
data.events.map((e) => renderEventRow(e, lang)).join(""),
|
||||
));
|
||||
}
|
||||
if (data.scenarios.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.scenarios"),
|
||||
data.scenarios.map((s) => renderScenarioRow(s)).join(""),
|
||||
));
|
||||
}
|
||||
if (data.projects.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.projects"),
|
||||
data.projects.map((p) => renderProjectRow(p, lang)).join(""),
|
||||
));
|
||||
}
|
||||
|
||||
active.dropdown.innerHTML = sections.join("");
|
||||
reposition();
|
||||
}
|
||||
|
||||
function renderGroup(label: string, rowsHtml: string): string {
|
||||
return `<section class="builder-search-group">` +
|
||||
`<header class="builder-search-group-label">${escHtml(label)}</header>` +
|
||||
rowsHtml +
|
||||
`</section>`;
|
||||
}
|
||||
|
||||
function renderEventRow(hit: EventSearchHit, lang: "de" | "en"): string {
|
||||
const name = lang === "en" ? (hit.name_en || hit.name_de) : (hit.name_de || hit.name_en);
|
||||
const ptName = lang === "en"
|
||||
? (hit.proceeding_type.name_en || hit.proceeding_type.name_de)
|
||||
: (hit.proceeding_type.name_de || hit.proceeding_type.name_en);
|
||||
const party = hit.primary_party ? `<span class="builder-search-party">${escHtml(hit.primary_party)}</span>` : "";
|
||||
const kind = hit.event_kind ? `<span class="builder-search-kind">${escHtml(hit.event_kind)}</span>` : "";
|
||||
// Payload for the click handler — we embed the full hit so builder.ts
|
||||
// doesn't need a second lookup. JSON-encoded into a data attribute,
|
||||
// attr-escaped on the way in.
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="event" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-pt-code">${escHtml(hit.proceeding_type.code)}</span>` +
|
||||
`<span class="builder-search-event-name">${escHtml(name)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">` +
|
||||
`<span class="builder-search-pt-name">${escHtml(ptName)}</span>` +
|
||||
kind + party +
|
||||
`</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function renderScenarioRow(hit: ScenarioSearchHit): string {
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="scenario" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-scenario-name">${escHtml(hit.name)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">` +
|
||||
`<span class="builder-search-status">${escHtml(hit.status)}</span>` +
|
||||
`</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function renderProjectRow(hit: ProjectSearchHit, _lang: "de" | "en"): string {
|
||||
const meta: string[] = [];
|
||||
if (hit.case_number) meta.push(hit.case_number);
|
||||
if (hit.matter_number) meta.push(hit.matter_number);
|
||||
if (hit.client_number) meta.push(hit.client_number);
|
||||
if (hit.reference) meta.push(hit.reference);
|
||||
const metaText = meta.length > 0 ? meta.join(" · ") : "";
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="project" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-project-type">${escHtml(hit.type)}</span>` +
|
||||
`<span class="builder-search-project-title">${escHtml(hit.title)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">${escHtml(metaText)}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function openDropdown(): void {
|
||||
if (!active) return;
|
||||
active.dropdown.hidden = false;
|
||||
active.open = true;
|
||||
reposition();
|
||||
}
|
||||
|
||||
function closeDropdown(): void {
|
||||
if (!active) return;
|
||||
active.dropdown.hidden = true;
|
||||
active.open = false;
|
||||
if (active.abort) {
|
||||
active.abort.abort();
|
||||
active.abort = null;
|
||||
}
|
||||
}
|
||||
|
||||
function reposition(): void {
|
||||
if (!active || !active.open) return;
|
||||
const rect = active.input.getBoundingClientRect();
|
||||
const top = rect.bottom + window.scrollY + 4;
|
||||
const left = rect.left + window.scrollX;
|
||||
const width = Math.max(rect.width, 380);
|
||||
active.dropdown.style.position = "absolute";
|
||||
active.dropdown.style.top = `${top}px`;
|
||||
active.dropdown.style.left = `${left}px`;
|
||||
active.dropdown.style.width = `${width}px`;
|
||||
active.dropdown.style.zIndex = "60";
|
||||
}
|
||||
|
||||
// tCount applies a simple plural pick: keys ".one" / ".other" carry
|
||||
// the singular/plural variants; the caller's key is the bare stem.
|
||||
function tCount(key: string, n: number): string {
|
||||
const variant = n === 1 ? `${key}.one` : `${key}.other`;
|
||||
return t(variant).replace("{n}", String(n));
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
229
frontend/src/client/builder-shares.ts
Normal file
229
frontend/src/client/builder-shares.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
// Litigation Builder — share-with-team UI (m/paliad#153 PRD §2.5, B5).
|
||||
//
|
||||
// "Teilen" opens a modal with an HLC user picker. Picking a colleague +
|
||||
// "Schreibgeschützt teilen" POSTs a paliad.scenario_shares row; the owner
|
||||
// stays sole editor. Existing shares are listed with a revoke affordance.
|
||||
// The sharee sees the scenario in their "Geteilt mit mir" bucket (read-
|
||||
// only) — that side is handled by builder.ts.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface ShareUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
office?: string;
|
||||
}
|
||||
|
||||
export interface BuilderShareRow {
|
||||
id: string;
|
||||
scenario_id: string;
|
||||
shared_with_user_id: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ShareModalOpts {
|
||||
scenarioId: string;
|
||||
ownerId?: string;
|
||||
currentShares: BuilderShareRow[];
|
||||
// Called after a successful add/revoke with the fresh share list so the
|
||||
// caller can update state.active.shares + re-render side panel buckets.
|
||||
onChanged: (shares: BuilderShareRow[]) => void;
|
||||
}
|
||||
|
||||
let allUsers: ShareUser[] | null = null;
|
||||
|
||||
async function fetchUsers(): Promise<ShareUser[]> {
|
||||
if (allUsers) return allUsers;
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as ShareUser[];
|
||||
allUsers = Array.isArray(data) ? data : [];
|
||||
return allUsers;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function userLabel(u: ShareUser): string {
|
||||
const name = (u.display_name || "").trim();
|
||||
if (name) return u.office ? `${name} · ${u.office}` : name;
|
||||
return u.email;
|
||||
}
|
||||
|
||||
export async function openShareModal(opts: ShareModalOpts): Promise<void> {
|
||||
const users = await fetchUsers();
|
||||
let shares = [...opts.currentShares];
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "builder-modal-backdrop";
|
||||
backdrop.innerHTML = `
|
||||
<div class="builder-modal builder-share-modal" role="dialog" aria-modal="true"
|
||||
aria-label="${escAttr(t("builder.share.title"))}">
|
||||
<header class="builder-modal-header">
|
||||
<h2 class="builder-modal-title">${escHtml(t("builder.share.title"))}</h2>
|
||||
<button type="button" class="builder-modal-close" aria-label="${escAttr(t("builder.share.close"))}">×</button>
|
||||
</header>
|
||||
<p class="builder-modal-subtitle">${escHtml(t("builder.share.subtitle"))}</p>
|
||||
<div class="builder-share-pickerbox">
|
||||
<input type="search" class="builder-share-search" autocomplete="off" spellcheck="false"
|
||||
placeholder="${escAttr(t("builder.share.search.placeholder"))}" />
|
||||
<ul class="builder-share-results" aria-label="${escAttr(t("builder.share.title"))}"></ul>
|
||||
</div>
|
||||
<div class="builder-share-current">
|
||||
<h3 class="builder-share-current-title">${escHtml(t("builder.share.current.title"))}</h3>
|
||||
<ul class="builder-share-current-list"></ul>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onEsc, true);
|
||||
backdrop.remove();
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
backdrop.querySelector(".builder-modal-close")?.addEventListener("click", close);
|
||||
document.addEventListener("keydown", onEsc, true);
|
||||
|
||||
const searchEl = backdrop.querySelector(".builder-share-search") as HTMLInputElement;
|
||||
const resultsEl = backdrop.querySelector(".builder-share-results") as HTMLElement;
|
||||
const currentEl = backdrop.querySelector(".builder-share-current-list") as HTMLElement;
|
||||
|
||||
function renderCurrent(): void {
|
||||
if (shares.length === 0) {
|
||||
currentEl.innerHTML = `<li class="builder-share-current-empty">${escHtml(t("builder.share.current.empty"))}</li>`;
|
||||
return;
|
||||
}
|
||||
currentEl.innerHTML = shares.map((sh) => {
|
||||
const u = users.find((x) => x.id === sh.shared_with_user_id);
|
||||
const label = u ? userLabel(u) : sh.shared_with_user_id;
|
||||
return (
|
||||
`<li class="builder-share-current-item" data-share-id="${escAttr(sh.id)}">` +
|
||||
`<span class="builder-share-current-name">${escHtml(label)}</span>` +
|
||||
`<button type="button" class="builder-share-revoke">${escHtml(t("builder.share.revoke"))}</button>` +
|
||||
`</li>`
|
||||
);
|
||||
}).join("");
|
||||
currentEl.querySelectorAll<HTMLElement>(".builder-share-current-item").forEach((li) => {
|
||||
const id = li.getAttribute("data-share-id");
|
||||
if (!id) return;
|
||||
li.querySelector(".builder-share-revoke")?.addEventListener("click", () => {
|
||||
void revoke(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderResults(): void {
|
||||
const q = searchEl.value.trim().toLowerCase();
|
||||
const sharedIds = new Set(shares.map((s) => s.shared_with_user_id));
|
||||
const matches = users
|
||||
.filter((u) => u.id !== opts.ownerId && !sharedIds.has(u.id))
|
||||
.filter((u) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
(u.display_name || "").toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.office || "").toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 12);
|
||||
if (matches.length === 0) {
|
||||
resultsEl.innerHTML = `<li class="builder-share-result-empty">${escHtml(t("builder.share.no_results"))}</li>`;
|
||||
return;
|
||||
}
|
||||
resultsEl.innerHTML = matches.map((u) => (
|
||||
`<li class="builder-share-result" data-user-id="${escAttr(u.id)}">` +
|
||||
`<span class="builder-share-result-name">${escHtml(userLabel(u))}</span>` +
|
||||
`<button type="button" class="builder-share-add">${escHtml(t("builder.share.button"))}</button>` +
|
||||
`</li>`
|
||||
)).join("");
|
||||
resultsEl.querySelectorAll<HTMLElement>(".builder-share-result").forEach((li) => {
|
||||
const uid = li.getAttribute("data-user-id");
|
||||
if (!uid) return;
|
||||
li.querySelector(".builder-share-add")?.addEventListener("click", () => {
|
||||
void add(uid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function add(userId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) + "/shares",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ shared_with_user_id: userId }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
flashError();
|
||||
return;
|
||||
}
|
||||
const row = (await resp.json()) as BuilderShareRow;
|
||||
shares = [...shares.filter((s) => s.id !== row.id), row];
|
||||
searchEl.value = "";
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
opts.onChanged(shares);
|
||||
} catch {
|
||||
flashError();
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(shareId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) +
|
||||
"/shares/" + encodeURIComponent(shareId),
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
flashError();
|
||||
return;
|
||||
}
|
||||
shares = shares.filter((s) => s.id !== shareId);
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
opts.onChanged(shares);
|
||||
} catch {
|
||||
flashError();
|
||||
}
|
||||
}
|
||||
|
||||
function flashError(): void {
|
||||
const box = backdrop.querySelector(".builder-share-pickerbox") as HTMLElement;
|
||||
let err = box.querySelector(".builder-share-error") as HTMLElement | null;
|
||||
if (!err) {
|
||||
err = document.createElement("p");
|
||||
err.className = "builder-share-error";
|
||||
box.appendChild(err);
|
||||
}
|
||||
err.textContent = t("builder.share.error");
|
||||
}
|
||||
|
||||
searchEl.addEventListener("input", renderResults);
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
document.body.appendChild(backdrop);
|
||||
searchEl.focus();
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
271
frontend/src/client/builder-triplet.ts
Normal file
271
frontend/src/client/builder-triplet.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// ProceedingTriplet renderer for the Litigation Builder.
|
||||
//
|
||||
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
|
||||
// perspective + Detailgrad + columnar `proaktiv | court | reaktiv`
|
||||
// body.
|
||||
//
|
||||
// B2 wires the live controls — perspective radio, scenario-flag strip,
|
||||
// remove button, collapse — and the per-event-card overlays (3-state
|
||||
// machine, action buttons, optional-horizon chip). The 3-column body
|
||||
// itself is still produced by verfahrensablauf-core.renderColumnsBody;
|
||||
// per-card overlays are layered on top after innerHTML write via the
|
||||
// data-rule-id hooks added in the same slice.
|
||||
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
|
||||
import type { BuilderProceeding, BuilderEvent } from "./builder";
|
||||
import type { ProceedingTypeMeta } from "./builder-picker";
|
||||
|
||||
export interface ScenarioFlagCatalogEntry {
|
||||
flag_key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description?: string;
|
||||
hidden_unless_set: boolean;
|
||||
}
|
||||
|
||||
export interface TripletViewInput {
|
||||
proceeding: BuilderProceeding;
|
||||
meta: ProceedingTypeMeta;
|
||||
data: DeadlineResponse | null;
|
||||
side: Side;
|
||||
// Flag catalog filtered to the keys the active proceeding actually
|
||||
// references via its rules' condition_expr. B2 passes the global
|
||||
// catalog and lets the user toggle any — flags that don't gate any
|
||||
// rule are simply no-ops on this triplet.
|
||||
flagCatalog: ScenarioFlagCatalogEntry[];
|
||||
// Map keyed by sequencing_rule_id (lowercased UUID) → BuilderEvent
|
||||
// for the per-card state machine. Cards whose rule is absent default
|
||||
// to "planned".
|
||||
eventsByRule: Map<string, BuilderEvent>;
|
||||
// Per-card optional-horizon registry. Each rule with optional
|
||||
// children carries a `+N Optionen` chip; the chip's count comes from
|
||||
// here (defaults to scenario_events.horizon_optional, falls back to
|
||||
// proceeding-level when not stored per-card).
|
||||
columnsHtml: string;
|
||||
isChild: boolean;
|
||||
}
|
||||
|
||||
// Triplet header + controls + columns body. Pure-string render; the
|
||||
// caller (builder.ts) wires click handlers on top.
|
||||
export function renderTriplet(input: TripletViewInput): string {
|
||||
const lang = getLang();
|
||||
const procLabel = lang === "en"
|
||||
? (input.meta.nameEN || input.meta.name)
|
||||
: (input.meta.name || input.meta.nameEN);
|
||||
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
|
||||
|
||||
const body = input.data
|
||||
? input.columnsHtml
|
||||
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
|
||||
|
||||
const controls = renderControls(input);
|
||||
const flagStrip = renderFlagStrip(input);
|
||||
|
||||
return `
|
||||
<header class="builder-triplet-header">
|
||||
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
|
||||
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
|
||||
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
|
||||
${flagsBadge}
|
||||
</header>
|
||||
${controls}
|
||||
${flagStrip}
|
||||
<div class="builder-triplet-body">
|
||||
${body}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderControls(input: TripletViewInput): string {
|
||||
const perspective = input.side ?? "";
|
||||
const detailgrad = input.proceeding.detailgrad || "selected";
|
||||
|
||||
const radio = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-perspective-btn${active}"
|
||||
data-action="perspective" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
const detailBtn = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-detailgrad-btn${active}"
|
||||
data-action="detailgrad" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
|
||||
return `<div class="builder-triplet-controls">
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.perspective.label"))}</span>
|
||||
<div class="builder-triplet-perspective">
|
||||
${radio("", "builder.triplet.perspective.none", perspective)}
|
||||
${radio("claimant", "builder.triplet.perspective.claimant", perspective)}
|
||||
${radio("defendant", "builder.triplet.perspective.defendant", perspective)}
|
||||
</div>
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.detailgrad.label"))}</span>
|
||||
<div class="builder-triplet-detailgrad">
|
||||
${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)}
|
||||
${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)}
|
||||
</div>
|
||||
<button type="button" class="builder-triplet-remove" data-action="remove">
|
||||
${escHtml(t("builder.triplet.remove"))}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFlagStrip(input: TripletViewInput): string {
|
||||
// B2 ships the full global catalog. Flags that don't gate any of the
|
||||
// active proceeding's rules are still toggle-able but have no effect
|
||||
// on the calc result (the engine simply doesn't read them).
|
||||
const lang = getLang();
|
||||
const flags = input.proceeding.scenario_flags || {};
|
||||
if (input.flagCatalog.length === 0) {
|
||||
return `<div class="builder-triplet-flagstrip">
|
||||
<span class="builder-triplet-flag-empty">${escHtml(t("builder.triplet.no_flags"))}</span>
|
||||
</div>`;
|
||||
}
|
||||
const toggles = input.flagCatalog.map((entry) => {
|
||||
const label = lang === "en" ? entry.label_en : entry.label_de;
|
||||
const isOn = flags[entry.flag_key] === true;
|
||||
return `<label class="builder-triplet-flag-toggle">
|
||||
<input type="checkbox"
|
||||
data-action="flag"
|
||||
data-flag-key="${escAttr(entry.flag_key)}"
|
||||
${isOn ? "checked" : ""} />
|
||||
<span>${escHtml(label)}</span>
|
||||
</label>`;
|
||||
}).join("");
|
||||
return `<div class="builder-triplet-flagstrip">${toggles}</div>`;
|
||||
}
|
||||
|
||||
function jurisdictionFor(meta: ProceedingTypeMeta): string {
|
||||
if (meta.jurisdiction) return meta.jurisdiction;
|
||||
if (meta.group) return meta.group;
|
||||
const dot = meta.code.indexOf(".");
|
||||
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
|
||||
return meta.code.toUpperCase();
|
||||
}
|
||||
|
||||
function activeFlagsBadge(flags: Record<string, unknown>): string {
|
||||
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
|
||||
if (active.length === 0) return "";
|
||||
const label = t("builder.triplet.flags.label");
|
||||
const chips = active.map((f) =>
|
||||
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
|
||||
).join("");
|
||||
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
|
||||
}
|
||||
|
||||
// overlayEventStates walks the rendered .fr-col-item nodes and:
|
||||
// - sets data-builder-state from eventsByRule lookup;
|
||||
// - appends a per-card action row (file / skip / reset);
|
||||
// - shows a +N Optionen chip when the rule has optional children
|
||||
// (the chip placeholder; B2 ships the per-card horizon control —
|
||||
// the actual horizon-count→render expansion lands when the calc
|
||||
// engine surfaces "available optionals" for a parent rule, which
|
||||
// pasteur's Options.IncludeOptional flag already exposes server-
|
||||
// side; full wiring is a follow-up). Cards without optional
|
||||
// children get no chip.
|
||||
export function overlayEventStates(
|
||||
root: HTMLElement,
|
||||
eventsByRule: Map<string, BuilderEvent>,
|
||||
on: {
|
||||
onAction: (ruleId: string, action: "file" | "skip" | "reset", payload?: { date?: string; reason?: string }) => void;
|
||||
onHorizon: (ruleId: string, delta: 1 | -1) => void;
|
||||
},
|
||||
): void {
|
||||
const items = root.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]");
|
||||
items.forEach((item) => {
|
||||
const ruleId = item.getAttribute("data-rule-id");
|
||||
if (!ruleId) return;
|
||||
const ev = eventsByRule.get(ruleId.toLowerCase());
|
||||
const state = ev?.state || "planned";
|
||||
item.setAttribute("data-builder-state", state);
|
||||
|
||||
// Append actions (idempotent: clear any prior overlay first).
|
||||
item.querySelectorAll(".builder-event-actions, .builder-event-horizon-chip").forEach((n) => n.remove());
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "builder-event-actions";
|
||||
actions.innerHTML = actionButtonsHtml(state);
|
||||
item.appendChild(actions);
|
||||
|
||||
actions.addEventListener("click", (ev) => {
|
||||
const btn = (ev.target as HTMLElement).closest<HTMLElement>(".builder-event-action");
|
||||
if (!btn) return;
|
||||
const action = btn.getAttribute("data-action") as "file" | "skip" | "reset" | null;
|
||||
if (!action) return;
|
||||
ev.stopPropagation();
|
||||
if (action === "file") {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const v = window.prompt(t("builder.event.actual_date.prompt"), today);
|
||||
if (v === null) return;
|
||||
on.onAction(ruleId, "file", { date: v.trim() || today });
|
||||
} else if (action === "skip") {
|
||||
const reason = window.prompt(t("builder.event.skip_reason.prompt"), "");
|
||||
if (reason === null) return;
|
||||
on.onAction(ruleId, "skip", { reason: reason.trim() });
|
||||
} else {
|
||||
on.onAction(ruleId, "reset");
|
||||
}
|
||||
});
|
||||
|
||||
// Per-card optional horizon chip. The PRD §3.4 places the chip on
|
||||
// every card with optional children; until the calc surface exposes
|
||||
// an "optionals available count" on each parent rule, the chip is
|
||||
// shown only when the card has a stored non-zero horizon (so the
|
||||
// user can see and reduce a previously-set horizon). This is the
|
||||
// graceful B2 baseline; the full surface lands once the engine
|
||||
// emits an optionalsAvailable counter (PRD §3.4 follow-up).
|
||||
const horizonCount = ev?.horizon_optional ?? 0;
|
||||
if (horizonCount > 0) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-toggle");
|
||||
chip.textContent = t("builder.event.horizon.label").replace("{n}", String(horizonCount));
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, -1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
} else {
|
||||
// Inline "+ Optionen" affordance — adds a horizon entry when
|
||||
// first clicked. Tagged as data-builder-feature so the cleanup
|
||||
// sweep can rip it out if the calc surface lands a counter.
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-add");
|
||||
chip.setAttribute("data-builder-feature", "horizon-add");
|
||||
chip.textContent = "+ " + t("builder.event.horizon.label").replace("+{n} ", "");
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, 1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function actionButtonsHtml(state: BuilderEvent["state"]): string {
|
||||
// Re-render the action row per state. Cards in the planned state
|
||||
// show "File / Skip"; filed/skipped cards show "Reset to planned".
|
||||
if (state === "planned") {
|
||||
return `
|
||||
<button type="button" class="builder-event-action" data-action="file">${escHtml(t("builder.event.action.file"))}</button>
|
||||
<button type="button" class="builder-event-action" data-action="skip">${escHtml(t("builder.event.action.skip"))}</button>
|
||||
`;
|
||||
}
|
||||
return `<button type="button" class="builder-event-action" data-action="reset">${escHtml(t("builder.event.action.reset"))}</button>`;
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
1571
frontend/src/client/builder.ts
Normal file
1571
frontend/src/client/builder.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,507 +0,0 @@
|
||||
// Fristenrechner overhaul Mode A — "Direkt suchen" (design §3.1).
|
||||
//
|
||||
// Power-user surface: a filter strip (Forum / Verfahren / Was passierte /
|
||||
// Partei) over a free-text search box over a result list of
|
||||
// procedural_events. Clicking a row locks the event as the trigger and
|
||||
// transitions to the shared result view (S2). Inbox channel chip lives
|
||||
// as a secondary "Erweitert" toggle per design §3.3 — picking CMS / beA
|
||||
// / Postal auto-sets the Forum chip.
|
||||
//
|
||||
// Section-split visual hierarchy per m §11.Q3: filter strip on top
|
||||
// ("Filter (eingrenzen)") with the four chip groups, search box and
|
||||
// result list below — clicking a result row IS the qualifier action.
|
||||
|
||||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||||
import { getLang, t, tDyn } from "./i18n";
|
||||
import { mountResultView } from "./fristenrechner-result";
|
||||
|
||||
// Wire shape from GET /api/tools/fristenrechner/search?kind=events.
|
||||
// Mirrors services.EventSearchResponse server-side.
|
||||
interface EventSearchHit {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string;
|
||||
description?: string;
|
||||
primary_party?: string;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
};
|
||||
anchor_rule_id: string;
|
||||
follow_up_count: number;
|
||||
concept_id?: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface EventSearchResponse {
|
||||
query: string;
|
||||
events: EventSearchHit[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ProceedingChip {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
// Module-local state — single Mode A surface at a time.
|
||||
interface ModeAState {
|
||||
jurisdiction: string; // "" = Alle
|
||||
proc: string; // proceeding_types.code, "" = Alle
|
||||
eventKind: string; // "" = Alle
|
||||
party: string; // "" = Alle (Mode A's filter semantics, §11.Q8)
|
||||
q: string; // free-text query
|
||||
inbox: string; // CMS / bea / postal / "" — secondary, design §3.3
|
||||
inboxOpen: boolean;
|
||||
}
|
||||
|
||||
const state: ModeAState = {
|
||||
jurisdiction: "",
|
||||
proc: "",
|
||||
eventKind: "",
|
||||
party: "",
|
||||
q: "",
|
||||
inbox: "",
|
||||
inboxOpen: false,
|
||||
};
|
||||
|
||||
// Debounce token for search input — avoid hammering the server on
|
||||
// every keystroke.
|
||||
let searchSeq = 0;
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Chip data — static. Forum and event-kind are closed-set per design;
|
||||
// party is closed-set with "Beide" option (Mode A is filter mode,
|
||||
// §11.Q8). Inbox secondary chip set per §3.3.
|
||||
const FORUMS = ["UPC", "DE", "EPA", "DPMA"] as const;
|
||||
const EVENT_KINDS = ["filing", "hearing", "decision", "order"] as const;
|
||||
const PARTIES = ["claimant", "defendant", "both"] as const;
|
||||
|
||||
// Forum auto-derivation from inbox chip per §3.3: CMS → UPC, beA → DE,
|
||||
// Postal → no narrowing (postal arrives at every jurisdiction).
|
||||
const INBOX_TO_FORUM: Record<string, string> = {
|
||||
cms: "UPC",
|
||||
bea: "DE",
|
||||
postal: "",
|
||||
};
|
||||
|
||||
// MODE_A_HOST_ID is the DOM id of the container Mode A renders into.
|
||||
// The mode shell (fristenrechner-result.mountModeShell) creates this
|
||||
// element under the overhaul root and hands it to Mode A; Mode A
|
||||
// otherwise has no opinion about its placement on the page.
|
||||
const MODE_A_HOST_ID = "fristen-overhaul-mode-host";
|
||||
|
||||
export function isModeASurfaceMounted(): boolean {
|
||||
return !!document.getElementById("fristen-mode-a-root");
|
||||
}
|
||||
|
||||
// mountModeA renders the Mode A surface into the overhaul root. Reads
|
||||
// initial state from URL params so deep links restore the previous
|
||||
// filter / search state.
|
||||
export async function mountModeA(): Promise<void> {
|
||||
const root = document.getElementById(MODE_A_HOST_ID);
|
||||
if (!root) return;
|
||||
|
||||
// Hydrate state from URL.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
state.jurisdiction = (params.get("forum") || "").toUpperCase();
|
||||
state.proc = params.get("pt") || "";
|
||||
state.eventKind = params.get("kind") || "";
|
||||
state.party = params.get("party") || "";
|
||||
state.q = params.get("q") || "";
|
||||
|
||||
renderShell();
|
||||
await loadProceedingChips();
|
||||
void runSearch();
|
||||
}
|
||||
|
||||
// renderShell builds the Mode A markup. Idempotent re-call from the
|
||||
// boot path; row-level rewrites use renderResults / renderFilterStrip
|
||||
// for finer-grained updates.
|
||||
function renderShell(): void {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div id="fristen-mode-a-root" class="fristen-mode-a-root">
|
||||
<section class="fristen-mode-a-filters" aria-label="${escAttr(t("deadlines.overhaul.modea.filters.label"))}">
|
||||
<header class="fristen-mode-a-filters-header">
|
||||
<span class="fristen-mode-a-filters-title">${escHtml(t("deadlines.overhaul.modea.filters.heading"))}</span>
|
||||
</header>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="forum">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.forum"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-forum"></div>
|
||||
</div>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="proc">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.proc"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-proc"></div>
|
||||
</div>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="kind">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.kind"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-kind"></div>
|
||||
</div>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="party">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.party"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-party"></div>
|
||||
</div>
|
||||
<details class="fristen-mode-a-inbox" ${state.inboxOpen ? "open" : ""}>
|
||||
<summary class="fristen-mode-a-inbox-summary">${escHtml(t("deadlines.overhaul.modea.inbox.summary"))}</summary>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="inbox">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.inbox"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-inbox"></div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="fristen-mode-a-search" aria-label="${escAttr(t("deadlines.overhaul.modea.search.label"))}">
|
||||
<div class="fristen-mode-a-search-input-wrap">
|
||||
<svg class="fristen-mode-a-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input type="search" id="fristen-mode-a-search-input"
|
||||
class="fristen-mode-a-search-input"
|
||||
autocomplete="off" spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.overhaul.modea.search.placeholder"
|
||||
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
|
||||
value="${escAttr(state.q)}" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fristen-mode-a-results" aria-label="${escAttr(t("deadlines.overhaul.modea.results.label"))}">
|
||||
<header class="fristen-mode-a-results-header">
|
||||
<span class="fristen-mode-a-results-title">${escHtml(t("deadlines.overhaul.modea.results.heading"))}</span>
|
||||
<span class="fristen-mode-a-results-count" id="fristen-mode-a-results-count"></span>
|
||||
</header>
|
||||
<ul class="fristen-mode-a-result-list" id="fristen-mode-a-result-list" role="listbox" aria-live="polite"></ul>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderForumChips();
|
||||
renderKindChips();
|
||||
renderPartyChips();
|
||||
renderInboxChips();
|
||||
// Proceeding chips render later, after fetch.
|
||||
|
||||
// Wire search input.
|
||||
const input = document.getElementById("fristen-mode-a-search-input") as HTMLInputElement | null;
|
||||
if (input) {
|
||||
input.addEventListener("input", () => {
|
||||
state.q = input.value;
|
||||
scheduleSearch(180);
|
||||
});
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") {
|
||||
e.preventDefault();
|
||||
scheduleSearch(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter-strip chip renderers ----------------------------------------
|
||||
|
||||
function renderForumChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-forum");
|
||||
if (!host) return;
|
||||
const chips = [
|
||||
chipHtml("forum", "", t("deadlines.overhaul.modea.chip.all"), state.jurisdiction === ""),
|
||||
...FORUMS.map((j) => chipHtml("forum", j, j, state.jurisdiction === j)),
|
||||
];
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = btn.dataset.value || "";
|
||||
state.jurisdiction = v;
|
||||
// Forum change invalidates the proc pick if it falls outside.
|
||||
state.proc = "";
|
||||
syncUrl();
|
||||
renderForumChips();
|
||||
void loadProceedingChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderKindChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-kind");
|
||||
if (!host) return;
|
||||
const chips = [
|
||||
chipHtml("kind", "", t("deadlines.overhaul.modea.chip.all"), state.eventKind === ""),
|
||||
...EVENT_KINDS.map((k) => chipHtml("kind", k, t(`deadlines.overhaul.kind.${k}` as never), state.eventKind === k, eventKindIconForChip(k))),
|
||||
];
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.eventKind = btn.dataset.value || "";
|
||||
syncUrl();
|
||||
renderKindChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderPartyChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-party");
|
||||
if (!host) return;
|
||||
const chips = [
|
||||
chipHtml("party", "", t("deadlines.overhaul.modea.chip.all"), state.party === ""),
|
||||
...PARTIES.map((p) => chipHtml("party", p, t(`deadlines.party.${p}` as never), state.party === p)),
|
||||
];
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.party = btn.dataset.value || "";
|
||||
syncUrl();
|
||||
renderPartyChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderInboxChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-inbox");
|
||||
if (!host) return;
|
||||
const opts = [
|
||||
{ v: "", label: t("deadlines.overhaul.modea.chip.all") },
|
||||
{ v: "cms", label: "CMS" },
|
||||
{ v: "bea", label: "beA" },
|
||||
{ v: "postal", label: t("deadlines.overhaul.modea.inbox.postal") },
|
||||
];
|
||||
host.innerHTML = opts.map((o) => chipHtml("inbox", o.v, o.label, state.inbox === o.v)).join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = btn.dataset.value || "";
|
||||
state.inbox = v;
|
||||
// Auto-nudge forum from inbox per design §3.3.
|
||||
const nudge = INBOX_TO_FORUM[v];
|
||||
if (nudge !== undefined && nudge !== "") {
|
||||
state.jurisdiction = nudge;
|
||||
state.proc = "";
|
||||
renderForumChips();
|
||||
void loadProceedingChips();
|
||||
}
|
||||
renderInboxChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Proceeding chips — dynamic fetch.
|
||||
|
||||
let lastProcFetchKey = "";
|
||||
|
||||
async function loadProceedingChips(): Promise<void> {
|
||||
const host = document.getElementById("fristen-mode-a-chips-proc");
|
||||
if (!host) return;
|
||||
const key = `j=${state.jurisdiction}`;
|
||||
if (lastProcFetchKey === key) return; // cached for current jurisdiction
|
||||
lastProcFetchKey = key;
|
||||
host.innerHTML = `<span class="fristen-mode-a-chip-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</span>`;
|
||||
|
||||
const url = new URL("/api/tools/proceeding-types", window.location.origin);
|
||||
url.searchParams.set("kind", "proceeding");
|
||||
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
|
||||
|
||||
let chips: ProceedingChip[] = [];
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (resp.ok) {
|
||||
const data = (await resp.json()) as ProceedingChip[] | null;
|
||||
chips = data || [];
|
||||
}
|
||||
} catch {
|
||||
// Soft-fail: chip strip just hides; search still runs without
|
||||
// proceeding narrowing.
|
||||
}
|
||||
|
||||
renderProceedingChips(chips);
|
||||
}
|
||||
|
||||
function renderProceedingChips(chips: ProceedingChip[]): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-proc");
|
||||
if (!host) return;
|
||||
const lang = getLang();
|
||||
if (chips.length === 0) {
|
||||
host.innerHTML = `<span class="fristen-mode-a-chip-empty">${escHtml(t("deadlines.overhaul.modea.no_proceedings"))}</span>`;
|
||||
return;
|
||||
}
|
||||
const rendered = [
|
||||
chipHtml("proc", "", t("deadlines.overhaul.modea.chip.all"), state.proc === ""),
|
||||
...chips.map((c) => {
|
||||
const label = lang === "en" ? c.nameEN || c.name : c.name;
|
||||
return chipHtml("proc", c.code, label, state.proc === c.code, undefined, c.code);
|
||||
}),
|
||||
];
|
||||
host.innerHTML = rendered.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.proc = btn.dataset.value || "";
|
||||
syncUrl();
|
||||
renderProceedingChips(chips);
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Search ------------------------------------------------------------
|
||||
|
||||
function scheduleSearch(delayMs: number): void {
|
||||
if (searchTimer !== null) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
searchTimer = null;
|
||||
void runSearch();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async function runSearch(): Promise<void> {
|
||||
searchSeq++;
|
||||
const mySeq = searchSeq;
|
||||
|
||||
const list = document.getElementById("fristen-mode-a-result-list");
|
||||
const count = document.getElementById("fristen-mode-a-results-count");
|
||||
if (!list || !count) return;
|
||||
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</li>`;
|
||||
count.textContent = "";
|
||||
|
||||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||||
url.searchParams.set("kind", "events");
|
||||
if (state.q) url.searchParams.set("q", state.q);
|
||||
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
|
||||
if (state.proc) url.searchParams.set("proc", state.proc);
|
||||
if (state.eventKind) url.searchParams.set("event_kind", state.eventKind);
|
||||
if (state.party) url.searchParams.set("party", state.party);
|
||||
|
||||
let data: EventSearchResponse;
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
if (mySeq === searchSeq) {
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
data = (await resp.json()) as EventSearchResponse;
|
||||
} catch {
|
||||
if (mySeq === searchSeq) {
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mySeq !== searchSeq) return; // stale response
|
||||
|
||||
renderResults(data);
|
||||
}
|
||||
|
||||
function renderResults(data: EventSearchResponse): void {
|
||||
const list = document.getElementById("fristen-mode-a-result-list");
|
||||
const count = document.getElementById("fristen-mode-a-results-count");
|
||||
if (!list || !count) return;
|
||||
count.textContent = tDyn("deadlines.overhaul.modea.results.count").replace("{n}", String(data.total));
|
||||
|
||||
if (data.events.length === 0) {
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-empty">${escHtml(t("deadlines.overhaul.modea.no_results"))}</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = getLang();
|
||||
list.innerHTML = data.events.map((e) => {
|
||||
const name = lang === "en" ? e.name_en || e.name_de : e.name_de;
|
||||
const pt = e.proceeding_type;
|
||||
const ptName = lang === "en" ? pt.name_en || pt.name_de : pt.name_de;
|
||||
const icon = eventKindIconForChip(e.event_kind);
|
||||
const followUps = tDyn("deadlines.overhaul.modea.row.followups").replace("{n}", String(e.follow_up_count));
|
||||
const juris = pt.jurisdiction || "";
|
||||
return `
|
||||
<li class="fristen-mode-a-result" data-event-code="${escAttr(e.code)}" tabindex="0" role="option">
|
||||
<span class="fristen-mode-a-result-icon" aria-hidden="true">${icon}</span>
|
||||
<div class="fristen-mode-a-result-body">
|
||||
<div class="fristen-mode-a-result-title">${escHtml(name)}</div>
|
||||
<div class="fristen-mode-a-result-meta">
|
||||
<span class="fristen-mode-a-result-pt">${escHtml(pt.code)}</span>
|
||||
<span class="fristen-mode-a-result-pt-name">${escHtml(ptName)}</span>
|
||||
${juris ? `<span class="fristen-mode-a-result-juris">${escHtml(juris)}</span>` : ""}
|
||||
<span class="fristen-mode-a-result-followups">${escHtml(followUps)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fristen-mode-a-result-cta" aria-hidden="true">→</span>
|
||||
</li>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".fristen-mode-a-result").forEach((li) => {
|
||||
li.addEventListener("click", () => commitEvent(li.dataset.eventCode || ""));
|
||||
li.addEventListener("keydown", (e) => {
|
||||
const k = (e as KeyboardEvent).key;
|
||||
if (k === "Enter" || k === " ") {
|
||||
e.preventDefault();
|
||||
commitEvent(li.dataset.eventCode || "");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Commit — user picked a result; lock the event as trigger and
|
||||
// transition to the §4 result view (S2).
|
||||
function commitEvent(code: string): void {
|
||||
if (!code) return;
|
||||
// Reflect in URL before re-mounting so the result view's deep link
|
||||
// is consistent.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("event", code);
|
||||
// Preserve project / forum / kind filters so a back-navigation
|
||||
// brings Mode A back with the same filters.
|
||||
history.pushState(null, "", url.pathname + url.search + url.hash);
|
||||
void mountResultView({
|
||||
eventRef: code,
|
||||
party: state.party || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Helpers -----------------------------------------------------------
|
||||
|
||||
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
|
||||
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
|
||||
const t = title ? ` title="${escAttr(title)}"` : "";
|
||||
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
|
||||
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${t}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
|
||||
}
|
||||
|
||||
function eventKindIconForChip(kind?: string): string {
|
||||
switch (kind) {
|
||||
case "filing": return "📥";
|
||||
case "hearing": return "🏛️";
|
||||
case "decision": return "⚖️";
|
||||
case "order": return "📜";
|
||||
default: return "🔍";
|
||||
}
|
||||
}
|
||||
|
||||
// syncUrl writes the active filter set into the URL so the deep link
|
||||
// restores Mode A in the same state.
|
||||
function syncUrl(): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
setOrClear(url, "forum", state.jurisdiction);
|
||||
setOrClear(url, "pt", state.proc);
|
||||
setOrClear(url, "kind", state.eventKind);
|
||||
setOrClear(url, "party", state.party);
|
||||
setOrClear(url, "q", state.q);
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
function setOrClear(url: URL, key: string, val: string): void {
|
||||
if (val) url.searchParams.set(key, val);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
defaultChecked,
|
||||
groupFollowUps,
|
||||
type FollowUpRule,
|
||||
} from "./fristenrechner-result";
|
||||
|
||||
// Pure helpers exercised here; the DOM-driven render path is covered
|
||||
// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the
|
||||
// entry-mode UIs in later slices).
|
||||
|
||||
function mk(partial: Partial<FollowUpRule>): FollowUpRule {
|
||||
return {
|
||||
rule_id: "r" + Math.random().toString(36).slice(2, 8),
|
||||
event_code: "evt",
|
||||
title_de: "Frist",
|
||||
title_en: "Deadline",
|
||||
priority: "mandatory",
|
||||
is_court_set: false,
|
||||
is_spawn: false,
|
||||
is_bilateral: false,
|
||||
has_condition: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupFollowUps — design §4.2 priority+condition buckets", () => {
|
||||
test("groups by priority; conditional takes precedence over priority", () => {
|
||||
const rows = [
|
||||
mk({ priority: "mandatory" }),
|
||||
mk({ priority: "recommended" }),
|
||||
mk({ priority: "optional" }),
|
||||
mk({ priority: "mandatory", has_condition: true }), // → conditional
|
||||
mk({ priority: "optional", has_condition: true }), // → conditional
|
||||
];
|
||||
const g = groupFollowUps(rows);
|
||||
expect(g.mandatory.length).toBe(1);
|
||||
expect(g.recommended.length).toBe(1);
|
||||
expect(g.optional.length).toBe(1);
|
||||
expect(g.conditional.length).toBe(2);
|
||||
});
|
||||
|
||||
test("unknown priority falls through to optional", () => {
|
||||
const g = groupFollowUps([mk({ priority: "informational" })]);
|
||||
expect(g.optional.length).toBe(1);
|
||||
expect(g.mandatory.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => {
|
||||
test("mandatory rules pre-checked", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true);
|
||||
});
|
||||
test("recommended rules pre-checked", () => {
|
||||
expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true);
|
||||
});
|
||||
test("optional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "optional" }))).toBe(false);
|
||||
});
|
||||
test("conditional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false);
|
||||
});
|
||||
test("court-set rules unchecked even when mandatory", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false);
|
||||
});
|
||||
test("spawned rules pre-checked when mandatory", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true);
|
||||
});
|
||||
test("spawned optional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,672 +0,0 @@
|
||||
// Fristenrechner overhaul — shared result view (design §4).
|
||||
//
|
||||
// Given a locked trigger event + a trigger date, this module renders
|
||||
// the result surface: a sticky trigger card on top, then four priority
|
||||
// groups (mandatory / recommended / optional / conditional) of follow-up
|
||||
// rules with computed dates, then a write-back footer that calls the
|
||||
// existing POST /api/projects/{id}/deadlines/bulk.
|
||||
//
|
||||
// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B
|
||||
// wizard in S4) both land here once they've identified a trigger
|
||||
// procedural_event. S2 mounts the surface under `?overhaul=1` and is
|
||||
// deep-linkable on its own via `?overhaul=1&event=<code>&trigger_date=…`.
|
||||
|
||||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||||
import { getLang, t, tDyn } from "./i18n";
|
||||
|
||||
// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors
|
||||
// services.FollowUpsResponse server-side.
|
||||
export interface FollowUpRule {
|
||||
rule_id: string;
|
||||
event_code: string;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
priority: string;
|
||||
primary_party?: string;
|
||||
duration_value?: number;
|
||||
duration_unit?: string;
|
||||
timing?: string;
|
||||
due_date?: string;
|
||||
original_due_date?: string;
|
||||
was_adjusted?: boolean;
|
||||
is_court_set: boolean;
|
||||
is_spawn: boolean;
|
||||
is_bilateral: boolean;
|
||||
has_condition: boolean;
|
||||
rule_code?: string;
|
||||
legal_source?: string;
|
||||
legal_source_display?: string;
|
||||
legal_source_url?: string;
|
||||
notes_de?: string;
|
||||
notes_en?: string;
|
||||
spawn_label?: string;
|
||||
spawn_proceeding_code?: string;
|
||||
concept_id?: string;
|
||||
}
|
||||
|
||||
export interface FollowUpsResponse {
|
||||
trigger: {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
};
|
||||
anchor_rule_id: string;
|
||||
};
|
||||
trigger_date: string;
|
||||
party?: string;
|
||||
follow_ups: FollowUpRule[];
|
||||
}
|
||||
|
||||
// Per-rule UI state — checkbox, optional date override.
|
||||
interface RuleSelection {
|
||||
checked: boolean;
|
||||
override?: string;
|
||||
}
|
||||
|
||||
// Module-local state. Single result view at a time; the surface
|
||||
// re-renders in place when the user changes the trigger date or
|
||||
// re-locks a different event.
|
||||
let currentResponse: FollowUpsResponse | null = null;
|
||||
const selections = new Map<string, RuleSelection>();
|
||||
let currentProjectId: string | null = null;
|
||||
|
||||
// Public API ----------------------------------------------------------
|
||||
|
||||
// isOverhaulMode reports whether the page is in overhaul mode.
|
||||
// After Slice S5 (t-paliad-323), overhaul is the default; the legacy
|
||||
// wizard / row-stack / cascade is only reachable via `?legacy=1` for
|
||||
// a two-week deprecation window. The `?overhaul=1` deep links from
|
||||
// S2-S4 still work — they're now redundant with the default but kept
|
||||
// alive so bookmarks don't 302 / lose state.
|
||||
export function isOverhaulMode(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("legacy") !== "1";
|
||||
}
|
||||
|
||||
// resolveProjectId reads the active Akte from the URL query string.
|
||||
// Returns null when in kontextfrei mode (no project picked).
|
||||
function resolveProjectId(): string | null {
|
||||
const p = new URLSearchParams(window.location.search).get("project");
|
||||
return p && p.length > 0 ? p : null;
|
||||
}
|
||||
|
||||
// MODE_TAB_KEYS — the two entry-mode tabs landed by S3 + S4. S2's deep
|
||||
// link path bypasses these (jumps straight to the result view via
|
||||
// ?event=); the tabs appear when no event is locked yet.
|
||||
export type ModeTab = "search" | "wizard";
|
||||
|
||||
// mountModeShell renders the mode-tab pair under the page header and
|
||||
// hosts whichever mode panel is currently active. Called from the boot
|
||||
// path when no `?event=` is present. S3 wires Mode A; S4 will add
|
||||
// Mode B and the actual tab switching.
|
||||
export async function mountModeShell(activeTab: ModeTab): Promise<void> {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.hidden = false;
|
||||
// Defer to the per-mode module to render into the root. The tab
|
||||
// strip itself is a small header above the mode panel — for S3 we
|
||||
// render the shell + Mode A in one shot.
|
||||
// S4 will replace this with a real tab switcher.
|
||||
const tabs = `
|
||||
<nav class="fristen-mode-tabs" role="tablist" aria-label="${escAttr(t("deadlines.overhaul.modes.label"))}">
|
||||
<button type="button" class="fristen-mode-tab${activeTab === "search" ? " is-active" : ""}" role="tab"
|
||||
aria-selected="${activeTab === "search"}" data-tab="search">
|
||||
<span class="fristen-mode-tab-icon" aria-hidden="true">⚡</span>
|
||||
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.search"))}</span>
|
||||
</button>
|
||||
<button type="button" class="fristen-mode-tab${activeTab === "wizard" ? " is-active" : ""}" role="tab"
|
||||
aria-selected="${activeTab === "wizard"}" data-tab="wizard">
|
||||
<span class="fristen-mode-tab-icon" aria-hidden="true">🧭</span>
|
||||
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.wizard"))}</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div id="fristen-overhaul-mode-host"></div>
|
||||
`;
|
||||
root.innerHTML = tabs;
|
||||
|
||||
// Wire tab switching. S3 only has Mode A wired; Mode B is a
|
||||
// placeholder until S4.
|
||||
root.querySelectorAll<HTMLButtonElement>(".fristen-mode-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = (btn.dataset.tab || "search") as ModeTab;
|
||||
void mountModeShell(tab);
|
||||
});
|
||||
});
|
||||
|
||||
// Mount the active mode panel into the host. S3 only routes "search";
|
||||
// "wizard" renders a placeholder until S4 lands.
|
||||
const host = document.getElementById("fristen-overhaul-mode-host");
|
||||
if (!host) return;
|
||||
if (activeTab === "search") {
|
||||
// Lazy import to keep the bundle layered and avoid a circular ref
|
||||
// between fristenrechner-result.ts ↔ fristenrechner-mode-a.ts.
|
||||
const mod = await import("./fristenrechner-mode-a");
|
||||
await mod.mountModeA();
|
||||
} else {
|
||||
const mod = await import("./fristenrechner-wizard");
|
||||
await mod.mountWizard();
|
||||
}
|
||||
}
|
||||
|
||||
// MountOptions configures the surface entry. Both entry-mode paths
|
||||
// (Mode A in S3, Mode B in S4) call mount() with the event reference
|
||||
// that the user committed.
|
||||
export interface MountOptions {
|
||||
// eventRef is the procedural_event code OR its uuid OR the anchor
|
||||
// sequencing_rule id. Resolved server-side; the wire returns the
|
||||
// canonical code so the URL bookmark is stable.
|
||||
eventRef: string;
|
||||
// triggerDate is YYYY-MM-DD. Defaults to today when omitted.
|
||||
triggerDate?: string;
|
||||
// party is "claimant" | "defendant"; mode A may pass "both" or
|
||||
// "court". When omitted, follow-ups are returned without party
|
||||
// narrowing.
|
||||
party?: string;
|
||||
// courtId selects the holiday calendar for the per-rule date
|
||||
// adjustment. Optional.
|
||||
courtId?: string;
|
||||
}
|
||||
|
||||
// mountResultView fetches /follow-ups and renders the result surface
|
||||
// into the host container. Re-callable: replaces previous state.
|
||||
export async function mountResultView(opts: MountOptions): Promise<void> {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.hidden = false;
|
||||
|
||||
const triggerDate = opts.triggerDate || todayIso();
|
||||
currentProjectId = resolveProjectId();
|
||||
|
||||
// Show a quick "loading…" placeholder so the user sees something
|
||||
// immediately, even on a cold fetch.
|
||||
root.innerHTML = `<div class="fristen-overhaul-loading">${escHtml(t("deadlines.overhaul.loading"))}</div>`;
|
||||
|
||||
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
|
||||
url.searchParams.set("event", opts.eventRef);
|
||||
url.searchParams.set("trigger_date", triggerDate);
|
||||
if (opts.party) url.searchParams.set("party", opts.party);
|
||||
if (opts.courtId) url.searchParams.set("court_id", opts.courtId);
|
||||
|
||||
let data: FollowUpsResponse;
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}) as { error?: string });
|
||||
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(body.error || t("deadlines.overhaul.load_error"))}</div>`;
|
||||
return;
|
||||
}
|
||||
data = (await resp.json()) as FollowUpsResponse;
|
||||
} catch (err) {
|
||||
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(t("deadlines.overhaul.load_error"))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
currentResponse = data;
|
||||
selections.clear();
|
||||
for (const r of data.follow_ups) {
|
||||
selections.set(r.rule_id, { checked: defaultChecked(r) });
|
||||
}
|
||||
|
||||
renderSurface();
|
||||
// Reflect the canonical event code + trigger date in the URL so the
|
||||
// deep-link survives a reload.
|
||||
syncUrlState(data.trigger.code, data.trigger_date);
|
||||
}
|
||||
|
||||
// Render --------------------------------------------------------------
|
||||
|
||||
function renderSurface(): void {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root || !currentResponse) return;
|
||||
|
||||
const lang = getLang();
|
||||
const trig = currentResponse.trigger;
|
||||
const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de;
|
||||
const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de;
|
||||
const juris = trig.proceeding_type.jurisdiction || "";
|
||||
const kindIcon = eventKindIcon(trig.event_kind);
|
||||
|
||||
const triggerCard = `
|
||||
<section class="fristen-overhaul-trigger" aria-label="${escAttr(t("deadlines.overhaul.trigger.label"))}">
|
||||
<header class="fristen-overhaul-trigger-header">
|
||||
<span class="fristen-overhaul-kind-icon" aria-hidden="true">${kindIcon}</span>
|
||||
<h2 class="fristen-overhaul-trigger-title">${escHtml(triggerName)}</h2>
|
||||
</header>
|
||||
<div class="fristen-overhaul-trigger-meta">
|
||||
<span class="fristen-overhaul-trigger-code">${escHtml(trig.code)}</span>
|
||||
<span class="fristen-overhaul-trigger-pt">${escHtml(ptName)}</span>
|
||||
${juris ? `<span class="fristen-overhaul-trigger-juris">${escHtml(juris)}</span>` : ""}
|
||||
</div>
|
||||
<div class="fristen-overhaul-trigger-date">
|
||||
<label for="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-label">
|
||||
${escHtml(t("deadlines.overhaul.trigger.date"))}
|
||||
</label>
|
||||
<input type="date" id="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-input"
|
||||
value="${escAttr(currentResponse.trigger_date)}" />
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const groups = groupFollowUps(currentResponse.follow_ups);
|
||||
const groupHtml = renderGroups(groups, lang);
|
||||
|
||||
const nudge = currentProjectId
|
||||
? ""
|
||||
: `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.nudge.no_project"))}</div>`;
|
||||
|
||||
const footer = currentProjectId
|
||||
? renderFooter()
|
||||
: "";
|
||||
|
||||
root.innerHTML = `
|
||||
${triggerCard}
|
||||
${nudge}
|
||||
<section class="fristen-overhaul-groups" aria-label="${escAttr(t("deadlines.overhaul.followups.label"))}">
|
||||
${groupHtml}
|
||||
</section>
|
||||
${footer}
|
||||
<div class="fristen-overhaul-msg" id="fristen-overhaul-msg" role="status" aria-live="polite"></div>
|
||||
`;
|
||||
|
||||
wireSurfaceEvents();
|
||||
}
|
||||
|
||||
export interface GroupedFollowUps {
|
||||
mandatory: FollowUpRule[];
|
||||
recommended: FollowUpRule[];
|
||||
optional: FollowUpRule[];
|
||||
conditional: FollowUpRule[];
|
||||
}
|
||||
|
||||
// groupFollowUps splits the wire list into the four visible groups per
|
||||
// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes
|
||||
// precedence over the priority bucket so a "nur wenn CCR" mandatory
|
||||
// rule renders under Conditional with the gating language visible.
|
||||
export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps {
|
||||
const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] };
|
||||
for (const r of rows) {
|
||||
if (r.has_condition) {
|
||||
out.conditional.push(r);
|
||||
continue;
|
||||
}
|
||||
switch (r.priority) {
|
||||
case "mandatory":
|
||||
out.mandatory.push(r);
|
||||
break;
|
||||
case "recommended":
|
||||
out.recommended.push(r);
|
||||
break;
|
||||
case "optional":
|
||||
out.optional.push(r);
|
||||
break;
|
||||
default:
|
||||
// unknown / informational — fold into optional so the row is at
|
||||
// least visible. Future Phase 2 'informational' tier gets a
|
||||
// dedicated bucket once seeded.
|
||||
out.optional.push(r);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string {
|
||||
const blocks: string[] = [];
|
||||
if (groups.mandatory.length > 0) {
|
||||
blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang));
|
||||
}
|
||||
if (groups.recommended.length > 0) {
|
||||
blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang));
|
||||
}
|
||||
if (groups.optional.length > 0) {
|
||||
blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang));
|
||||
}
|
||||
if (groups.conditional.length > 0) {
|
||||
blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang));
|
||||
}
|
||||
if (blocks.length === 0) {
|
||||
return `<div class="fristen-overhaul-empty">${escHtml(t("deadlines.overhaul.empty"))}</div>`;
|
||||
}
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string {
|
||||
const items = rows.map((r) => renderRule(r, lang)).join("");
|
||||
return `
|
||||
<div class="fristen-overhaul-group fristen-overhaul-group--${escAttr(slug)}">
|
||||
<h3 class="fristen-overhaul-group-title">${escHtml(label)}</h3>
|
||||
<ul class="fristen-overhaul-rule-list">
|
||||
${items}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
||||
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
||||
const sel = selections.get(r.rule_id);
|
||||
const checked = sel ? sel.checked : defaultChecked(r);
|
||||
const dateOverride = sel?.override;
|
||||
const computedDate = r.due_date || "";
|
||||
const effectiveDate = dateOverride || computedDate;
|
||||
const disabled = r.is_court_set || (r.is_spawn && !r.due_date);
|
||||
|
||||
// Duration phrase: "3 Monate" / "14 Tage" — language-aware.
|
||||
const durationPhrase = formatDurationPhrase(r, lang);
|
||||
const dateCell = r.is_court_set
|
||||
? `<span class="fristen-overhaul-rule-court-set">${escHtml(t("deadlines.court.set"))}</span>`
|
||||
: effectiveDate
|
||||
? `<span class="fristen-overhaul-rule-date" data-rule-id="${escAttr(r.rule_id)}">${escHtml(formatDateForLang(effectiveDate, lang))}</span>`
|
||||
: `<span class="fristen-overhaul-rule-date fristen-overhaul-rule-date--unknown">—</span>`;
|
||||
|
||||
const partyBadge = r.primary_party
|
||||
? `<span class="fristen-overhaul-rule-party fristen-overhaul-rule-party--${escAttr(r.primary_party)}">${escHtml(t(`deadlines.party.${r.primary_party}` as never))}</span>`
|
||||
: "";
|
||||
|
||||
const sourceBadge = r.legal_source_display
|
||||
? r.legal_source_url
|
||||
? `<a class="fristen-overhaul-rule-source" href="${escAttr(r.legal_source_url)}" target="_blank" rel="noreferrer">${escHtml(r.legal_source_display)}</a>`
|
||||
: `<span class="fristen-overhaul-rule-source">${escHtml(r.legal_source_display)}</span>`
|
||||
: r.rule_code
|
||||
? `<span class="fristen-overhaul-rule-source">${escHtml(r.rule_code)}</span>`
|
||||
: "";
|
||||
|
||||
const spawnBadge = r.is_spawn
|
||||
? `<span class="fristen-overhaul-rule-spawn" title="${escAttr(t("deadlines.overhaul.spawn.tooltip"))}">${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}</span>`
|
||||
: "";
|
||||
|
||||
const condBadge = r.has_condition
|
||||
? `<span class="fristen-overhaul-rule-cond">${escHtml(t("deadlines.overhaul.condition.badge"))}</span>`
|
||||
: "";
|
||||
|
||||
const notesHtml = notes
|
||||
? `<details class="fristen-overhaul-rule-notes"><summary>${escHtml(t("deadlines.overhaul.notes.summary"))}</summary><p>${escHtml(notes)}</p></details>`
|
||||
: "";
|
||||
|
||||
const editBtn = r.is_court_set || r.is_spawn || !computedDate
|
||||
? ""
|
||||
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
|
||||
|
||||
return `
|
||||
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}" data-rule-id="${escAttr(r.rule_id)}">
|
||||
<label class="fristen-overhaul-rule-check">
|
||||
<input type="checkbox" data-rule-id="${escAttr(r.rule_id)}"
|
||||
${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
|
||||
<span class="visually-hidden">${escHtml(t("deadlines.overhaul.select_rule"))}</span>
|
||||
</label>
|
||||
<div class="fristen-overhaul-rule-body">
|
||||
<div class="fristen-overhaul-rule-title-row">
|
||||
<span class="fristen-overhaul-rule-title">${escHtml(title)}</span>
|
||||
${spawnBadge}
|
||||
${condBadge}
|
||||
</div>
|
||||
<div class="fristen-overhaul-rule-meta-row">
|
||||
${durationPhrase ? `<span class="fristen-overhaul-rule-duration">${escHtml(durationPhrase)}</span>` : ""}
|
||||
${partyBadge}
|
||||
${sourceBadge}
|
||||
</div>
|
||||
${notesHtml}
|
||||
</div>
|
||||
<div class="fristen-overhaul-rule-date-cell">
|
||||
${dateCell}
|
||||
${editBtn}
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFooter(): string {
|
||||
const selectedCount = countSelected();
|
||||
return `
|
||||
<footer class="fristen-overhaul-footer" id="fristen-overhaul-footer">
|
||||
<span class="fristen-overhaul-footer-count" id="fristen-overhaul-footer-count">
|
||||
${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))}
|
||||
</span>
|
||||
<button type="button" class="fristen-overhaul-footer-cta btn-primary btn-cta-lime"
|
||||
id="fristen-overhaul-write-back"
|
||||
${selectedCount === 0 ? "disabled" : ""}>
|
||||
${escHtml(t("deadlines.overhaul.footer.cta"))}
|
||||
</button>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
// Event wiring --------------------------------------------------------
|
||||
|
||||
function wireSurfaceEvents(): void {
|
||||
// Trigger-date change → re-fetch with new date.
|
||||
const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput && currentResponse) {
|
||||
dateInput.addEventListener("change", () => {
|
||||
if (!currentResponse) return;
|
||||
const newDate = dateInput.value;
|
||||
if (!newDate) return;
|
||||
void mountResultView({
|
||||
eventRef: currentResponse.trigger.code,
|
||||
triggerDate: newDate,
|
||||
party: currentResponse.party,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Checkbox toggles → update selections + footer count.
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (root) {
|
||||
root.querySelectorAll<HTMLInputElement>(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const id = cb.dataset.ruleId || "";
|
||||
const sel = selections.get(id) ?? { checked: cb.checked };
|
||||
sel.checked = cb.checked;
|
||||
selections.set(id, sel);
|
||||
refreshFooterCount();
|
||||
});
|
||||
});
|
||||
|
||||
// Per-rule date override.
|
||||
root.querySelectorAll<HTMLButtonElement>(".fristen-overhaul-rule-edit-date").forEach((btn) => {
|
||||
btn.addEventListener("click", () => editRuleDate(btn));
|
||||
});
|
||||
}
|
||||
|
||||
// Write-back CTA.
|
||||
const cta = document.getElementById("fristen-overhaul-write-back");
|
||||
if (cta) cta.addEventListener("click", () => void submitWriteBack());
|
||||
}
|
||||
|
||||
function editRuleDate(btn: HTMLButtonElement): void {
|
||||
const ruleId = btn.dataset.ruleId || "";
|
||||
const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId);
|
||||
if (!rule) return;
|
||||
const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) };
|
||||
const current = sel.override || rule.due_date || todayIso();
|
||||
|
||||
const dateCell = btn.parentElement;
|
||||
if (!dateCell) return;
|
||||
const dateSpan = dateCell.querySelector<HTMLSpanElement>(".fristen-overhaul-rule-date");
|
||||
if (!dateSpan) return;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "date";
|
||||
input.value = current;
|
||||
input.className = "fristen-overhaul-rule-date-input";
|
||||
dateSpan.replaceWith(input);
|
||||
btn.disabled = true;
|
||||
input.focus();
|
||||
|
||||
const commit = () => {
|
||||
const newDate = input.value;
|
||||
if (newDate && newDate !== current) {
|
||||
sel.override = newDate;
|
||||
selections.set(ruleId, sel);
|
||||
}
|
||||
renderSurface();
|
||||
};
|
||||
input.addEventListener("blur", commit, { once: true });
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") {
|
||||
e.preventDefault();
|
||||
input.blur();
|
||||
} else if ((e as KeyboardEvent).key === "Escape") {
|
||||
renderSurface();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshFooterCount(): void {
|
||||
const countEl = document.getElementById("fristen-overhaul-footer-count");
|
||||
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
||||
const n = countSelected();
|
||||
if (countEl) {
|
||||
countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n));
|
||||
}
|
||||
if (cta) cta.disabled = n === 0;
|
||||
}
|
||||
|
||||
function countSelected(): number {
|
||||
let n = 0;
|
||||
if (!currentResponse) return 0;
|
||||
for (const r of currentResponse.follow_ups) {
|
||||
if (r.is_court_set) continue;
|
||||
const sel = selections.get(r.rule_id);
|
||||
if (sel?.checked) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Write-back ----------------------------------------------------------
|
||||
|
||||
async function submitWriteBack(): Promise<void> {
|
||||
if (!currentResponse) return;
|
||||
if (!currentProjectId) return;
|
||||
const msg = document.getElementById("fristen-overhaul-msg");
|
||||
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
||||
const lang = getLang();
|
||||
|
||||
const deadlines: Array<Record<string, unknown>> = [];
|
||||
for (const r of currentResponse.follow_ups) {
|
||||
const sel = selections.get(r.rule_id);
|
||||
if (!sel?.checked) continue;
|
||||
if (r.is_court_set) continue;
|
||||
const dueDate = sel.override || r.due_date;
|
||||
if (!dueDate) continue;
|
||||
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
||||
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
||||
deadlines.push({
|
||||
title,
|
||||
rule_code: r.rule_code || undefined,
|
||||
due_date: dueDate,
|
||||
original_due_date: r.original_due_date || r.due_date || undefined,
|
||||
source: "fristenrechner",
|
||||
rule_id: r.rule_id,
|
||||
notes: notes || undefined,
|
||||
audit_reason: auditReason(),
|
||||
});
|
||||
}
|
||||
|
||||
if (deadlines.length === 0 || !msg || !cta) return;
|
||||
cta.disabled = true;
|
||||
msg.textContent = "";
|
||||
msg.className = "fristen-overhaul-msg";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deadlines }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = body.error || t("deadlines.save.error");
|
||||
msg.className = "fristen-overhaul-msg form-msg-error";
|
||||
cta.disabled = false;
|
||||
return;
|
||||
}
|
||||
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(currentProjectId)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
|
||||
msg.className = "fristen-overhaul-msg form-msg-ok";
|
||||
setTimeout(() => {
|
||||
if (cta) cta.disabled = false;
|
||||
}, 1500);
|
||||
} catch {
|
||||
msg.textContent = t("deadlines.save.error");
|
||||
msg.className = "fristen-overhaul-msg form-msg-error";
|
||||
cta.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})".
|
||||
function auditReason(): string {
|
||||
if (!currentResponse) return "";
|
||||
const name = currentResponse.trigger.name_de;
|
||||
const date = currentResponse.trigger_date;
|
||||
return `Aus Fristenrechner — Trigger: ${name} (${date})`;
|
||||
}
|
||||
|
||||
// Helpers -------------------------------------------------------------
|
||||
|
||||
export function defaultChecked(r: FollowUpRule): boolean {
|
||||
if (r.is_court_set) return false;
|
||||
if (r.is_spawn) return r.priority === "mandatory";
|
||||
if (r.has_condition) return false;
|
||||
return r.priority === "mandatory" || r.priority === "recommended";
|
||||
}
|
||||
|
||||
function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
if (!r.duration_value || !r.duration_unit) return "";
|
||||
const unitDE: Record<string, string> = {
|
||||
days: "Tage",
|
||||
months: "Monate",
|
||||
weeks: "Wochen",
|
||||
years: "Jahre",
|
||||
};
|
||||
const unitEN: Record<string, string> = {
|
||||
days: "days",
|
||||
months: "months",
|
||||
weeks: "weeks",
|
||||
years: "years",
|
||||
};
|
||||
const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit;
|
||||
return `${r.duration_value} ${u}`;
|
||||
}
|
||||
|
||||
function formatDateForLang(iso: string, lang: "de" | "en"): string {
|
||||
// YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short).
|
||||
if (!iso || iso.length < 10) return iso;
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return iso;
|
||||
if (lang === "en") {
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
const idx = parseInt(m, 10) - 1;
|
||||
const mn = idx >= 0 && idx < months.length ? months[idx] : m;
|
||||
return `${parseInt(d, 10)} ${mn} ${y}`;
|
||||
}
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
function eventKindIcon(kind?: string): string {
|
||||
switch (kind) {
|
||||
case "filing": return "📥"; // inbox/letter
|
||||
case "hearing": return "🏛️"; // courthouse
|
||||
case "decision": return "⚖️"; // scales
|
||||
case "order": return "📜"; // page
|
||||
default: return "📅"; // calendar
|
||||
}
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function syncUrlState(eventCode: string, triggerDate: string): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("event", eventCode);
|
||||
url.searchParams.set("trigger_date", triggerDate);
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { followUpsDifferByParty } from "./fristenrechner-wizard";
|
||||
|
||||
describe("followUpsDifferByParty — R5 trigger condition (S4, design §3.2)", () => {
|
||||
test("true when both claimant and defendant rules present", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(true);
|
||||
});
|
||||
test("false when all claimant", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "claimant" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when all defendant", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when only 'both' rules", () => {
|
||||
// "Both" rules are bilateral procedural moves (Vertraulichkeits-
|
||||
// Erwiderung); they don't gate R5 because either party can be
|
||||
// looking at them.
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "both" },
|
||||
{ primary_party: "both" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when only court rules", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "court" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("true when mixed with both / court alongside the asymmetric pair", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "both" },
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "court" },
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(true);
|
||||
});
|
||||
test("false on empty list", () => {
|
||||
expect(followUpsDifferByParty([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,711 +0,0 @@
|
||||
// Fristenrechner overhaul Mode B — "Geführt" / wizard (design §3.2).
|
||||
//
|
||||
// 3-5 question row stack that lands the user on one procedural_event
|
||||
// (the trigger), then transitions to the shared §4 result view.
|
||||
//
|
||||
// R1 Was ist passiert? (event_kind) always asked
|
||||
// R2 Vor welchem Gericht? (jurisdiction) skip if R1 narrows
|
||||
// R3 In welchem Verfahren? (proceeding_type) auto-skip when 1 option
|
||||
// R4 Welches Schriftstück? (procedural_event — land) always asked
|
||||
// R5 Welche Seite vertreten Sie? (party) only when follow-ups differ
|
||||
//
|
||||
// Row badges per §11.Q3: R1+R2 = "Filter", R3+R4+R5 = "Qualifier".
|
||||
// R5 has NO "Beide" option per §11.Q8 (Mode B is the file-mode where
|
||||
// perspective is a qualifier).
|
||||
// Pre-fill + collapse rows from project (project.proceeding_type →
|
||||
// R3 + R2 derived; project.our_side → R5). Preserve compatible
|
||||
// downstream picks on back-navigation (§11.Q10).
|
||||
|
||||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||||
import { getLang, t, tDyn } from "./i18n";
|
||||
import { mountResultView } from "./fristenrechner-result";
|
||||
|
||||
// Wire shapes — duplicates the parts of fristenrechner-mode-a.ts we
|
||||
// need; kept local so the wizard doesn't depend on Mode A.
|
||||
|
||||
interface EventSearchHit {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
};
|
||||
follow_up_count: number;
|
||||
}
|
||||
|
||||
interface EventSearchResponse {
|
||||
events: EventSearchHit[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ProceedingChip {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
}
|
||||
|
||||
type Forum = "UPC" | "DE" | "EPA" | "DPMA";
|
||||
type EventKindRow = "filing" | "hearing" | "decision" | "order" | "missed";
|
||||
type WizardParty = "claimant" | "defendant";
|
||||
|
||||
// WIZARD_HOST_ID is the DOM id the wizard renders into. Mounted by
|
||||
// fristenrechner-result.mountModeShell which creates the host element
|
||||
// under the overhaul root.
|
||||
const WIZARD_HOST_ID = "fristen-overhaul-mode-host";
|
||||
|
||||
// FORUMS + EVENT_KINDS — closed sets. Keep parallel to Mode A's lists
|
||||
// so re-grouping happens in one place.
|
||||
const FORUMS: Forum[] = ["UPC", "DE", "EPA", "DPMA"];
|
||||
const EVENT_KINDS: EventKindRow[] = ["filing", "hearing", "decision", "order", "missed"];
|
||||
|
||||
// Single wizard state. Module-local; one wizard at a time.
|
||||
interface WizardState {
|
||||
// Picks. "" = not answered. R5 only set when the question is asked.
|
||||
r1: EventKindRow | "";
|
||||
r2: Forum | "";
|
||||
r3: string; // proceeding_types.code
|
||||
r4: string; // procedural_events.code
|
||||
r5: WizardParty | "";
|
||||
|
||||
// Pre-fill provenance — when a pick came from the project context,
|
||||
// the row renders with an "aus Akte" tag so the user notices.
|
||||
r2FromProject: boolean;
|
||||
r3FromProject: boolean;
|
||||
r5FromProject: boolean;
|
||||
|
||||
// Implicit fills — R2 auto-derived from R1 when R1 narrows to one
|
||||
// forum (e.g. "missed" → no narrowing, "filing" → cross-forum, but
|
||||
// if downstream R3 lookup returns a single forum we can mark R2 as
|
||||
// implicit).
|
||||
r2Implicit: boolean;
|
||||
r3Implicit: boolean;
|
||||
}
|
||||
|
||||
const state: WizardState = {
|
||||
r1: "", r2: "", r3: "", r4: "", r5: "",
|
||||
r2FromProject: false, r3FromProject: false, r5FromProject: false,
|
||||
r2Implicit: false, r3Implicit: false,
|
||||
};
|
||||
|
||||
// Loaded from the project (if any).
|
||||
let projectSummary: ProjectSummary | null = null;
|
||||
|
||||
// Proceeding chip cache key: jurisdiction × event_kind.
|
||||
let lastProcCacheKey = "";
|
||||
let cachedProcChips: ProceedingChip[] = [];
|
||||
|
||||
// Event chip cache: keyed on R3 code + R1 event_kind.
|
||||
let lastEventCacheKey = "";
|
||||
let cachedEventChips: EventSearchHit[] = [];
|
||||
|
||||
// Public API ---------------------------------------------------------
|
||||
|
||||
export async function mountWizard(): Promise<void> {
|
||||
const host = document.getElementById(WIZARD_HOST_ID);
|
||||
if (!host) return;
|
||||
|
||||
// Hydrate from URL state (mode=wizard&forum=UPC&pt=upc.inf.cfi&…).
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
state.r1 = (params.get("kind") as EventKindRow) || "";
|
||||
state.r2 = (params.get("forum") as Forum) || "";
|
||||
state.r3 = params.get("pt") || "";
|
||||
state.r4 = params.get("event") || "";
|
||||
state.r5 = (params.get("party") as WizardParty) || "";
|
||||
|
||||
// Project prefills.
|
||||
const projectId = params.get("project");
|
||||
if (projectId) {
|
||||
projectSummary = await fetchProject(projectId);
|
||||
await applyProjectPrefills();
|
||||
} else {
|
||||
projectSummary = null;
|
||||
}
|
||||
|
||||
renderShell();
|
||||
void renderRows();
|
||||
}
|
||||
|
||||
// applyProjectPrefills derives R2 + R3 + R5 from the project when they
|
||||
// haven't been set explicitly. Project picks take precedence over
|
||||
// unspecified state, but a user-supplied URL pick wins over the
|
||||
// project default.
|
||||
async function applyProjectPrefills(): Promise<void> {
|
||||
if (!projectSummary) return;
|
||||
// Map our_side → R5.
|
||||
if (!state.r5) {
|
||||
const side = projectSummary.our_side;
|
||||
if (side === "claimant" || side === "applicant" || side === "appellant") {
|
||||
state.r5 = "claimant";
|
||||
state.r5FromProject = true;
|
||||
} else if (side === "defendant" || side === "respondent") {
|
||||
state.r5 = "defendant";
|
||||
state.r5FromProject = true;
|
||||
}
|
||||
}
|
||||
// Map proceeding_type_id → R3 + infer R2 jurisdiction.
|
||||
if (projectSummary.proceeding_type_id && !state.r3) {
|
||||
const pt = await fetchProceedingByID(projectSummary.proceeding_type_id);
|
||||
if (pt) {
|
||||
state.r3 = pt.code;
|
||||
state.r3FromProject = true;
|
||||
if (pt.group && !state.r2) {
|
||||
state.r2 = pt.group as Forum;
|
||||
state.r2FromProject = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render -------------------------------------------------------------
|
||||
|
||||
function renderShell(): void {
|
||||
const host = document.getElementById(WIZARD_HOST_ID);
|
||||
if (!host) return;
|
||||
host.innerHTML = `
|
||||
<div class="fristen-wizard-root">
|
||||
<header class="fristen-wizard-header">
|
||||
<h2 class="fristen-wizard-title">${escHtml(t("deadlines.overhaul.wizard.heading"))}</h2>
|
||||
<p class="fristen-wizard-hint">${escHtml(t("deadlines.overhaul.wizard.hint"))}</p>
|
||||
</header>
|
||||
<div class="fristen-wizard-rows" id="fristen-wizard-rows" aria-live="polite"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function renderRows(): Promise<void> {
|
||||
const host = document.getElementById("fristen-wizard-rows");
|
||||
if (!host) return;
|
||||
|
||||
// Resolve dynamic row prerequisites BEFORE building markup so chip
|
||||
// sets are populated.
|
||||
if (state.r1 && state.r2) {
|
||||
await ensureProceedingChips(state.r2, state.r1);
|
||||
// Auto-skip R3 when the narrowed pool has exactly one option.
|
||||
if (!state.r3 && cachedProcChips.length === 1) {
|
||||
state.r3 = cachedProcChips[0].code;
|
||||
state.r3Implicit = true;
|
||||
}
|
||||
}
|
||||
if (state.r1 && state.r3) {
|
||||
await ensureEventChips(state.r3, state.r1);
|
||||
}
|
||||
|
||||
const rows: string[] = [];
|
||||
rows.push(rowR1());
|
||||
if (shouldShowR2()) rows.push(rowR2());
|
||||
if (shouldShowR3()) rows.push(rowR3());
|
||||
if (shouldShowR4()) rows.push(rowR4());
|
||||
if (state.r4 && shouldShowR5Sync()) rows.push(rowR5Loading());
|
||||
|
||||
host.innerHTML = rows.join("");
|
||||
wireRowEvents();
|
||||
|
||||
// R5 conditional check — fires after R4 picked. Inspects /follow-ups
|
||||
// to see whether they actually differ by party. If yes, show R5. If
|
||||
// no, or R5 already set, transition straight to result view.
|
||||
if (state.r4) {
|
||||
void maybeAdvanceFromR4();
|
||||
}
|
||||
}
|
||||
|
||||
// Should-show predicates --------------------------------------------
|
||||
|
||||
function shouldShowR2(): boolean {
|
||||
// Skip R2 only when R1 narrows to a single forum — which today
|
||||
// never happens for the closed event_kind set (every kind exists in
|
||||
// multiple jurisdictions). Always show R2 until we have empirical
|
||||
// evidence otherwise.
|
||||
return state.r1 !== "" && state.r1 !== "missed";
|
||||
}
|
||||
|
||||
function shouldShowR3(): boolean {
|
||||
if (state.r1 === "" || state.r2 === "") return false;
|
||||
if (state.r3 && state.r3Implicit) return true; // visible compact
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldShowR4(): boolean {
|
||||
return state.r3 !== "" && state.r1 !== "";
|
||||
}
|
||||
|
||||
// shouldShowR5Sync renders the placeholder row immediately; the actual
|
||||
// asked-or-not decision happens after the async follow-ups probe in
|
||||
// maybeAdvanceFromR4.
|
||||
function shouldShowR5Sync(): boolean {
|
||||
return state.r4 !== "";
|
||||
}
|
||||
|
||||
// Row builders ------------------------------------------------------
|
||||
|
||||
function rowR1(): string {
|
||||
const chips = EVENT_KINDS.map((k) => {
|
||||
const label = t(`deadlines.overhaul.kind.${k}` as never);
|
||||
const icon = eventKindIcon(k);
|
||||
return chipHtml("r1", k, label, state.r1 === k, icon);
|
||||
}).join("");
|
||||
return rowShell({
|
||||
n: 1,
|
||||
badge: "filter",
|
||||
label: t("deadlines.overhaul.wizard.r1.label"),
|
||||
active: !state.r1,
|
||||
answeredText: state.r1 ? t(`deadlines.overhaul.kind.${state.r1}` as never) : "",
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR2(): string {
|
||||
const chips = FORUMS.map((f) => chipHtml("r2", f, f, state.r2 === f)).join("");
|
||||
return rowShell({
|
||||
n: 2,
|
||||
badge: "filter",
|
||||
label: t("deadlines.overhaul.wizard.r2.label"),
|
||||
active: !state.r2,
|
||||
fromProject: state.r2FromProject,
|
||||
answeredText: state.r2 || "",
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR3(): string {
|
||||
if (cachedProcChips.length === 0) {
|
||||
return rowShell({
|
||||
n: 3, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r3.label"),
|
||||
active: true,
|
||||
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r3.empty"))}</div>`,
|
||||
});
|
||||
}
|
||||
const lang = getLang();
|
||||
const chips = cachedProcChips.map((p) => {
|
||||
const label = lang === "en" ? p.nameEN || p.name : p.name;
|
||||
return chipHtml("r3", p.code, label, state.r3 === p.code, undefined, p.code);
|
||||
}).join("");
|
||||
let answered = "";
|
||||
if (state.r3) {
|
||||
const hit = cachedProcChips.find((p) => p.code === state.r3);
|
||||
if (hit) answered = lang === "en" ? hit.nameEN || hit.name : hit.name;
|
||||
}
|
||||
return rowShell({
|
||||
n: 3,
|
||||
badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r3.label"),
|
||||
active: !state.r3,
|
||||
fromProject: state.r3FromProject,
|
||||
implicit: state.r3Implicit,
|
||||
answeredText: answered,
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR4(): string {
|
||||
if (cachedEventChips.length === 0) {
|
||||
return rowShell({
|
||||
n: 4, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r4.label"),
|
||||
active: true,
|
||||
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r4.empty"))}</div>`,
|
||||
});
|
||||
}
|
||||
const lang = getLang();
|
||||
const chips = cachedEventChips.map((e) => {
|
||||
const label = lang === "en" ? e.name_en || e.name_de : e.name_de;
|
||||
return chipHtml("r4", e.code, label, state.r4 === e.code, eventKindIcon(e.event_kind as EventKindRow));
|
||||
}).join("");
|
||||
let answered = "";
|
||||
if (state.r4) {
|
||||
const hit = cachedEventChips.find((e) => e.code === state.r4);
|
||||
if (hit) answered = lang === "en" ? hit.name_en || hit.name_de : hit.name_de;
|
||||
}
|
||||
return rowShell({
|
||||
n: 4,
|
||||
badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r4.label"),
|
||||
active: !state.r4,
|
||||
answeredText: answered,
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR5Loading(): string {
|
||||
// Placeholder while we probe whether R5 is needed. The async
|
||||
// follow-ups probe replaces this with rowR5 chips or skips
|
||||
// straight to the result view.
|
||||
return rowShell({
|
||||
n: 5, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r5.label"),
|
||||
active: !state.r5,
|
||||
fromProject: state.r5FromProject,
|
||||
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
|
||||
body: `<div class="fristen-wizard-probe">${escHtml(t("deadlines.overhaul.wizard.r5.probing"))}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR5Chips(): string {
|
||||
const chips = (["claimant", "defendant"] as const).map((p) =>
|
||||
chipHtml("r5", p, t(`deadlines.party.${p}` as never), state.r5 === p)).join("");
|
||||
return rowShell({
|
||||
n: 5, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r5.label"),
|
||||
active: !state.r5,
|
||||
fromProject: state.r5FromProject,
|
||||
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
interface RowShellOpts {
|
||||
n: number;
|
||||
badge: "filter" | "qualifier";
|
||||
label: string;
|
||||
active: boolean;
|
||||
body: string;
|
||||
answeredText?: string;
|
||||
fromProject?: boolean;
|
||||
implicit?: boolean;
|
||||
}
|
||||
|
||||
function rowShell(o: RowShellOpts): string {
|
||||
const cls = `fristen-wizard-row fristen-wizard-row--${o.badge}` +
|
||||
(o.active ? " is-active" : " is-answered") +
|
||||
(o.fromProject ? " is-from-project" : "") +
|
||||
(o.implicit ? " is-implicit" : "");
|
||||
const badgeText = o.badge === "filter"
|
||||
? t("deadlines.overhaul.wizard.badge.filter")
|
||||
: t("deadlines.overhaul.wizard.badge.qualifier");
|
||||
const annotations: string[] = [];
|
||||
if (o.fromProject) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.from_project"))}</span>`);
|
||||
if (o.implicit) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.implicit"))}</span>`);
|
||||
const answered = o.answeredText
|
||||
? `<span class="fristen-wizard-row-answer">${escHtml(o.answeredText)}</span>`
|
||||
: "";
|
||||
const edit = !o.active
|
||||
? `<button type="button" class="fristen-wizard-row-edit" data-row="${o.n}">${escHtml(t("deadlines.overhaul.wizard.edit"))}</button>`
|
||||
: "";
|
||||
return `
|
||||
<section class="${cls}" data-row="${o.n}">
|
||||
<header class="fristen-wizard-row-header">
|
||||
<span class="fristen-wizard-row-n">${o.n}</span>
|
||||
<span class="fristen-wizard-row-badge fristen-wizard-row-badge--${o.badge}">${escHtml(badgeText)}</span>
|
||||
<span class="fristen-wizard-row-label">${escHtml(o.label)}</span>
|
||||
${annotations.join("")}
|
||||
${answered}
|
||||
${edit}
|
||||
</header>
|
||||
${o.active ? `<div class="fristen-wizard-row-body">${o.body}</div>` : ""}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
// Event wiring ------------------------------------------------------
|
||||
|
||||
function wireRowEvents(): void {
|
||||
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row .fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const axis = btn.dataset.axis || "";
|
||||
const value = btn.dataset.value || "";
|
||||
handleChip(axis, value);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row-edit").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const n = parseInt(btn.dataset.row || "0", 10);
|
||||
handleEdit(n);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleChip(axis: string, value: string): void {
|
||||
switch (axis) {
|
||||
case "r1": {
|
||||
if (state.r1 === value) return;
|
||||
state.r1 = value as EventKindRow;
|
||||
// R1 change resets R3/R4 (event-kind narrows the pools).
|
||||
state.r3 = "";
|
||||
state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
state.r5 = state.r5FromProject ? state.r5 : "";
|
||||
cachedEventChips = [];
|
||||
lastEventCacheKey = "";
|
||||
cachedProcChips = [];
|
||||
lastProcCacheKey = "";
|
||||
break;
|
||||
}
|
||||
case "r2": {
|
||||
if (state.r2 === value) return;
|
||||
state.r2 = value as Forum;
|
||||
state.r2FromProject = false;
|
||||
state.r2Implicit = false;
|
||||
// R2 change may invalidate R3 → reset.
|
||||
state.r3 = "";
|
||||
state.r3FromProject = false;
|
||||
state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedProcChips = [];
|
||||
lastProcCacheKey = "";
|
||||
cachedEventChips = [];
|
||||
lastEventCacheKey = "";
|
||||
break;
|
||||
}
|
||||
case "r3": {
|
||||
if (state.r3 === value) return;
|
||||
state.r3 = value;
|
||||
state.r3FromProject = false;
|
||||
state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedEventChips = [];
|
||||
lastEventCacheKey = "";
|
||||
break;
|
||||
}
|
||||
case "r4": {
|
||||
if (state.r4 === value) return;
|
||||
state.r4 = value;
|
||||
break;
|
||||
}
|
||||
case "r5": {
|
||||
if (state.r5 === value) return;
|
||||
state.r5 = value as WizardParty;
|
||||
state.r5FromProject = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
syncUrl();
|
||||
void renderRows();
|
||||
}
|
||||
|
||||
function handleEdit(n: number): void {
|
||||
switch (n) {
|
||||
case 1:
|
||||
state.r1 = ""; state.r2 = ""; state.r3 = ""; state.r4 = ""; state.r5 = state.r5FromProject ? state.r5 : "";
|
||||
cachedProcChips = []; lastProcCacheKey = "";
|
||||
cachedEventChips = []; lastEventCacheKey = "";
|
||||
break;
|
||||
case 2:
|
||||
state.r2 = ""; state.r2FromProject = false; state.r2Implicit = false;
|
||||
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedProcChips = []; lastProcCacheKey = "";
|
||||
cachedEventChips = []; lastEventCacheKey = "";
|
||||
break;
|
||||
case 3:
|
||||
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedEventChips = []; lastEventCacheKey = "";
|
||||
break;
|
||||
case 4:
|
||||
state.r4 = "";
|
||||
state.r5 = state.r5FromProject ? state.r5 : "";
|
||||
break;
|
||||
case 5:
|
||||
state.r5 = ""; state.r5FromProject = false;
|
||||
break;
|
||||
}
|
||||
syncUrl();
|
||||
void renderRows();
|
||||
}
|
||||
|
||||
// maybeAdvanceFromR4 fetches /follow-ups for the picked event to
|
||||
// decide whether R5 is needed. If R5 is already set OR the
|
||||
// follow-ups don't differ by party, transition straight to the
|
||||
// result view. Else swap the R5 loading row for the chip picker.
|
||||
async function maybeAdvanceFromR4(): Promise<void> {
|
||||
if (!state.r4) return;
|
||||
if (state.r5) {
|
||||
// R5 already answered (project prefill or explicit pick) → go.
|
||||
void launchResult();
|
||||
return;
|
||||
}
|
||||
// Probe follow-ups.
|
||||
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
|
||||
url.searchParams.set("event", state.r4);
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
// Soft-fail → swap to R5 chips so the user can decide manually.
|
||||
swapR5(rowR5Chips());
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as { follow_ups: Array<{ primary_party?: string }> };
|
||||
const differs = followUpsDifferByParty(data.follow_ups);
|
||||
if (!differs) {
|
||||
void launchResult();
|
||||
return;
|
||||
}
|
||||
swapR5(rowR5Chips());
|
||||
} catch {
|
||||
swapR5(rowR5Chips());
|
||||
}
|
||||
}
|
||||
|
||||
function swapR5(html: string): void {
|
||||
const host = document.getElementById("fristen-wizard-rows");
|
||||
if (!host) return;
|
||||
const r5 = host.querySelector('.fristen-wizard-row[data-row="5"]');
|
||||
if (!r5) {
|
||||
host.insertAdjacentHTML("beforeend", html);
|
||||
} else {
|
||||
r5.outerHTML = html;
|
||||
}
|
||||
wireRowEvents();
|
||||
}
|
||||
|
||||
function launchResult(): void {
|
||||
// Hand off to the §4 result view. The URL already carries the
|
||||
// picks via syncUrl(); add event= so the boot path treats this
|
||||
// as a deep-link.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("event", state.r4);
|
||||
if (state.r5) url.searchParams.set("party", state.r5);
|
||||
else url.searchParams.delete("party");
|
||||
history.pushState(null, "", url.pathname + url.search + url.hash);
|
||||
void mountResultView({ eventRef: state.r4, party: state.r5 || undefined });
|
||||
}
|
||||
|
||||
export function followUpsDifferByParty(rows: Array<{ primary_party?: string }>): boolean {
|
||||
let hasClaimant = false, hasDefendant = false;
|
||||
for (const r of rows) {
|
||||
if (r.primary_party === "claimant") hasClaimant = true;
|
||||
else if (r.primary_party === "defendant") hasDefendant = true;
|
||||
if (hasClaimant && hasDefendant) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetches -----------------------------------------------------------
|
||||
|
||||
async function fetchProject(id: string): Promise<ProjectSummary | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as ProjectSummary;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProceedingByID(id: number): Promise<ProceedingChip | null> {
|
||||
// The proceeding-types endpoint returns codes, names, jurisdictions
|
||||
// but doesn't carry the id (the wire shape FristenrechnerType is
|
||||
// code-keyed). Walk the unfiltered list and pick by sort-order
|
||||
// proximity / sort-fallback: we need the row whose id matches; since
|
||||
// the wire doesn't expose id, fetch the projects detail to get the
|
||||
// code directly. Cheap workaround: rely on /api/projects/{id}'s
|
||||
// proceeding_type_id being matched against the proceeding-types list
|
||||
// by jurisdiction round-trip is not possible without id. Instead
|
||||
// expose the proceeding-types-by-id mapping via a follow-up endpoint
|
||||
// later. For now hit the unfiltered list and assume the project's
|
||||
// pick is in the active set.
|
||||
//
|
||||
// Pragmatic fallback: query the full list and return the only entry
|
||||
// whose pseudo-id-via-sort-order matches. The lookup is unreliable
|
||||
// until the wire shape includes id; for the project-prefill case the
|
||||
// user can always re-pick R3 / R2 if the prefill misfires.
|
||||
try {
|
||||
const resp = await fetch(`/api/tools/proceeding-types`, { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) return null;
|
||||
const list = (await resp.json()) as ProceedingChip[] | null;
|
||||
if (!list || list.length === 0) return null;
|
||||
// Without id in the wire we cannot match by id. Skip the prefill
|
||||
// silently — R3 stays unanswered and the user picks manually.
|
||||
// (S5/follow-up can extend the wire shape to include id.)
|
||||
void id;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProceedingChips(forum: Forum, kind: EventKindRow): Promise<void> {
|
||||
const key = `${forum}\x00${kind}`;
|
||||
if (lastProcCacheKey === key) return;
|
||||
lastProcCacheKey = key;
|
||||
const url = new URL("/api/tools/proceeding-types", window.location.origin);
|
||||
url.searchParams.set("kind", "proceeding");
|
||||
url.searchParams.set("jurisdiction", forum);
|
||||
if (kind !== "missed") url.searchParams.set("event_kind", kind);
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
cachedProcChips = [];
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as ProceedingChip[] | null;
|
||||
cachedProcChips = data || [];
|
||||
} catch {
|
||||
cachedProcChips = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureEventChips(procCode: string, kind: EventKindRow): Promise<void> {
|
||||
const key = `${procCode}\x00${kind}`;
|
||||
if (lastEventCacheKey === key) return;
|
||||
lastEventCacheKey = key;
|
||||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||||
url.searchParams.set("kind", "events");
|
||||
url.searchParams.set("proc", procCode);
|
||||
if (kind !== "missed") url.searchParams.set("event_kind", kind);
|
||||
url.searchParams.set("limit", "100");
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
cachedEventChips = [];
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as EventSearchResponse;
|
||||
cachedEventChips = data.events || [];
|
||||
} catch {
|
||||
cachedEventChips = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers -----------------------------------------------------------
|
||||
|
||||
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
|
||||
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
|
||||
const tt = title ? ` title="${escAttr(title)}"` : "";
|
||||
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
|
||||
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${tt}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
|
||||
}
|
||||
|
||||
function eventKindIcon(kind?: EventKindRow): string {
|
||||
switch (kind) {
|
||||
case "filing": return "📥";
|
||||
case "hearing": return "🏛️";
|
||||
case "decision": return "⚖️";
|
||||
case "order": return "📜";
|
||||
case "missed": return "⏲";
|
||||
default: return "📅";
|
||||
}
|
||||
}
|
||||
|
||||
function syncUrl(): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("mode", "wizard");
|
||||
setOrClear(url, "kind", state.r1);
|
||||
setOrClear(url, "forum", state.r2);
|
||||
setOrClear(url, "pt", state.r3);
|
||||
// event=… is set only on launchResult; the wizard URL carries the
|
||||
// R4 candidate via r4= so back/forward navigates within the wizard.
|
||||
setOrClear(url, "r4", state.r4);
|
||||
setOrClear(url, "party", state.r5);
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
function setOrClear(url: URL, key: string, val: string): void {
|
||||
if (val) url.searchParams.set(key, val);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,8 +20,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Navigation
|
||||
"nav.home": "Home",
|
||||
"nav.kostenrechner": "Kostenrechner",
|
||||
"nav.fristenrechner": "Fristenrechner",
|
||||
"nav.verfahrensablauf": "Verfahrensablauf",
|
||||
"nav.downloads": "Downloads",
|
||||
"nav.links": "Links",
|
||||
"nav.glossar": "Glossar",
|
||||
@@ -200,10 +198,157 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.heading": "Fristenrechner",
|
||||
"deadlines.subtitle": "Berechnung von Verfahrensfristen f\u00fcr UPC-, deutsche und EPA-Verfahren.",
|
||||
|
||||
// Verfahrensablauf (t-paliad-179 Slice 1)
|
||||
"tools.verfahrensablauf.title": "Verfahrensablauf \u2014 Paliad",
|
||||
"tools.verfahrensablauf.heading": "Verfahrensablauf",
|
||||
"tools.verfahrensablauf.subtitle": "Typischen Verfahrensablauf einsehen \u2014 Verfahrensart w\u00e4hlen, Datum optional setzen.",
|
||||
// Unified procedural-events tool (m/paliad#151)
|
||||
"procedures.title": "Verfahren & Fristen \u2014 Paliad",
|
||||
"procedures.heading": "Verfahren & Fristen",
|
||||
"procedures.subtitle": "Verfahrensablauf, Fristenrechner und gef\u00fchrte Suche in einem Tool.",
|
||||
"procedures.filter.search.placeholder": "Klageerhebung, Hinweisbeschluss, oral hearing\u2026",
|
||||
"procedures.filter.axis.forum": "Forum:",
|
||||
"procedures.filter.axis.proc": "Verfahren:",
|
||||
"procedures.filter.axis.kind": "Ereignisart:",
|
||||
"procedures.filter.axis.party": "Partei:",
|
||||
"procedures.tab.proceeding": "Verfahren w\u00e4hlen",
|
||||
"procedures.tab.search": "Direkt suchen",
|
||||
"procedures.tab.wizard": "Gef\u00fchrt",
|
||||
"procedures.tab.akte": "Aus Akte",
|
||||
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
|
||||
"nav.procedures": "Verfahren & Fristen",
|
||||
|
||||
// Litigation Builder (m/paliad#153 B1+B2)
|
||||
"builder.subtitle": "Litigation Builder \u2014 Szenarien bauen, Verfahren stapeln, Fristen behalten.",
|
||||
"builder.header.scenario": "Szenario:",
|
||||
"builder.header.akte": "Akte:",
|
||||
"builder.header.stichtag": "Stichtag:",
|
||||
"builder.header.search": "Suche:",
|
||||
"builder.akte.none": "\u2014 ohne \u2014",
|
||||
"builder.akte.banner.prefix": "Aus Akte:",
|
||||
"builder.search.placeholder": "Ereignis, Szenario, Akte \u2026",
|
||||
"builder.action.rename": "Benennen",
|
||||
"builder.action.rename.prompt": "Name f\u00fcr dieses Szenario:",
|
||||
"builder.action.share": "Teilen",
|
||||
"builder.action.promote": "Als Projekt anlegen",
|
||||
"builder.mode.cold": "\u00dcbersicht",
|
||||
"builder.mode.event": "Ereignis",
|
||||
"builder.mode.akte": "Aus Akte",
|
||||
"builder.panel.title": "Meine Szenarien",
|
||||
"builder.panel.new": "+ Neues Szenario",
|
||||
"builder.panel.empty": "Noch keine Szenarien.",
|
||||
"builder.bucket.active": "Aktiv",
|
||||
"builder.empty.headline": "Noch kein Szenario ge\u00f6ffnet.",
|
||||
"builder.empty.hint": "Starte ein neues Szenario, w\u00e4hle aus deiner Liste oder \u00fcbernimm eine Akte (B4).",
|
||||
"builder.empty.cta": "Neues Szenario starten",
|
||||
"builder.empty.recent": "Zuletzt bearbeitet",
|
||||
"builder.picker.placeholder": "\u2014 Szenario w\u00e4hlen \u2014",
|
||||
"builder.picker.title": "Verfahren hinzuf\u00fcgen",
|
||||
"builder.picker.close": "Schlie\u00dfen",
|
||||
"builder.picker.aria": "Verfahren ausw\u00e4hlen",
|
||||
"builder.picker.axis.forum": "Forum:",
|
||||
"builder.picker.axis.proc": "Verfahren:",
|
||||
"builder.picker.empty": "Keine Verfahren verf\u00fcgbar.",
|
||||
"builder.picker.future_jurisdiction": "Andere Foren folgen sp\u00e4ter.",
|
||||
"builder.canvas.add_proceeding": "+ Verfahren hinzuf\u00fcgen",
|
||||
"builder.triplet.loading": "Berechne Fristen \u2026",
|
||||
"builder.triplet.unknown_proceeding": "Unbekannter Verfahrenstyp.",
|
||||
"builder.triplet.side.claimant": "Kl\u00e4ger-Sicht",
|
||||
"builder.triplet.side.defendant": "Beklagten-Sicht",
|
||||
"builder.triplet.flags.label": "Optionen:",
|
||||
"builder.triplet.perspective.label": "Perspektive:",
|
||||
"builder.triplet.perspective.none": "keine",
|
||||
"builder.triplet.perspective.claimant": "Kl\u00e4ger",
|
||||
"builder.triplet.perspective.defendant": "Beklagter",
|
||||
"builder.triplet.detailgrad.label": "Detailgrad:",
|
||||
"builder.triplet.detailgrad.selected": "Gew\u00e4hlt",
|
||||
"builder.triplet.detailgrad.all_options": "Alle Optionen",
|
||||
"builder.triplet.remove": "Entfernen",
|
||||
"builder.triplet.collapse": "Einklappen",
|
||||
"builder.triplet.expand": "Ausklappen",
|
||||
"builder.triplet.no_flags": "(keine Flags f\u00fcr diesen Verfahrenstyp)",
|
||||
"builder.event.state.planned": "geplant",
|
||||
"builder.event.state.filed": "eingereicht",
|
||||
"builder.event.state.skipped": "ausgelassen",
|
||||
"builder.event.action.file": "Einreichen",
|
||||
"builder.event.action.skip": "Auslassen",
|
||||
"builder.event.action.reset": "Zur\u00fcck zu geplant",
|
||||
"builder.event.actual_date.prompt": "Datum der Einreichung:",
|
||||
"builder.event.skip_reason.prompt": "Grund (optional):",
|
||||
"builder.event.horizon.label": "+{n} Optionen \u25be",
|
||||
"builder.event.horizon.hide": "Optionen ausblenden",
|
||||
"builder.save.idle": "\u00a0",
|
||||
"builder.save.saving": "Speichert \u2026",
|
||||
"builder.save.saved": "Gespeichert \u2713",
|
||||
"builder.save.error": "Speichern fehlgeschlagen",
|
||||
"builder.search.hint.start": "Tippe \u2026 z.\u202fB. \u201eKlageerwiderung\u201c, \u201eHinweis\u201c, \u201eHL-2024\u201c",
|
||||
"builder.search.hint.short": "Mindestens 2 Zeichen.",
|
||||
"builder.search.hint.loading": "Suche \u2026",
|
||||
"builder.search.hint.empty": "Keine Treffer.",
|
||||
"builder.search.hint.error": "Suche fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.search.hint.akte_b4": "Akten-Modus folgt in B4.",
|
||||
"builder.search.group.events": "Ereignisse",
|
||||
"builder.search.group.scenarios": "Szenarien",
|
||||
"builder.search.group.projects": "Akten",
|
||||
"builder.search.summary.events.one": "{n} Ereignis",
|
||||
"builder.search.summary.events.other": "{n} Ereignisse",
|
||||
"builder.search.summary.scenarios.one": "{n} Szenario",
|
||||
"builder.search.summary.scenarios.other": "{n} Szenarien",
|
||||
"builder.search.summary.projects.one": "{n} Akte",
|
||||
"builder.search.summary.projects.other": "{n} Akten",
|
||||
"builder.search.anchor.divider": "\u2501\u2501\u2501\u2501 DU BIST HIER \u2501\u2501\u2501\u2501",
|
||||
|
||||
// B5 \u2014 side-panel buckets, sharing, promote-to-project wizard.
|
||||
"builder.bucket.shared": "Geteilt mit mir",
|
||||
"builder.bucket.promoted": "Als Projekt angelegt",
|
||||
"builder.bucket.archived": "Archiviert",
|
||||
"builder.bucket.empty": "\u2014",
|
||||
"builder.readonly.watermark": "Geteilt von {owner} \u00b7 schreibgesch\u00fctzt",
|
||||
"builder.readonly.blocked": "Schreibgesch\u00fctzt \u2014 Bearbeiten ist nur f\u00fcr die Eigent\u00fcmer:in m\u00f6glich.",
|
||||
"builder.share.title": "Szenario teilen",
|
||||
"builder.share.subtitle": "Schreibgesch\u00fctzt mit HLC-Kolleg:innen teilen. Du bleibst alleinige Bearbeiter:in.",
|
||||
"builder.share.search.placeholder": "Name oder E-Mail suchen \u2026",
|
||||
"builder.share.button": "Schreibgesch\u00fctzt teilen",
|
||||
"builder.share.current.title": "Bereits geteilt mit:",
|
||||
"builder.share.current.empty": "Noch mit niemandem geteilt.",
|
||||
"builder.share.revoke": "Entfernen",
|
||||
"builder.share.close": "Schlie\u00dfen",
|
||||
"builder.share.no_results": "Keine Nutzer:innen gefunden.",
|
||||
"builder.share.error": "Teilen fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.promote.title": "Als Projekt anlegen",
|
||||
"builder.promote.step1": "Best\u00e4tigen",
|
||||
"builder.promote.step2": "Parteien erg\u00e4nzen",
|
||||
"builder.promote.step3": "Akte-Metadaten",
|
||||
"builder.promote.next": "Weiter",
|
||||
"builder.promote.back": "Zur\u00fcck",
|
||||
"builder.promote.commit": "Anlegen",
|
||||
"builder.promote.cancel": "Abbrechen",
|
||||
"builder.promote.summary.heading": "Das wird angelegt:",
|
||||
"builder.promote.summary.proceeding": "Hauptverfahren",
|
||||
"builder.promote.summary.events_filed": "eingereichte Ereignisse",
|
||||
"builder.promote.summary.events_planned": "geplante Ereignisse",
|
||||
"builder.promote.summary.flags": "aktive Optionen",
|
||||
"builder.promote.summary.note_extra": "{n} weitere(s) eigenst\u00e4ndige(s) Verfahren bleibt im Szenario und wird nicht automatisch \u00fcbernommen.",
|
||||
"builder.promote.parties.hint": "Trage die echten Parteinamen ein \u2014 oder erg\u00e4nze sie sp\u00e4ter in der Akte.",
|
||||
"builder.promote.parties.add": "+ Partei hinzuf\u00fcgen",
|
||||
"builder.promote.parties.name": "Name",
|
||||
"builder.promote.parties.role": "Rolle (z. B. Kl\u00e4ger)",
|
||||
"builder.promote.parties.representative": "Vertreter:in",
|
||||
"builder.promote.parties.remove": "Entfernen",
|
||||
"builder.promote.parties.empty": "Noch keine Parteien.",
|
||||
"builder.promote.meta.title": "Aktentitel / Mandat",
|
||||
"builder.promote.meta.title.placeholder": "z. B. Becker ./. X \u2014 UPC Verletzung",
|
||||
"builder.promote.meta.reference": "Referenz (optional)",
|
||||
"builder.promote.meta.case_number": "Aktenzeichen (optional)",
|
||||
"builder.promote.meta.client_number": "Mandantennummer (optional)",
|
||||
"builder.promote.meta.our_side": "Unsere Seite",
|
||||
"builder.promote.meta.our_side.claimant": "Kl\u00e4ger",
|
||||
"builder.promote.meta.our_side.defendant": "Beklagter",
|
||||
"builder.promote.meta.our_side.none": "\u2014 offen \u2014",
|
||||
"builder.promote.meta.parent": "\u00dcbergeordnetes Verfahren (optional)",
|
||||
"builder.promote.meta.parent.none": "\u2014 keines \u2014",
|
||||
"builder.promote.meta.team": "Team (optional)",
|
||||
"builder.promote.meta.team.hint": "Du wirst automatisch als Lead hinzugef\u00fcgt.",
|
||||
"builder.promote.error.title_required": "Bitte einen Aktentitel eingeben.",
|
||||
"builder.promote.error.generic": "Anlegen fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.promote.success": "Akte angelegt \u2014 Weiterleitung \u2026",
|
||||
"builder.mobile.blocked": "Auf gr\u00f6\u00dferem Bildschirm \u00f6ffnen, um zu bearbeiten.",
|
||||
|
||||
"deadlines.step1": "Verfahrensart w\u00e4hlen",
|
||||
"deadlines.step2": "Ausgangsdatum eingeben",
|
||||
@@ -254,10 +399,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.epa.opp.opd": "Einspruchsverfahren",
|
||||
"deadlines.epa.opp.boa": "Beschwerdeverfahren",
|
||||
"deadlines.epa.grant.exa": "EP-Erteilungsverfahren",
|
||||
"deadlines.party.claimant": "Kl\u00e4ger",
|
||||
"deadlines.party.defendant": "Beklagter",
|
||||
"deadlines.party.court": "Gericht",
|
||||
"deadlines.party.both": "Beide",
|
||||
"deadlines.party.both.label": "beide Seiten",
|
||||
"deadlines.court.set": "vom Gericht bestimmt",
|
||||
"deadlines.court.indirect": "unbestimmt",
|
||||
@@ -983,6 +1124,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"cal.view.month": "Monat",
|
||||
"cal.view.week": "Woche",
|
||||
"cal.view.day": "Tag",
|
||||
"cal.today": "Heute",
|
||||
"cal.month.prev": "Vorheriger Monat",
|
||||
"cal.month.next": "Nächster Monat",
|
||||
"cal.week.prev": "Vorherige Woche",
|
||||
@@ -1023,7 +1165,16 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.overhaul.group.conditional": "Bedingt",
|
||||
"deadlines.overhaul.spawn.badge": "\u21f2 neues Verfahren",
|
||||
"deadlines.overhaul.spawn.tooltip": "Diese Regel leitet ein neues Verfahren ein.",
|
||||
"deadlines.detail.label": "Anzeige:",
|
||||
"deadlines.detail.mandatory_only": "Nur Pflicht",
|
||||
"deadlines.detail.selected": "Gewählt",
|
||||
"deadlines.detail.all_options": "Alle Optionen",
|
||||
"deadlines.detail.optional_unselected_hint": "Diese Regel ist optional und gehört nicht zum aktuellen Szenario.",
|
||||
"deadlines.detail.aufnehmen": "Aufnehmen",
|
||||
"deadlines.detail.entfernen": "Entfernen",
|
||||
"deadlines.overhaul.condition.badge": "Nur unter Bedingung",
|
||||
"deadlines.overhaul.crossparty.badge": "Gegenseitig",
|
||||
"deadlines.overhaul.crossparty.tooltip": "Diese Frist wird von der Gegenseite eingereicht. Sie erscheint nur zur Information und wird nicht in die Akte übernommen.",
|
||||
"deadlines.overhaul.notes.summary": "Hinweis",
|
||||
"deadlines.overhaul.edit_date.label": "\u270f Datum",
|
||||
"deadlines.overhaul.edit_date.title": "Datum manuell anpassen",
|
||||
@@ -1594,11 +1745,28 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.language.de": "DE",
|
||||
"submissions.draft.language.en": "EN",
|
||||
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
|
||||
// t-paliad-354 — Dateiname-Stichwort (führt den Namen des exportierten Dokuments an).
|
||||
"submissions.draft.keyword.label": "Stichwort (Dateiname)",
|
||||
"submissions.draft.keyword.placeholder": "Automatisch aus dem Schriftsatztyp",
|
||||
"submissions.draft.keyword.hint": "Führt den Dateinamen an: <Datum> <Stichwort> (<Aktenzeichen>).",
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
|
||||
"submissions.draft.base.label": "Vorlagenbasis",
|
||||
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
|
||||
"submissions.draft.sections.title": "Abschnitte",
|
||||
"submissions.draft.sections.hint": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.",
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
|
||||
"templates.authoring.title": "Vorlagen — Paliad",
|
||||
"templates.authoring.heading": "Vorlagen",
|
||||
"templates.authoring.intro": "Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.",
|
||||
"templates.authoring.upload.title": "Neue Vorlage hochladen",
|
||||
"templates.authoring.upload.file": "Word-Datei (.docx)",
|
||||
"templates.authoring.upload.name_de": "Name (DE)",
|
||||
"templates.authoring.upload.name_en": "Name (EN)",
|
||||
"templates.authoring.upload.firm": "Kanzlei (optional)",
|
||||
"templates.authoring.upload.submit": "Hochladen",
|
||||
"templates.authoring.list.title": "Vorhandene Vorlagen",
|
||||
"templates.authoring.workspace.hint": "Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.",
|
||||
"templates.authoring.slots.title": "Platzhalter",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Bausteine — Paliad",
|
||||
"admin.building_blocks.heading": "Bausteine",
|
||||
@@ -3208,8 +3376,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Navigation
|
||||
"nav.home": "Home",
|
||||
"nav.kostenrechner": "Cost Calculator",
|
||||
"nav.fristenrechner": "Deadline Calculator",
|
||||
"nav.verfahrensablauf": "Procedure Roadmap",
|
||||
"nav.downloads": "Downloads",
|
||||
"nav.links": "Links",
|
||||
"nav.glossar": "Glossary",
|
||||
@@ -3386,9 +3552,157 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.subtitle": "Calculate procedural deadlines for UPC, German, and EPA proceedings.",
|
||||
|
||||
// Verfahrensablauf (t-paliad-179 Slice 1)
|
||||
"tools.verfahrensablauf.title": "Procedure Roadmap \u2014 Paliad",
|
||||
"tools.verfahrensablauf.heading": "Procedure Roadmap",
|
||||
"tools.verfahrensablauf.subtitle": "Browse the typical proceeding shape \u2014 pick a proceeding type, optionally set a trigger date.",
|
||||
// Unified procedural-events tool (m/paliad#151)
|
||||
"procedures.title": "Procedures & Deadlines \u2014 Paliad",
|
||||
"procedures.heading": "Procedures & Deadlines",
|
||||
"procedures.subtitle": "Procedure roadmap, deadline calculator, and guided search in one tool.",
|
||||
"procedures.filter.search.placeholder": "Statement of claim, hearing notice, m\u00fcndliche Verhandlung\u2026",
|
||||
"procedures.filter.axis.forum": "Forum:",
|
||||
"procedures.filter.axis.proc": "Proceeding:",
|
||||
"procedures.filter.axis.kind": "Event kind:",
|
||||
"procedures.filter.axis.party": "Party:",
|
||||
"procedures.tab.proceeding": "Pick proceeding",
|
||||
"procedures.tab.search": "Direct search",
|
||||
"procedures.tab.wizard": "Guided",
|
||||
"procedures.tab.akte": "From matter",
|
||||
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
|
||||
"nav.procedures": "Procedures & Deadlines",
|
||||
|
||||
// Litigation Builder (m/paliad#153 B1+B2)
|
||||
"builder.subtitle": "Litigation Builder — build scenarios, stack proceedings, track deadlines.",
|
||||
"builder.header.scenario": "Scenario:",
|
||||
"builder.header.akte": "Matter:",
|
||||
"builder.header.stichtag": "Anchor:",
|
||||
"builder.header.search": "Search:",
|
||||
"builder.akte.none": "— none —",
|
||||
"builder.akte.banner.prefix": "From matter:",
|
||||
"builder.search.placeholder": "Event, scenario, matter …",
|
||||
"builder.action.rename": "Name it",
|
||||
"builder.action.rename.prompt": "Name for this scenario:",
|
||||
"builder.action.share": "Share",
|
||||
"builder.action.promote": "Create as project",
|
||||
"builder.mode.cold": "Overview",
|
||||
"builder.mode.event": "Event",
|
||||
"builder.mode.akte": "From matter",
|
||||
"builder.panel.title": "My scenarios",
|
||||
"builder.panel.new": "+ New scenario",
|
||||
"builder.panel.empty": "No scenarios yet.",
|
||||
"builder.bucket.active": "Active",
|
||||
"builder.empty.headline": "No scenario open.",
|
||||
"builder.empty.hint": "Start a new scenario, pick one from your list, or load a matter (B4).",
|
||||
"builder.empty.cta": "Start a new scenario",
|
||||
"builder.empty.recent": "Recent",
|
||||
"builder.picker.placeholder": "— pick a scenario —",
|
||||
"builder.picker.title": "Add proceeding",
|
||||
"builder.picker.close": "Close",
|
||||
"builder.picker.aria": "Pick a proceeding",
|
||||
"builder.picker.axis.forum": "Forum:",
|
||||
"builder.picker.axis.proc": "Proceeding:",
|
||||
"builder.picker.empty": "No proceedings available.",
|
||||
"builder.picker.future_jurisdiction": "Other forums coming later.",
|
||||
"builder.canvas.add_proceeding": "+ Add proceeding",
|
||||
"builder.triplet.loading": "Calculating deadlines …",
|
||||
"builder.triplet.unknown_proceeding": "Unknown proceeding type.",
|
||||
"builder.triplet.side.claimant": "Claimant view",
|
||||
"builder.triplet.side.defendant": "Defendant view",
|
||||
"builder.triplet.flags.label": "Options:",
|
||||
"builder.triplet.perspective.label": "Perspective:",
|
||||
"builder.triplet.perspective.none": "none",
|
||||
"builder.triplet.perspective.claimant": "Claimant",
|
||||
"builder.triplet.perspective.defendant": "Defendant",
|
||||
"builder.triplet.detailgrad.label": "Detail:",
|
||||
"builder.triplet.detailgrad.selected": "Selected",
|
||||
"builder.triplet.detailgrad.all_options": "All options",
|
||||
"builder.triplet.remove": "Remove",
|
||||
"builder.triplet.collapse": "Collapse",
|
||||
"builder.triplet.expand": "Expand",
|
||||
"builder.triplet.no_flags": "(no flags for this proceeding type)",
|
||||
"builder.event.state.planned": "planned",
|
||||
"builder.event.state.filed": "filed",
|
||||
"builder.event.state.skipped": "skipped",
|
||||
"builder.event.action.file": "File",
|
||||
"builder.event.action.skip": "Skip",
|
||||
"builder.event.action.reset": "Reset to planned",
|
||||
"builder.event.actual_date.prompt": "Date of filing:",
|
||||
"builder.event.skip_reason.prompt": "Reason (optional):",
|
||||
"builder.event.horizon.label": "+{n} optional ▾",
|
||||
"builder.event.horizon.hide": "Hide optional",
|
||||
"builder.save.idle": " ",
|
||||
"builder.save.saving": "Saving …",
|
||||
"builder.save.saved": "Saved ✓",
|
||||
"builder.save.error": "Save failed",
|
||||
"builder.search.hint.start": "Type … e.g. \"defence\", \"hearing\", \"HL-2024\"",
|
||||
"builder.search.hint.short": "At least 2 characters.",
|
||||
"builder.search.hint.loading": "Searching …",
|
||||
"builder.search.hint.empty": "No matches.",
|
||||
"builder.search.hint.error": "Search failed. Try again.",
|
||||
"builder.search.hint.akte_b4": "Matter mode coming in B4.",
|
||||
"builder.search.group.events": "Events",
|
||||
"builder.search.group.scenarios": "Scenarios",
|
||||
"builder.search.group.projects": "Matters",
|
||||
"builder.search.summary.events.one": "{n} event",
|
||||
"builder.search.summary.events.other": "{n} events",
|
||||
"builder.search.summary.scenarios.one": "{n} scenario",
|
||||
"builder.search.summary.scenarios.other": "{n} scenarios",
|
||||
"builder.search.summary.projects.one": "{n} matter",
|
||||
"builder.search.summary.projects.other": "{n} matters",
|
||||
"builder.search.anchor.divider": "━━━━ YOU ARE HERE ━━━━",
|
||||
|
||||
// B5 — side-panel buckets, sharing, promote-to-project wizard.
|
||||
"builder.bucket.shared": "Shared with me",
|
||||
"builder.bucket.promoted": "Promoted to project",
|
||||
"builder.bucket.archived": "Archived",
|
||||
"builder.bucket.empty": "—",
|
||||
"builder.readonly.watermark": "Shared by {owner} · read-only",
|
||||
"builder.readonly.blocked": "Read-only — only the owner can edit.",
|
||||
"builder.share.title": "Share scenario",
|
||||
"builder.share.subtitle": "Share read-only with HLC colleagues. You remain the sole editor.",
|
||||
"builder.share.search.placeholder": "Search name or email …",
|
||||
"builder.share.button": "Share read-only",
|
||||
"builder.share.current.title": "Already shared with:",
|
||||
"builder.share.current.empty": "Not shared with anyone yet.",
|
||||
"builder.share.revoke": "Remove",
|
||||
"builder.share.close": "Close",
|
||||
"builder.share.no_results": "No users found.",
|
||||
"builder.share.error": "Sharing failed. Please try again.",
|
||||
"builder.promote.title": "Create as project",
|
||||
"builder.promote.step1": "Confirm",
|
||||
"builder.promote.step2": "Add parties",
|
||||
"builder.promote.step3": "Case metadata",
|
||||
"builder.promote.next": "Next",
|
||||
"builder.promote.back": "Back",
|
||||
"builder.promote.commit": "Create",
|
||||
"builder.promote.cancel": "Cancel",
|
||||
"builder.promote.summary.heading": "What will be created:",
|
||||
"builder.promote.summary.proceeding": "Primary proceeding",
|
||||
"builder.promote.summary.events_filed": "filed events",
|
||||
"builder.promote.summary.events_planned": "planned events",
|
||||
"builder.promote.summary.flags": "active options",
|
||||
"builder.promote.summary.note_extra": "{n} further standalone proceeding(s) stay in the scenario and are not carried over automatically.",
|
||||
"builder.promote.parties.hint": "Enter the real party names — or add them later in the case file.",
|
||||
"builder.promote.parties.add": "+ Add party",
|
||||
"builder.promote.parties.name": "Name",
|
||||
"builder.promote.parties.role": "Role (e.g. claimant)",
|
||||
"builder.promote.parties.representative": "Representative",
|
||||
"builder.promote.parties.remove": "Remove",
|
||||
"builder.promote.parties.empty": "No parties yet.",
|
||||
"builder.promote.meta.title": "Case title / matter",
|
||||
"builder.promote.meta.title.placeholder": "e.g. Becker v. X — UPC infringement",
|
||||
"builder.promote.meta.reference": "Reference (optional)",
|
||||
"builder.promote.meta.case_number": "Case number (optional)",
|
||||
"builder.promote.meta.client_number": "Client number (optional)",
|
||||
"builder.promote.meta.our_side": "Our side",
|
||||
"builder.promote.meta.our_side.claimant": "Claimant",
|
||||
"builder.promote.meta.our_side.defendant": "Defendant",
|
||||
"builder.promote.meta.our_side.none": "— open —",
|
||||
"builder.promote.meta.parent": "Parent litigation (optional)",
|
||||
"builder.promote.meta.parent.none": "— none —",
|
||||
"builder.promote.meta.team": "Team (optional)",
|
||||
"builder.promote.meta.team.hint": "You are added as lead automatically.",
|
||||
"builder.promote.error.title_required": "Please enter a case title.",
|
||||
"builder.promote.error.generic": "Creation failed. Please try again.",
|
||||
"builder.promote.success": "Case created — redirecting …",
|
||||
"builder.mobile.blocked": "Open on a larger screen to edit.",
|
||||
|
||||
"deadlines.step1": "Select Proceeding Type",
|
||||
"deadlines.step2": "Enter Trigger Date",
|
||||
@@ -3439,10 +3753,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.epa.opp.opd": "Opposition",
|
||||
"deadlines.epa.opp.boa": "Appeal",
|
||||
"deadlines.epa.grant.exa": "Grant Procedure",
|
||||
"deadlines.party.claimant": "Claimant",
|
||||
"deadlines.party.defendant": "Defendant",
|
||||
"deadlines.party.court": "Court",
|
||||
"deadlines.party.both": "Both",
|
||||
"deadlines.party.both.label": "both parties",
|
||||
"deadlines.court.set": "set by court",
|
||||
"deadlines.court.indirect": "tbd",
|
||||
@@ -4209,7 +4519,16 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.overhaul.group.conditional": "Conditional",
|
||||
"deadlines.overhaul.spawn.badge": "⇲ new proceeding",
|
||||
"deadlines.overhaul.spawn.tooltip": "This rule initiates a new proceeding.",
|
||||
"deadlines.detail.label": "Detail:",
|
||||
"deadlines.detail.mandatory_only": "Mandatory only",
|
||||
"deadlines.detail.selected": "Selected",
|
||||
"deadlines.detail.all_options": "All options",
|
||||
"deadlines.detail.optional_unselected_hint": "This rule is optional and not part of the current scenario.",
|
||||
"deadlines.detail.aufnehmen": "Add",
|
||||
"deadlines.detail.entfernen": "Remove",
|
||||
"deadlines.overhaul.condition.badge": "Conditional",
|
||||
"deadlines.overhaul.crossparty.badge": "Other side",
|
||||
"deadlines.overhaul.crossparty.tooltip": "This deadline is filed by the opposing party. Shown for information only — not added to the Akte.",
|
||||
"deadlines.overhaul.notes.summary": "Note",
|
||||
"deadlines.overhaul.edit_date.label": "✏ Date",
|
||||
"deadlines.overhaul.edit_date.title": "Edit date manually",
|
||||
@@ -4755,6 +5074,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.language.de": "DE",
|
||||
"submissions.draft.language.en": "EN",
|
||||
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
|
||||
// t-paliad-354 — filename keyword (leads the exported document name).
|
||||
"submissions.draft.keyword.label": "Keyword (filename)",
|
||||
"submissions.draft.keyword.placeholder": "Auto-derived from the submission type",
|
||||
"submissions.draft.keyword.hint": "Leads the filename: <date> <keyword> (<case number>).",
|
||||
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
|
||||
// t-paliad-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Import from project",
|
||||
@@ -4765,6 +5088,19 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.",
|
||||
"submissions.draft.sections.title": "Sections",
|
||||
"submissions.draft.sections.hint": "Edit per section — autosaves after 500ms. Final layout in Word.",
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
|
||||
"templates.authoring.title": "Templates — Paliad",
|
||||
"templates.authoring.heading": "Templates",
|
||||
"templates.authoring.intro": "Upload a Word template, highlight spots and insert variables.",
|
||||
"templates.authoring.upload.title": "Upload a new template",
|
||||
"templates.authoring.upload.file": "Word file (.docx)",
|
||||
"templates.authoring.upload.name_de": "Name (DE)",
|
||||
"templates.authoring.upload.name_en": "Name (EN)",
|
||||
"templates.authoring.upload.firm": "Firm (optional)",
|
||||
"templates.authoring.upload.submit": "Upload",
|
||||
"templates.authoring.list.title": "Existing templates",
|
||||
"templates.authoring.workspace.hint": "Highlight text, then pick a variable to place a placeholder.",
|
||||
"templates.authoring.slots.title": "Placeholders",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Building blocks — Paliad",
|
||||
"admin.building_blocks.heading": "Building blocks",
|
||||
|
||||
@@ -109,7 +109,7 @@ export function routeNameFor(pathname: string): string {
|
||||
if (pathname === "/links") return "links";
|
||||
if (pathname === "/downloads") return "downloads";
|
||||
if (pathname === "/checklists") return "checklists";
|
||||
if (pathname.startsWith("/tools/fristenrechner")) return "tools.fristenrechner";
|
||||
if (pathname.startsWith("/tools/procedures")) return "tools.procedures";
|
||||
if (pathname.startsWith("/tools/kostenrechner")) return "tools.kostenrechner";
|
||||
if (pathname.startsWith("/tools/gebuehrentabellen")) return "tools.gebuehrentabellen";
|
||||
if (pathname === "/events") return "events";
|
||||
|
||||
15
frontend/src/client/procedures.ts
Normal file
15
frontend/src/client/procedures.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1).
|
||||
//
|
||||
// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is
|
||||
// emitted by procedures.tsx; this file boots the i18n + sidebar
|
||||
// runtime and hands off to builder.ts.
|
||||
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mountBuilder } from "./builder";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
void mountBuilder();
|
||||
});
|
||||
125
frontend/src/client/scenario-flags.ts
Normal file
125
frontend/src/client/scenario-flags.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// Per-project scenario_flags client — the single source of truth
|
||||
// (m/paliad#149 Phase 2 P0, mig 154). Wraps GET/PATCH
|
||||
// /api/projects/{id}/scenario-flags so any project-bound surface can
|
||||
// read + write the same flag map.
|
||||
//
|
||||
// Shape on the wire:
|
||||
//
|
||||
// GET → { flags: { "with_ccr": true, "rule:<uuid>": false }, catalog: [...] }
|
||||
// PATCH body: { "with_ccr": true, "with_amend": null }
|
||||
// - bool → write the value verbatim
|
||||
// - null → delete the key (priority-driven default returns)
|
||||
// - undefined → caller never sends this key; the value is left alone
|
||||
//
|
||||
// Cross-surface coherence: every successful PATCH dispatches a
|
||||
// `scenario-flag-changed` CustomEvent on document so other surfaces
|
||||
// (Verfahrensablauf strip, Mode B result-view conditional group) can
|
||||
// re-render without a fresh fetch. Detail carries the merged map so
|
||||
// listeners can use it directly.
|
||||
|
||||
export interface ScenarioFlagCatalogEntry {
|
||||
flag_key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description?: string;
|
||||
hidden_unless_set: boolean;
|
||||
}
|
||||
|
||||
export interface ScenarioFlagsView {
|
||||
flags: Record<string, boolean>;
|
||||
catalog: ScenarioFlagCatalogEntry[];
|
||||
}
|
||||
|
||||
// PatchDelta represents a partial update. Keys present with `null`
|
||||
// delete the entry; keys present with a bool overwrite; keys not
|
||||
// present are left untouched.
|
||||
export type ScenarioFlagsDelta = Record<string, boolean | null>;
|
||||
|
||||
export interface ScenarioFlagChangedDetail {
|
||||
projectId: string;
|
||||
flags: Record<string, boolean>;
|
||||
// The keys that were touched by the PATCH that fired this event.
|
||||
// Useful for surfaces that re-render only when *their* flag moved.
|
||||
changedKeys: string[];
|
||||
}
|
||||
|
||||
export const SCENARIO_FLAG_CHANGED_EVENT = "scenario-flag-changed";
|
||||
|
||||
// fetchScenarioFlags loads the current state and catalog for a project.
|
||||
// Returns null if the project is invisible to the caller (404 path) or
|
||||
// the server rejected the request — callers should fall back to local
|
||||
// defaults in that case rather than surfacing a hard error to the UI.
|
||||
export async function fetchScenarioFlags(projectId: string): Promise<ScenarioFlagsView | null> {
|
||||
if (!projectId) return null;
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/scenario-flags`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 401 || resp.status === 403 || resp.status === 404) {
|
||||
return null;
|
||||
}
|
||||
console.warn(`scenario-flags GET ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
return (await resp.json()) as ScenarioFlagsView;
|
||||
} catch (e) {
|
||||
console.error("scenario-flags GET failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// patchScenarioFlags writes a delta. Returns the merged map on success;
|
||||
// returns null on failure (caller decides whether to roll back UI).
|
||||
// Dispatches `scenario-flag-changed` on success so peer surfaces can
|
||||
// re-sync.
|
||||
export async function patchScenarioFlags(
|
||||
projectId: string,
|
||||
delta: ScenarioFlagsDelta,
|
||||
): Promise<ScenarioFlagsView | null> {
|
||||
if (!projectId) return null;
|
||||
if (Object.keys(delta).length === 0) return null;
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/scenario-flags`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(delta),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(`scenario-flags PATCH ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const view = (await resp.json()) as ScenarioFlagsView;
|
||||
dispatchScenarioFlagChanged(projectId, view.flags, Object.keys(delta));
|
||||
return view;
|
||||
} catch (e) {
|
||||
console.error("scenario-flags PATCH failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchScenarioFlagChanged(
|
||||
projectId: string,
|
||||
flags: Record<string, boolean>,
|
||||
changedKeys: string[],
|
||||
): void {
|
||||
const detail: ScenarioFlagChangedDetail = { projectId, flags, changedKeys };
|
||||
document.dispatchEvent(new CustomEvent(SCENARIO_FLAG_CHANGED_EVENT, { detail }));
|
||||
}
|
||||
|
||||
// onScenarioFlagsChanged subscribes a listener and returns an
|
||||
// unsubscribe function. Convenient for surfaces wired by lifecycle
|
||||
// hooks (init / teardown).
|
||||
export function onScenarioFlagsChanged(
|
||||
listener: (detail: ScenarioFlagChangedDetail) => void,
|
||||
): () => void {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<ScenarioFlagChangedDetail>).detail;
|
||||
if (detail) listener(detail);
|
||||
};
|
||||
document.addEventListener(SCENARIO_FLAG_CHANGED_EVENT, handler);
|
||||
return () => document.removeEventListener(SCENARIO_FLAG_CHANGED_EVENT, handler);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { escapeHtml, cssEscape } from "../lib/docforge-editor/dom";
|
||||
import { fetchVariableCatalogue, labelMap } from "../lib/docforge-editor/catalogue";
|
||||
|
||||
// t-paliad-238 Slice A — client bundle for the dedicated
|
||||
// Submissions/Schriftsätze editor at
|
||||
@@ -33,6 +35,9 @@ interface SubmissionDraftJSON {
|
||||
// path stays the fallback). composer_meta carries the seed-time
|
||||
// section order in later slices.
|
||||
base_id?: string | null;
|
||||
// t-paliad-349 slice 7 — pinned uploaded docforge template version.
|
||||
// Mutually exclusive with base_id in practice (export checks this first).
|
||||
template_version_id?: string | null;
|
||||
composer_meta?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -69,6 +74,17 @@ interface SubmissionBaseRow {
|
||||
section_count: number;
|
||||
}
|
||||
|
||||
// t-paliad-349 slice 7 — an uploaded docforge template offered in the
|
||||
// picker for generation. version_id is what a draft pins.
|
||||
interface PickerTemplate {
|
||||
id: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
firm?: string | null;
|
||||
version: number;
|
||||
version_id?: string;
|
||||
}
|
||||
|
||||
interface AvailablePartyJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -153,19 +169,16 @@ function isEN(): boolean {
|
||||
return document.documentElement.lang === "en";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
// escapeHtml + cssEscape now come from ../lib/docforge-editor/dom (the
|
||||
// shared editor utilities); the local copies were removed in slice 5.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Variable contract — DE/EN labels per dotted-path placeholder.
|
||||
// Mirrors the same shape the email-template variables sidebar uses;
|
||||
// keeps the lawyer's mental model anchored on the same vocabulary.
|
||||
// Labels come from the Go-side catalogue (GET /api/docforge/variables),
|
||||
// fetched once on boot into state.varLabels. The frontend keeps only the
|
||||
// presentation grouping (VARIABLE_GROUPS) — which keys to show and how to
|
||||
// section them — not the label data itself, so labels can't drift from the
|
||||
// resolvers that produce the values (t-paliad-349 slice 5).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VariableLabel {
|
||||
@@ -186,71 +199,6 @@ interface VariableGroup {
|
||||
collapsedByDefault?: boolean;
|
||||
}
|
||||
|
||||
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
"firm.name": { de: "Kanzlei", en: "Firm" },
|
||||
"firm.signature_block": { de: "Signatur-Block", en: "Signature block" },
|
||||
"today": { de: "Heute", en: "Today" },
|
||||
"today.iso": { de: "Heute (ISO)", en: "Today (ISO)" },
|
||||
"today.long_de": { de: "Heute (DE lang)", en: "Today (DE long)" },
|
||||
"today.long_en": { de: "Heute (EN lang)", en: "Today (EN long)" },
|
||||
"user.display_name": { de: "Bearbeiter", en: "Author" },
|
||||
"user.email": { de: "E-Mail", en: "Email" },
|
||||
"user.office": { de: "Büro", en: "Office" },
|
||||
"project.title": { de: "Projekttitel", en: "Project title" },
|
||||
"project.reference": { de: "Aktenzeichen (intern)", en: "Internal reference" },
|
||||
"project.case_number": { de: "Aktenzeichen (Gericht)", en: "Court case number" },
|
||||
"project.court": { de: "Gericht", en: "Court" },
|
||||
"project.patent_number": { de: "Patentnummer", en: "Patent number" },
|
||||
"project.patent_number_upc": { de: "Patentnummer (UPC-Format)", en: "Patent number (UPC format)" },
|
||||
"project.filing_date": { de: "Anmeldedatum", en: "Filing date" },
|
||||
"project.grant_date": { de: "Erteilungsdatum", en: "Grant date" },
|
||||
"project.our_side": { de: "Unsere Seite", en: "Our side" },
|
||||
"project.our_side_de": { de: "Unsere Seite (DE)", en: "Our side (DE)" },
|
||||
"project.our_side_en": { de: "Unsere Seite (EN)", en: "Our side (EN)" },
|
||||
"project.instance_level": { de: "Instanz", en: "Instance" },
|
||||
"project.client_number": { de: "Mandantennummer", en: "Client number" },
|
||||
"project.matter_number": { de: "Matter-Nummer", en: "Matter number" },
|
||||
"project.proceeding.code": { de: "Verfahrenstyp (Code)", en: "Proceeding type (code)" },
|
||||
"project.proceeding.name": { de: "Verfahrenstyp", en: "Proceeding type" },
|
||||
"project.proceeding.name_de": { de: "Verfahrenstyp (DE)", en: "Proceeding type (DE)" },
|
||||
"project.proceeding.name_en": { de: "Verfahrenstyp (EN)", en: "Proceeding type (EN)" },
|
||||
"parties.claimant.name": { de: "Klägerin", en: "Claimant" },
|
||||
"parties.claimant.representative": { de: "Klägerin-Vertreter", en: "Claimant representative" },
|
||||
"parties.defendant.name": { de: "Beklagte", en: "Defendant" },
|
||||
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
|
||||
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
|
||||
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
|
||||
// Procedural-event namespace (t-paliad-262 Slice A, design doc
|
||||
// docs/design-procedural-events-model-2026-05-25.md). The canonical
|
||||
// placeholder names are below; the `rule.*` aliases that follow are
|
||||
// @deprecated but kept forever per m's Q7 lock — existing Word
|
||||
// templates and saved drafts authored with the old names keep
|
||||
// merging identically.
|
||||
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
|
||||
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
|
||||
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
|
||||
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
|
||||
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||||
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
|
||||
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||||
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
|
||||
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
|
||||
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
|
||||
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
|
||||
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
|
||||
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
|
||||
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
|
||||
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
|
||||
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
|
||||
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
|
||||
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
|
||||
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
|
||||
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
|
||||
"deadline.original_due_date": { de: "Ursprüngliche Frist", en: "Original deadline" },
|
||||
"deadline.computed_from": { de: "Frist berechnet aus", en: "Deadline computed from" },
|
||||
"deadline.title": { de: "Frist-Titel", en: "Deadline title" },
|
||||
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
||||
};
|
||||
|
||||
// t-paliad-287 — variable groups restructured into four lawyer-facing
|
||||
// sections: Mandant/Verfahren up top (the case identity), then Parteien
|
||||
@@ -341,7 +289,7 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
];
|
||||
|
||||
function labelFor(key: string): string {
|
||||
const entry = VARIABLE_LABELS[key];
|
||||
const entry = state.varLabels[key];
|
||||
if (!entry) return key;
|
||||
return isEN() ? entry.en : entry.de;
|
||||
}
|
||||
@@ -373,6 +321,15 @@ interface State {
|
||||
// completes) keeps the picker hidden permanently for this load.
|
||||
bases: SubmissionBaseRow[];
|
||||
basesLoaded: boolean;
|
||||
// t-paliad-349 slice 7 — uploaded templates offered in the picker.
|
||||
templates: PickerTemplate[];
|
||||
templatesLoaded: boolean;
|
||||
// t-paliad-349 slice 5 — variable labels fetched once on boot from the
|
||||
// Go catalogue (GET /api/docforge/variables), the single source of
|
||||
// truth. Empty until the fetch lands; labelFor falls back to the raw
|
||||
// key, so a failed fetch degrades gracefully rather than breaking the
|
||||
// form.
|
||||
varLabels: Record<string, VariableLabel>;
|
||||
}
|
||||
|
||||
type PartySide = "claimant" | "defendant" | "other";
|
||||
@@ -401,6 +358,9 @@ const state: State = {
|
||||
addPartyBusy: false,
|
||||
bases: [],
|
||||
basesLoaded: false,
|
||||
templates: [],
|
||||
templatesLoaded: false,
|
||||
varLabels: {},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -425,6 +385,21 @@ async function boot(): Promise<void> {
|
||||
console.warn("submission-draft: base catalog fetch failed", err);
|
||||
state.basesLoaded = true;
|
||||
});
|
||||
// t-paliad-349 slice 7 — uploaded-template catalog for the picker.
|
||||
loadTemplates().catch(err => {
|
||||
console.warn("submission-draft: template catalog fetch failed", err);
|
||||
state.templatesLoaded = true;
|
||||
});
|
||||
|
||||
// t-paliad-349 slice 5 — load the variable-label catalogue (Go SSOT)
|
||||
// before the first paint so the sidebar form labels render. Awaited
|
||||
// because labelFor needs it at paint time; a failure leaves varLabels
|
||||
// empty and labelFor falls back to the raw key (degraded but usable).
|
||||
try {
|
||||
state.varLabels = labelMap(await fetchVariableCatalogue());
|
||||
} catch (err) {
|
||||
console.warn("submission-draft: variable catalogue fetch failed", err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsed.mode === "global") {
|
||||
@@ -528,7 +503,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string; filename_keyword?: string }): Promise<SubmissionDraftView> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) throw new Error("no draft id");
|
||||
if (state.inFlight) {
|
||||
@@ -583,6 +558,7 @@ function paint(): void {
|
||||
paintPartyPicker();
|
||||
paintLanguageRow();
|
||||
paintLanguageFallback();
|
||||
paintKeywordRow();
|
||||
paintVariables();
|
||||
paintSectionList();
|
||||
paintPreview();
|
||||
@@ -1059,6 +1035,53 @@ function paintLanguageFallback(): void {
|
||||
el.style.display = fallback ? "" : "none";
|
||||
}
|
||||
|
||||
// autoKeyword returns the lang-aware rule name that leads the exported
|
||||
// filename when the user sets no override — shown as the keyword input's
|
||||
// placeholder so the lawyer sees the default without it being forced.
|
||||
// t-paliad-354.
|
||||
function autoKeyword(): string {
|
||||
const view = state.view;
|
||||
if (!view?.rule) return "";
|
||||
const en = (view.draft.language || view.lang || "de").toLowerCase() === "en";
|
||||
const name = en && view.rule.name_en ? view.rule.name_en : view.rule.name;
|
||||
return (name || "").trim();
|
||||
}
|
||||
|
||||
// paintKeywordRow syncs the "Stichwort (Dateiname)" input with the
|
||||
// draft's stored override (composer_meta.filename_keyword) and shows the
|
||||
// auto-derived rule name as the placeholder. Editing PATCHes the draft on
|
||||
// blur (change), persisting under composer_meta.filename_keyword.
|
||||
// t-paliad-354.
|
||||
function paintKeywordRow(): void {
|
||||
const input = document.getElementById("submission-draft-keyword") as HTMLInputElement | null;
|
||||
if (!input || !state.view) return;
|
||||
const stored = state.view.draft.composer_meta?.["filename_keyword"];
|
||||
input.value = typeof stored === "string" ? stored : "";
|
||||
const auto = autoKeyword();
|
||||
if (auto) input.placeholder = auto;
|
||||
input.onchange = () => { void onKeywordChange(input.value.trim()); };
|
||||
}
|
||||
|
||||
async function onKeywordChange(keyword: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const stored = state.view.draft.composer_meta?.["filename_keyword"];
|
||||
const current = typeof stored === "string" ? stored.trim() : "";
|
||||
if (keyword === current) return;
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
try {
|
||||
const view = await patchDraft({ filename_keyword: keyword });
|
||||
state.view = view;
|
||||
paintKeywordRow();
|
||||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
console.error("submission-draft keyword save:", err);
|
||||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||||
// Revert to the persisted value so the field doesn't lie.
|
||||
paintKeywordRow();
|
||||
}
|
||||
}
|
||||
|
||||
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
|
||||
if (!state.view) return;
|
||||
if ((state.view.draft.language || "de").toLowerCase() === lang) return;
|
||||
@@ -1217,29 +1240,46 @@ async function loadBases(): Promise<void> {
|
||||
if (state.view) paintBasePicker();
|
||||
}
|
||||
|
||||
// loadTemplates fetches the firm-shared uploaded-template catalog
|
||||
// (t-paliad-349 slice 7). Failure leaves the list empty — the picker
|
||||
// simply offers no uploaded templates, the editor stays usable.
|
||||
async function loadTemplates(): Promise<void> {
|
||||
const res = await fetch("/api/templates", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
throw new Error("template list HTTP " + res.status);
|
||||
}
|
||||
const body = await res.json() as { templates?: PickerTemplate[] };
|
||||
state.templates = (body.templates ?? []).filter(t => !!t.version_id);
|
||||
state.templatesLoaded = true;
|
||||
if (state.view) paintBasePicker();
|
||||
}
|
||||
|
||||
function paintBasePicker(): void {
|
||||
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
|
||||
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
|
||||
if (!row || !sel || !state.view) return;
|
||||
|
||||
// Hide the picker until the catalog has loaded AND the catalog has
|
||||
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
|
||||
// keeps the picker hidden indefinitely so the editor stays usable.
|
||||
if (!state.basesLoaded || state.bases.length === 0) {
|
||||
// Hide the picker only when BOTH catalogs are loaded-but-empty. As long
|
||||
// as bases OR uploaded templates exist, the picker is useful. A failed
|
||||
// fetch leaves the respective list empty; the editor stays usable.
|
||||
const hasBases = state.basesLoaded && state.bases.length > 0;
|
||||
const hasTemplates = state.templatesLoaded && state.templates.length > 0;
|
||||
if (!hasBases && !hasTemplates) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
|
||||
// Rebuild the <option> list each paint so language toggles + base
|
||||
// catalog updates flow through.
|
||||
// Rebuild the <option> list each paint so language toggles + catalog
|
||||
// updates flow through.
|
||||
sel.innerHTML = "";
|
||||
const currentBaseID = state.view.draft.base_id ?? "";
|
||||
const currentTplVersion = state.view.draft.template_version_id ?? "";
|
||||
|
||||
// "Keine Vorlagenbasis" only listed when the draft is currently in
|
||||
// that state (pre-Composer / cleared). Avoids tempting the lawyer
|
||||
// to clear after they've already picked one.
|
||||
if (!currentBaseID) {
|
||||
// that state (no base, no template). Avoids tempting the lawyer to
|
||||
// clear after they've already picked one.
|
||||
if (!currentBaseID && !currentTplVersion) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
|
||||
@@ -1252,6 +1292,21 @@ function paintBasePicker(): void {
|
||||
if (b.id === currentBaseID) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// t-paliad-349 slice 7 — uploaded templates as a separate optgroup.
|
||||
// The value is "tpl:<version_id>" so onBaseChange can route it to the
|
||||
// template_version_id PATCH instead of base_id.
|
||||
if (hasTemplates) {
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = isEN() ? "Uploaded templates" : "Hochgeladene Vorlagen";
|
||||
for (const tmpl of state.templates) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "tpl:" + tmpl.version_id;
|
||||
opt.textContent = isEN() ? tmpl.name_en : tmpl.name_de;
|
||||
if (tmpl.version_id === currentTplVersion) opt.selected = true;
|
||||
group.appendChild(opt);
|
||||
}
|
||||
sel.appendChild(group);
|
||||
}
|
||||
|
||||
// Wire change handler once per paint. Removing then re-adding
|
||||
// keeps the binding consistent across repaints (e.g. after
|
||||
@@ -1259,12 +1314,17 @@ function paintBasePicker(): void {
|
||||
sel.onchange = () => { onBaseChange(sel.value); };
|
||||
}
|
||||
|
||||
async function onBaseChange(newBaseID: string): Promise<void> {
|
||||
async function onBaseChange(newValue: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const payload: Record<string, unknown> = {
|
||||
// Empty string in the picker maps to null = clear.
|
||||
base_id: newBaseID === "" ? null : newBaseID,
|
||||
};
|
||||
// The picker mixes legacy bases (plain uuid) and uploaded templates
|
||||
// ("tpl:<version_id>"). Route to the matching field and clear the other
|
||||
// so the two render paths stay mutually exclusive. Empty = clear both.
|
||||
let payload: Record<string, unknown>;
|
||||
if (newValue.startsWith("tpl:")) {
|
||||
payload = { template_version_id: newValue.slice(4), base_id: null };
|
||||
} else {
|
||||
payload = { base_id: newValue === "" ? null : newValue, template_version_id: null };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${state.view.draft.id}`,
|
||||
@@ -1985,11 +2045,11 @@ function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec
|
||||
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
|
||||
row.innerHTML = `
|
||||
<div class="submission-bb-picker-row-head">
|
||||
<strong>${escapeHTML(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHtml(b.visibility)}">${escapeHtml(b.visibility)}</span>
|
||||
</div>
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHtml(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHtml(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
row.addEventListener("click", () => {
|
||||
void insertBlockIntoSection(b.id, sec.id, overlay);
|
||||
});
|
||||
@@ -2019,15 +2079,6 @@ async function insertBlockIntoSection(blockID: string, sectionID: string, overla
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
const draftID = state.view?.draft.id;
|
||||
@@ -2104,17 +2155,6 @@ function findVarInput(key: string): HTMLInputElement | null {
|
||||
);
|
||||
}
|
||||
|
||||
function cssEscape(s: string): string {
|
||||
// CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
|
||||
// older browsers may lack it; defensive fallback escapes characters
|
||||
// CSS treats as special. Placeholder keys never carry whitespace or
|
||||
// quotes so escaping is straightforward.
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
|
||||
function onDraftVarClick(key: string, ev: Event): void {
|
||||
const input = findVarInput(key);
|
||||
if (!input) return;
|
||||
|
||||
314
frontend/src/client/templates-authoring.ts
Normal file
314
frontend/src/client/templates-authoring.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { escapeHtml } from "../lib/docforge-editor/dom";
|
||||
import { fetchVariableCatalogue, type VariableEntry } from "../lib/docforge-editor/catalogue";
|
||||
|
||||
// t-paliad-349 docforge slice 6 — client for the template authoring page.
|
||||
//
|
||||
// Flow: list templates → upload a .docx (or open one) → the carrier renders
|
||||
// as run spans (<span class="docforge-run" data-run="N">) → the admin
|
||||
// selects text within one run, then clicks a variable in the palette → the
|
||||
// server injects {{slot}} at the selection and returns the updated view.
|
||||
//
|
||||
// The select-then-pick gesture keys on the run index (data-run) + the
|
||||
// selected text, matching the server's text-based InjectSlot so umlauts
|
||||
// can't desync the selection from the slice. Selections that span more than
|
||||
// one run are rejected with a hint (v1 scope: single-run text slots).
|
||||
|
||||
interface TemplateMeta {
|
||||
id: string;
|
||||
slug?: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
kind: string;
|
||||
source_format: string;
|
||||
firm?: string;
|
||||
is_active: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface TemplateSlot {
|
||||
key: string;
|
||||
anchor: string;
|
||||
label?: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
interface AuthoringView {
|
||||
template: TemplateMeta;
|
||||
preview_html: string;
|
||||
slots: TemplateSlot[];
|
||||
}
|
||||
|
||||
interface Selection1Run {
|
||||
runIndex: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
catalogue: VariableEntry[];
|
||||
openID: string | null;
|
||||
activeSlotKey: string | null;
|
||||
selection: Selection1Run | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
catalogue: [],
|
||||
openID: null,
|
||||
activeSlotKey: null,
|
||||
selection: null,
|
||||
};
|
||||
|
||||
function isEN(): boolean {
|
||||
return (document.documentElement.lang || "de").toLowerCase().startsWith("en");
|
||||
}
|
||||
|
||||
function labelOf(e: VariableEntry): string {
|
||||
return isEN() ? e.label_en : e.label_de;
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
try {
|
||||
state.catalogue = await fetchVariableCatalogue();
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: catalogue fetch failed", err);
|
||||
}
|
||||
|
||||
wireUploadForm();
|
||||
await loadList();
|
||||
}
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
const host = document.getElementById("docforge-template-list");
|
||||
if (!host) return;
|
||||
let metas: TemplateMeta[] = [];
|
||||
try {
|
||||
const res = await fetch("/api/admin/templates", { headers: { Accept: "application/json" } });
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { templates: TemplateMeta[] };
|
||||
metas = body.templates ?? [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: list fetch failed", err);
|
||||
}
|
||||
if (metas.length === 0) {
|
||||
host.innerHTML = `<li class="docforge-template-empty">${escapeHtml(isEN() ? "No templates yet." : "Noch keine Vorlagen.")}</li>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = metas
|
||||
.map((m) => {
|
||||
const name = isEN() ? m.name_en : m.name_de;
|
||||
const firm = m.firm ? ` · ${escapeHtml(m.firm)}` : "";
|
||||
return `<li class="docforge-template-row" data-template-id="${escapeHtml(m.id)}">
|
||||
<span class="docforge-template-name">${escapeHtml(name)}</span>
|
||||
<span class="docforge-template-meta">v${m.version}${firm}</span>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
host.querySelectorAll<HTMLLIElement>(".docforge-template-row").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const id = li.dataset.templateId;
|
||||
if (id) void openTemplate(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireUploadForm(): void {
|
||||
const form = document.getElementById("docforge-upload-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const status = document.getElementById("docforge-upload-status");
|
||||
const data = new FormData(form);
|
||||
setText(status, isEN() ? "Uploading…" : "Lädt hoch…");
|
||||
try {
|
||||
const res = await fetch("/api/admin/templates", { method: "POST", body: data });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
setText(status, (isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
|
||||
return;
|
||||
}
|
||||
const view = (await res.json()) as AuthoringView;
|
||||
setText(status, "");
|
||||
form.reset();
|
||||
await loadList();
|
||||
openView(view);
|
||||
} catch (err) {
|
||||
setText(status, (isEN() ? "Error: " : "Fehler: ") + String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openTemplate(id: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/templates/${encodeURIComponent(id)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
openView((await res.json()) as AuthoringView);
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: open failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function openView(view: AuthoringView): void {
|
||||
state.openID = view.template.id;
|
||||
state.activeSlotKey = null;
|
||||
state.selection = null;
|
||||
|
||||
const workspace = document.getElementById("docforge-workspace");
|
||||
if (workspace) workspace.hidden = false;
|
||||
|
||||
const title = document.getElementById("docforge-workspace-title");
|
||||
if (title) {
|
||||
const name = isEN() ? view.template.name_en : view.template.name_de;
|
||||
title.textContent = `${name} · v${view.template.version}`;
|
||||
}
|
||||
|
||||
renderPreview(view.preview_html);
|
||||
renderSlots(view.slots);
|
||||
renderPalette();
|
||||
setWorkspaceStatus("");
|
||||
}
|
||||
|
||||
function renderPreview(html: string): void {
|
||||
const host = document.getElementById("docforge-preview");
|
||||
if (!host) return;
|
||||
host.innerHTML = html;
|
||||
host.addEventListener("mouseup", onPreviewSelect);
|
||||
}
|
||||
|
||||
// onPreviewSelect captures a selection that lies entirely within one run
|
||||
// span; otherwise it clears the pending selection and hints.
|
||||
function onPreviewSelect(): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
const text = sel.toString();
|
||||
if (text === "") {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
const anchorRun = closestRun(sel.anchorNode);
|
||||
const focusRun = closestRun(sel.focusNode);
|
||||
if (!anchorRun || anchorRun !== focusRun) {
|
||||
state.selection = null;
|
||||
setWorkspaceStatus(isEN()
|
||||
? "Select within a single text span."
|
||||
: "Bitte innerhalb einer Textstelle markieren.");
|
||||
return;
|
||||
}
|
||||
const runIndex = Number(anchorRun.dataset.run);
|
||||
if (Number.isNaN(runIndex)) {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
state.selection = { runIndex, text };
|
||||
setWorkspaceStatus(state.activeSlotKey
|
||||
? (isEN() ? `Click to bind “${text}” → ${state.activeSlotKey}` : `Variable wählen, um „${text}“ zu setzen`)
|
||||
: (isEN() ? `Selected “${text}” — now pick a variable.` : `„${text}" markiert — jetzt Variable wählen.`));
|
||||
}
|
||||
|
||||
function closestRun(node: Node | null): HTMLElement | null {
|
||||
let el: Node | null = node;
|
||||
while (el && el !== document.body) {
|
||||
if (el instanceof HTMLElement && el.classList.contains("docforge-run")) return el;
|
||||
el = el.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// renderPalette groups catalogue entries by their namespace group and wires
|
||||
// each as a click-to-place control.
|
||||
function renderPalette(): void {
|
||||
const host = document.getElementById("docforge-palette");
|
||||
if (!host) return;
|
||||
if (state.catalogue.length === 0) {
|
||||
host.innerHTML = `<p class="docforge-palette-empty">${escapeHtml(isEN() ? "No variables." : "Keine Variablen.")}</p>`;
|
||||
return;
|
||||
}
|
||||
const groups = new Map<string, VariableEntry[]>();
|
||||
for (const e of state.catalogue) {
|
||||
const arr = groups.get(e.group) ?? [];
|
||||
arr.push(e);
|
||||
groups.set(e.group, arr);
|
||||
}
|
||||
let html = `<h3>${escapeHtml(isEN() ? "Variables" : "Variablen")}</h3>`;
|
||||
for (const [group, entries] of groups) {
|
||||
html += `<div class="docforge-palette-group"><h4>${escapeHtml(group)}</h4>`;
|
||||
for (const e of entries) {
|
||||
html += `<button type="button" class="docforge-palette-var" data-slot-key="${escapeHtml(e.key)}" title="{{${escapeHtml(e.key)}}}">${escapeHtml(labelOf(e))}</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
host.innerHTML = html;
|
||||
host.querySelectorAll<HTMLButtonElement>(".docforge-palette-var").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onPaletteClick(btn.dataset.slotKey ?? "", btn));
|
||||
});
|
||||
}
|
||||
|
||||
function onPaletteClick(slotKey: string, btn: HTMLButtonElement): void {
|
||||
state.activeSlotKey = slotKey;
|
||||
const host = document.getElementById("docforge-palette");
|
||||
host?.querySelectorAll(".docforge-palette-var--active").forEach((el) => el.classList.remove("docforge-palette-var--active"));
|
||||
btn.classList.add("docforge-palette-var--active");
|
||||
|
||||
if (state.selection) {
|
||||
void placeSlot(state.selection.runIndex, state.selection.text, slotKey);
|
||||
} else {
|
||||
setWorkspaceStatus(isEN()
|
||||
? `${slotKey} selected — now highlight the text to replace.`
|
||||
: `${slotKey} gewählt — jetzt den zu ersetzenden Text markieren.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function placeSlot(runIndex: number, selectedText: string, slotKey: string): Promise<void> {
|
||||
if (!state.openID) return;
|
||||
setWorkspaceStatus(isEN() ? "Placing…" : "Setze…");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/templates/${encodeURIComponent(state.openID)}/slots`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ run_index: runIndex, selected_text: selectedText, slot_key: slotKey }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
|
||||
return;
|
||||
}
|
||||
openView((await res.json()) as AuthoringView);
|
||||
} catch (err) {
|
||||
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSlots(slots: TemplateSlot[]): void {
|
||||
const host = document.getElementById("docforge-slot-list");
|
||||
if (!host) return;
|
||||
if (slots.length === 0) {
|
||||
host.innerHTML = `<li class="docforge-slot-empty">${escapeHtml(isEN() ? "No slots yet." : "Noch keine Platzhalter.")}</li>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = slots
|
||||
.map((s) => `<li class="docforge-slot-row" data-slot="${escapeHtml(s.key)}"><code>{{${escapeHtml(s.key)}}}</code></li>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function setWorkspaceStatus(msg: string): void {
|
||||
setText(document.getElementById("docforge-workspace-status"), msg);
|
||||
}
|
||||
|
||||
function setText(el: Element | null, msg: string): void {
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => void boot());
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
96
frontend/src/client/verfahrensablauf-detail-mode.test.ts
Normal file
96
frontend/src/client/verfahrensablauf-detail-mode.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { type CalculatedDeadline } from "./views/verfahrensablauf-core";
|
||||
import { filterByDetailMode, isRuleSelected } from "./verfahrensablauf-detail-mode";
|
||||
|
||||
// Helper — minimum-viable CalculatedDeadline for unit testing the filter
|
||||
// (the renderer's other fields don't matter to the filter).
|
||||
function mkRule(
|
||||
ruleId: string,
|
||||
priority: "mandatory" | "recommended" | "optional",
|
||||
extras: Partial<CalculatedDeadline> = {},
|
||||
): CalculatedDeadline {
|
||||
return {
|
||||
ruleId,
|
||||
code: ruleId,
|
||||
name: ruleId,
|
||||
nameEN: ruleId,
|
||||
party: "",
|
||||
priority,
|
||||
ruleRef: "",
|
||||
dueDate: "2026-06-01",
|
||||
originalDate: "2026-06-01",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isRuleSelected", () => {
|
||||
it("mandatory rules are always selected, even with explicit deselect", () => {
|
||||
const dl = mkRule("a", "mandatory");
|
||||
expect(isRuleSelected(dl, null)).toBe(true);
|
||||
expect(isRuleSelected(dl, { "rule:a": false })).toBe(true);
|
||||
});
|
||||
|
||||
it("recommended rules default to selected; explicit false deselects", () => {
|
||||
const dl = mkRule("a", "recommended");
|
||||
expect(isRuleSelected(dl, null)).toBe(true);
|
||||
expect(isRuleSelected(dl, {})).toBe(true);
|
||||
expect(isRuleSelected(dl, { "rule:a": false })).toBe(false);
|
||||
expect(isRuleSelected(dl, { "rule:a": true })).toBe(true);
|
||||
});
|
||||
|
||||
it("optional rules default to unselected; explicit true selects", () => {
|
||||
const dl = mkRule("a", "optional");
|
||||
expect(isRuleSelected(dl, null)).toBe(false);
|
||||
expect(isRuleSelected(dl, {})).toBe(false);
|
||||
expect(isRuleSelected(dl, { "rule:a": true })).toBe(true);
|
||||
expect(isRuleSelected(dl, { "rule:a": false })).toBe(false);
|
||||
});
|
||||
|
||||
it("conditional rules are treated as unselected in 'Gewählt' (engine left them unprojected)", () => {
|
||||
const dl = mkRule("a", "mandatory", { isConditional: true });
|
||||
expect(isRuleSelected(dl, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterByDetailMode", () => {
|
||||
const deadlines = [
|
||||
mkRule("anchor", "mandatory", { isRootEvent: true }),
|
||||
mkRule("m1", "mandatory"),
|
||||
mkRule("r1", "recommended"),
|
||||
mkRule("o1", "optional"),
|
||||
mkRule("o2", "optional"),
|
||||
];
|
||||
|
||||
it("mandatory_only returns mandatory + root only", () => {
|
||||
const out = filterByDetailMode(deadlines, "mandatory_only", null);
|
||||
const ids = out.map((d) => d.ruleId);
|
||||
expect(ids).toEqual(["anchor", "m1"]);
|
||||
});
|
||||
|
||||
it("selected (default flags) returns mandatory + recommended + root", () => {
|
||||
const out = filterByDetailMode(deadlines, "selected", null);
|
||||
const ids = out.map((d) => d.ruleId);
|
||||
expect(ids).toEqual(["anchor", "m1", "r1"]);
|
||||
});
|
||||
|
||||
it("selected with explicit per-rule overrides flips both directions", () => {
|
||||
const flags = { "rule:r1": false, "rule:o1": true };
|
||||
const out = filterByDetailMode(deadlines, "selected", flags);
|
||||
const ids = out.map((d) => d.ruleId);
|
||||
expect(ids).toEqual(["anchor", "m1", "o1"]);
|
||||
});
|
||||
|
||||
it("all_options returns the full list and tags unselected rules", () => {
|
||||
const out = filterByDetailMode(deadlines, "all_options", null);
|
||||
expect(out.length).toBe(5);
|
||||
const unselected = out.filter(
|
||||
(d) => (d as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected,
|
||||
);
|
||||
// Root + mandatory + recommended are selected; the two optionals
|
||||
// are unselected → 2 tagged rows.
|
||||
expect(unselected.map((d) => d.ruleId).sort()).toEqual(["o1", "o2"]);
|
||||
});
|
||||
});
|
||||
125
frontend/src/client/verfahrensablauf-detail-mode.ts
Normal file
125
frontend/src/client/verfahrensablauf-detail-mode.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// Detail-level filter for /tools/verfahrensablauf (m/paliad#149 Phase 2 P3).
|
||||
//
|
||||
// m's framing (2026-05-27 14:40, design §2.4a + §3.3a):
|
||||
//
|
||||
// "It is more that I want a grade of detail in our swimlane display.
|
||||
// I want to show them but also be able to 'focus' by not displaying
|
||||
// optional things. We need an option 'show only selected' or
|
||||
// 'mandatory' ... filter events from the timeline based on whether
|
||||
// they are selected in this scenario."
|
||||
//
|
||||
// Three modes:
|
||||
// - mandatory_only — render only priority='mandatory' rules
|
||||
// - selected (default) — mandatory + every rule whose effective
|
||||
// selection (priority-default OR scenario-flag
|
||||
// override) is true. Honest summary of "the
|
||||
// lawyer's scenario".
|
||||
// - all_options — render everything, with unselected optionals
|
||||
// rendered dotted-border + muted so the user sees
|
||||
// what they're NOT considering.
|
||||
//
|
||||
// Selection model (per design §2.4a):
|
||||
// - priority='mandatory' → always selected (cannot be deselected)
|
||||
// - priority='recommended' → default-selected; rule:<uuid>=false in
|
||||
// scenario_flags deselects
|
||||
// - priority='optional' → default-unselected; rule:<uuid>=true in
|
||||
// scenario_flags selects
|
||||
// - conditional rules → respect their condition_expr first; if
|
||||
// the predicate doesn't hold, they're
|
||||
// effectively unselected regardless of
|
||||
// their priority default
|
||||
|
||||
import { type CalculatedDeadline } from "./views/verfahrensablauf-core";
|
||||
|
||||
export type DetailMode = "mandatory_only" | "selected" | "all_options";
|
||||
|
||||
const STORAGE_KEY = "verfahrensablauf:view_mode";
|
||||
const DEFAULT_MODE: DetailMode = "selected";
|
||||
|
||||
export function getDetailMode(): DetailMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === "mandatory_only" || raw === "selected" || raw === "all_options") {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (private mode, security policy) — fall
|
||||
// through to default. Render still works; just no persistence.
|
||||
}
|
||||
return DEFAULT_MODE;
|
||||
}
|
||||
|
||||
export function setDetailMode(mode: DetailMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// isRuleSelected: combine priority default with the scenario-flag
|
||||
// override map. Returns the effective selection state.
|
||||
//
|
||||
// priority='mandatory' → always true
|
||||
// priority='recommended' → default true, flipped by rule:<uuid>=false
|
||||
// priority='optional' → default false, flipped by rule:<uuid>=true
|
||||
// other (informational) → treated as optional
|
||||
export function isRuleSelected(
|
||||
dl: CalculatedDeadline,
|
||||
scenarioFlags: Record<string, boolean> | null,
|
||||
): boolean {
|
||||
// A conditional rule that the engine left unprojected (no concrete
|
||||
// date because its predicate doesn't hold) is effectively unselected
|
||||
// in "selected" view mode — even for priority='mandatory' rules,
|
||||
// because mandatory means "must be filed IF the predicate fires",
|
||||
// not "always render". Surfacing a non-applicable conditional row in
|
||||
// "Gewählt" would be a lie. The "all_options" view re-surfaces it via
|
||||
// the unfiltered render path so the lawyer can see what scenarios
|
||||
// would unlock it.
|
||||
if (dl.isConditional) return false;
|
||||
|
||||
if (dl.priority === "mandatory") return true;
|
||||
|
||||
const key = dl.ruleId ? `rule:${dl.ruleId}` : null;
|
||||
const override = key && scenarioFlags ? scenarioFlags[key] : undefined;
|
||||
if (typeof override === "boolean") return override;
|
||||
|
||||
return dl.priority === "recommended";
|
||||
}
|
||||
|
||||
// filterByDetailMode applies the three-way filter to a deadlines list.
|
||||
// Returns a NEW array with the appropriate subset; the caller passes
|
||||
// the filtered list to the existing renderColumnsBody / renderTimelineBody.
|
||||
//
|
||||
// all_options: returns the input as-is, with an `__detailUnselected`
|
||||
// flag set on optionals/conditionals that aren't part of the active
|
||||
// scenario — the renderer reads this flag to add the dotted-border
|
||||
// muted styling.
|
||||
export function filterByDetailMode(
|
||||
deadlines: CalculatedDeadline[],
|
||||
mode: DetailMode,
|
||||
scenarioFlags: Record<string, boolean> | null,
|
||||
): CalculatedDeadline[] {
|
||||
if (mode === "all_options") {
|
||||
// No filtering, but tag the unselected rows so the renderer can
|
||||
// dim them. The original CalculatedDeadline doesn't carry this
|
||||
// axis — we stamp it via a cast so the renderer can pick it up
|
||||
// without growing the public type. Read-only at the renderer side.
|
||||
return deadlines.map((dl) => {
|
||||
const unselected = !isRuleSelected(dl, scenarioFlags) && !dl.isRootEvent;
|
||||
return unselected
|
||||
? ({ ...dl, __detailUnselected: true } as CalculatedDeadline & { __detailUnselected: true })
|
||||
: dl;
|
||||
});
|
||||
}
|
||||
if (mode === "mandatory_only") {
|
||||
return deadlines.filter(
|
||||
(dl) => dl.priority === "mandatory" || dl.isRootEvent,
|
||||
);
|
||||
}
|
||||
// "selected": mandatory always, plus rules whose effective selection
|
||||
// is true. Root events always render (they're the proceeding anchor).
|
||||
return deadlines.filter(
|
||||
(dl) => dl.isRootEvent || isRuleSelected(dl, scenarioFlags),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,320 +0,0 @@
|
||||
// Per-event-card choice popover + chip indicator (t-paliad-265 /
|
||||
// m/paliad#96).
|
||||
//
|
||||
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
|
||||
// button on cards that carry a non-empty `choices_offered` declaration
|
||||
// and an inert chip span next to the title. This module:
|
||||
//
|
||||
// 1. Wires a delegated click handler on the result container so the
|
||||
// caret opens a popover with the offered choice-kinds.
|
||||
// 2. Commits the user's pick — either by POSTing to the project-
|
||||
// bound endpoint or by mutating the in-memory state for the
|
||||
// unbound (no-project) case.
|
||||
// 3. Rehydrates the chip on every render + after every commit so the
|
||||
// glanceable indicator matches the active state.
|
||||
//
|
||||
// Two consumer pages — /tools/verfahrensablauf (unbound) and
|
||||
// /tools/fristenrechner (project-bound) — both wire this module
|
||||
// once at boot via attachEventCardChoices().
|
||||
|
||||
import { escAttr, escHtml } from "./verfahrensablauf-core";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
|
||||
|
||||
export interface EventChoice {
|
||||
submission_code: string;
|
||||
choice_kind: ChoiceKind;
|
||||
choice_value: string;
|
||||
}
|
||||
|
||||
// State surface — the page passes in callbacks that own persistence.
|
||||
// commit / remove must trigger a recalc on the page side (the popover
|
||||
// only owns its own visual state).
|
||||
export interface EventCardChoicesOpts {
|
||||
container: HTMLElement;
|
||||
// Initial state: a list of choices. The page seeds this from the
|
||||
// server response (project-bound) or from URL params (unbound).
|
||||
initial: EventChoice[];
|
||||
// commit gets called for an UPSERT. The page POSTs to the API (or
|
||||
// mutates URL state) AND triggers a recalc.
|
||||
commit: (choice: EventChoice) => Promise<void> | void;
|
||||
// remove gets called when the user resets a choice.
|
||||
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// One mutable bag per attach() call. The current implementation is a
|
||||
// single-page singleton — paginated views (admin tables) are not in
|
||||
// scope. Last-write-wins on the in-memory state.
|
||||
interface AttachedState {
|
||||
opts: EventCardChoicesOpts;
|
||||
// active: submission_code → kind → value. Rebuilt from `initial`
|
||||
// on every reseed() call.
|
||||
active: Map<string, Map<ChoiceKind, string>>;
|
||||
popover: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, AttachedState>();
|
||||
|
||||
// attachEventCardChoices wires the delegated click + popover lifecycle
|
||||
// to the given container. Call once per page after mount; safe to call
|
||||
// again with a fresh container.
|
||||
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
|
||||
const state: AttachedState = {
|
||||
opts,
|
||||
active: new Map(),
|
||||
popover: null,
|
||||
};
|
||||
for (const c of opts.initial) {
|
||||
if (!state.active.has(c.submission_code)) {
|
||||
state.active.set(c.submission_code, new Map());
|
||||
}
|
||||
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
|
||||
}
|
||||
states.set(opts.container, state);
|
||||
|
||||
opts.container.addEventListener("click", (e) => {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (caret) {
|
||||
e.stopPropagation();
|
||||
openPopover(state, caret);
|
||||
return;
|
||||
}
|
||||
// Outside-click closes the popover.
|
||||
if (state.popover && !state.popover.contains(e.target as Node)) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// ESC also closes.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.popover) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// Repaint chips on every renderResults() call. The page is
|
||||
// responsible for calling reseedChips() after re-render so the chip
|
||||
// dom node (re-created by the renderer) picks the active state up.
|
||||
reseedChips(opts.container);
|
||||
}
|
||||
|
||||
// reseedChips walks every chip span in the container and re-renders
|
||||
// its content from the active state map. Idempotent.
|
||||
export function reseedChips(container: HTMLElement): void {
|
||||
const state = states.get(container);
|
||||
if (!state) return;
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const kinds = state.active.get(code);
|
||||
if (!kinds || kinds.size === 0) {
|
||||
chip.innerHTML = "";
|
||||
chip.dataset.empty = "true";
|
||||
return;
|
||||
}
|
||||
chip.dataset.empty = "false";
|
||||
chip.innerHTML = renderChip(kinds);
|
||||
});
|
||||
// Skipped rows fade out via a class on the card-item ancestor.
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const skipped = state.active.get(code)?.get("skip") === "true";
|
||||
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
|
||||
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
|
||||
});
|
||||
}
|
||||
|
||||
function renderChip(kinds: Map<ChoiceKind, string>): string {
|
||||
const parts: string[] = [];
|
||||
if (kinds.get("skip") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
|
||||
}
|
||||
const ap = kinds.get("appellant");
|
||||
if (ap && ap !== "" ) {
|
||||
let label = "";
|
||||
switch (ap) {
|
||||
case "claimant": label = t("choices.appellant.claimant"); break;
|
||||
case "defendant": label = t("choices.appellant.defendant"); break;
|
||||
case "both": label = t("choices.appellant.both"); break;
|
||||
case "none": label = t("choices.appellant.none"); break;
|
||||
}
|
||||
if (label) {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
|
||||
}
|
||||
}
|
||||
if (kinds.get("include_ccr") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
closePopover(state);
|
||||
const code = caret.dataset.submissionCode || "";
|
||||
if (!code) return;
|
||||
let offered: Record<string, unknown> = {};
|
||||
try {
|
||||
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const isHidden = caret.dataset.isHidden === "1";
|
||||
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "event-card-choices-popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", t("choices.caret.title"));
|
||||
|
||||
const blocks: string[] = [];
|
||||
// t-paliad-293: hidden-card prominence. When the user opens the
|
||||
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
|
||||
// most likely intent — surface it as a single high-contrast action
|
||||
// at the top of the popover (rather than burying it under the skip
|
||||
// toggle's reset link). Clicking it clears the `skip` choice, which
|
||||
// is the same wire effect as the legacy inline chip from t-paliad-290.
|
||||
if (isHidden) {
|
||||
blocks.push(renderUnhideBlock());
|
||||
}
|
||||
if (Array.isArray(offered.appellant)) {
|
||||
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
|
||||
}
|
||||
if (Array.isArray(offered.include_ccr)) {
|
||||
blocks.push(renderToggleBlock(state, code, "include_ccr"));
|
||||
}
|
||||
if (Array.isArray(offered.skip)) {
|
||||
blocks.push(renderToggleBlock(state, code, "skip"));
|
||||
}
|
||||
pop.innerHTML = blocks.join("");
|
||||
|
||||
document.body.appendChild(pop);
|
||||
state.popover = pop;
|
||||
positionPopover(pop, caret);
|
||||
|
||||
pop.addEventListener("click", async (e) => {
|
||||
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
|
||||
const value = btn.dataset.choiceValue || "";
|
||||
const action = btn.dataset.choiceAction;
|
||||
if (!kind) return;
|
||||
try {
|
||||
if (action === "set") {
|
||||
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
if (!state.active.has(code)) state.active.set(code, new Map());
|
||||
state.active.get(code)!.set(kind, value);
|
||||
} else if (action === "clear") {
|
||||
await state.opts.remove(code, kind);
|
||||
state.active.get(code)?.delete(kind);
|
||||
}
|
||||
reseedChips(state.opts.container);
|
||||
closePopover(state);
|
||||
} catch (err) {
|
||||
console.error("event card choice commit failed", err);
|
||||
// Surface a soft inline error inside the popover; do NOT close.
|
||||
const errEl = document.createElement("div");
|
||||
errEl.className = "event-card-choices-error";
|
||||
errEl.textContent = t("choices.commit.error");
|
||||
pop.appendChild(errEl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
|
||||
const current = state.active.get(code)?.get("appellant") || "";
|
||||
const buttons = values
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.map((v) => {
|
||||
const labelKey = `choices.appellant.${v}` as const;
|
||||
const isActive = v === current;
|
||||
return `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="appellant"
|
||||
data-choice-value="${escAttr(v)}"
|
||||
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
})
|
||||
.join("");
|
||||
const reset = current
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
|
||||
<div class="event-card-choices-options">${buttons}</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
|
||||
const current = state.active.get(code)?.get(kind) || "false";
|
||||
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
|
||||
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
|
||||
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
|
||||
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="${kind}"
|
||||
data-choice-value="${v}"
|
||||
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
const reset = state.active.get(code)?.has(kind)
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
|
||||
<div class="event-card-choices-options">
|
||||
${opt("true", trueKey)}
|
||||
${opt("false", falseKey)}
|
||||
</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
|
||||
// action — surfaced only when the caret is opened on a re-surfaced
|
||||
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
|
||||
// the same `clear` action as the skip-block reset link below, but
|
||||
// labelled in the user's terms ("restore this card" rather than
|
||||
// "reset skip choice"). Drops out of the popover automatically on
|
||||
// non-hidden cards so the popover stays minimal. (t-paliad-293)
|
||||
function renderUnhideBlock(): string {
|
||||
const label = t("choices.unhide.chip");
|
||||
return `<div class="event-card-choices-block event-card-choices-block--unhide">
|
||||
<button type="button"
|
||||
data-choice-action="clear"
|
||||
data-choice-kind="skip"
|
||||
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function closePopover(state: AttachedState): void {
|
||||
if (state.popover) {
|
||||
state.popover.remove();
|
||||
state.popover = null;
|
||||
}
|
||||
}
|
||||
|
||||
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
|
||||
const rect = caret.getBoundingClientRect();
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
||||
pop.style.position = "absolute";
|
||||
pop.style.top = `${rect.bottom + scrollY + 4}px`;
|
||||
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
|
||||
pop.style.zIndex = "1000";
|
||||
}
|
||||
|
||||
// Returns the current in-memory choice list for the given container —
|
||||
// used by the unbound /tools/verfahrensablauf page to keep the URL
|
||||
// param in sync.
|
||||
export function currentChoices(container: HTMLElement): EventChoice[] {
|
||||
const state = states.get(container);
|
||||
if (!state) return [];
|
||||
const out: EventChoice[] = [];
|
||||
state.active.forEach((kinds, code) => {
|
||||
kinds.forEach((value, kind) => {
|
||||
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
calculateDeadlines,
|
||||
deadlineCardHtml,
|
||||
formatDurationLabel,
|
||||
renderColumnsBody,
|
||||
@@ -773,3 +774,81 @@ describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", (
|
||||
.toBe("Time limit set by the court");
|
||||
});
|
||||
});
|
||||
|
||||
// Pin the engine-options plumbing surface (t-paliad-348 / yoUPC#178).
|
||||
// calculateDeadlines must forward `includeOptional` and
|
||||
// `triggerEventAnchors` straight into the POST body so the Go handler
|
||||
// (handleFristenrechnerAPI) can pass them into lp.CalcOptions. If a
|
||||
// future refactor drops the fields, the Builder triplet silently
|
||||
// reverts to "engine emits optional rules" and the unified
|
||||
// /tools/procedures page loses its naked-proceeding default.
|
||||
describe("calculateDeadlines — forwards engine options into request body", () => {
|
||||
type CapturedRequest = { url: string; body: Record<string, unknown> };
|
||||
let captured: CapturedRequest | null;
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
captured = null;
|
||||
originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
captured = { url: String(input), body };
|
||||
return new Response(JSON.stringify({
|
||||
proceedingType: "x", proceedingName: "x", triggerDate: "2026-01-01", deadlines: [],
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}) as typeof globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("default call omits includeOptional and triggerEventAnchors", async () => {
|
||||
await calculateDeadlines({ proceedingType: "upc.inf.cfi", triggerDate: "2026-05-26" });
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.body.includeOptional).toBeUndefined();
|
||||
expect(captured!.body.triggerEventAnchors).toBeUndefined();
|
||||
});
|
||||
|
||||
test("includeOptional=true sends includeOptional: true", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
includeOptional: true,
|
||||
});
|
||||
expect(captured!.body.includeOptional).toBe(true);
|
||||
});
|
||||
|
||||
test("includeOptional=false is omitted (matches engine default)", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
includeOptional: false,
|
||||
});
|
||||
expect(captured!.body.includeOptional).toBeUndefined();
|
||||
});
|
||||
|
||||
test("triggerEventAnchors forwarded as object", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
triggerEventAnchors: {
|
||||
"upc.inf.cfi.oral": "2026-09-01",
|
||||
"upc.inf.cfi.decision": "2026-12-15",
|
||||
},
|
||||
});
|
||||
expect(captured!.body.triggerEventAnchors).toEqual({
|
||||
"upc.inf.cfi.oral": "2026-09-01",
|
||||
"upc.inf.cfi.decision": "2026-12-15",
|
||||
});
|
||||
});
|
||||
|
||||
test("empty triggerEventAnchors is omitted", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
triggerEventAnchors: {},
|
||||
});
|
||||
expect(captured!.body.triggerEventAnchors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,11 @@ export interface AdjustmentReason {
|
||||
}
|
||||
|
||||
export interface CalculatedDeadline {
|
||||
// ruleId is the sequencing_rule.id UUID, used by the P3 per-rule
|
||||
// selection deviations (`rule:<uuid>` keys in projects.scenario_flags).
|
||||
// Empty on synthetic UI markers like the appeal trigger row that the
|
||||
// engine prepends — those carry no real rule_id.
|
||||
ruleId?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
@@ -266,6 +271,12 @@ export interface DeadlineResponse {
|
||||
// when the toggle is OFF — so users know there's something to
|
||||
// re-surface.
|
||||
hiddenCount?: number;
|
||||
// rulesAwaitingAnchor (t-paliad-348 / yoUPC#178): number of rules the
|
||||
// engine suppressed because their `trigger_event_id` anchor wasn't
|
||||
// supplied via CalcParams.triggerEventAnchors. Mirrors the Go
|
||||
// Timeline.RulesAwaitingAnchor counter — a single integer surface for
|
||||
// "N rules waiting on an anchor" UI affordances.
|
||||
rulesAwaitingAnchor?: number;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -306,6 +317,20 @@ export interface CalcParams {
|
||||
// endentscheidung | kostenentscheidung | anordnung |
|
||||
// schadensbemessung | bucheinsicht.
|
||||
appealTarget?: string;
|
||||
// t-paliad-348 / yoUPC#178 — surface the engine's two new CalcOptions
|
||||
// axes to the HTTP boundary:
|
||||
//
|
||||
// includeOptional: when true, the engine returns priority='optional'
|
||||
// rules in the timeline. Default false matches the engine default
|
||||
// (mandatory backbone only). The /tools/procedures detailgrad
|
||||
// toggle ("all_options" mode) drives this to true so the dimmed
|
||||
// optional cards can be rendered for the lawyer to opt into.
|
||||
// triggerEventAnchors: per-event-code anchor dates the engine
|
||||
// consults for rules carrying trigger_event_id. Empty/omitted =
|
||||
// no anchors → such rules render as IsConditional (the engine
|
||||
// refuses to fabricate a date off the proceeding's trigger date).
|
||||
includeOptional?: boolean;
|
||||
triggerEventAnchors?: Record<string, string>;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -613,13 +638,43 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
data-empty="true"></span>`
|
||||
: "";
|
||||
|
||||
return `<div class="timeline-item-header">
|
||||
// m/paliad#149 Phase 2 P3 — Aufnehmen / Entfernen chip on optional /
|
||||
// recommended rules (when the detail-mode filter is in "all_options"
|
||||
// or "selected"). The detail-mode filter tags unselected rules with
|
||||
// __detailUnselected; the renderer picks that up to render the chip
|
||||
// in its "Aufnehmen" state. Mandatory rules never get the chip — the
|
||||
// user can't deselect them.
|
||||
const detailUnselected = (dl as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected === true;
|
||||
let selectionChip = "";
|
||||
if (dl.ruleId && dl.priority !== "mandatory" && !dl.isRootEvent) {
|
||||
if (detailUnselected) {
|
||||
selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--add"
|
||||
data-rule-id="${escAttr(dl.ruleId)}"
|
||||
data-priority="${escAttr(dl.priority)}"
|
||||
data-action="aufnehmen"
|
||||
title="${escAttr(t("deadlines.detail.optional_unselected_hint"))}">
|
||||
${escHtml(t("deadlines.detail.aufnehmen"))}
|
||||
</button>`;
|
||||
} else if (dl.priority === "recommended" || dl.priority === "optional") {
|
||||
// The rule IS in the active scenario but can be removed. Renders
|
||||
// as a discreet [Entfernen] chip on optional / recommended cards.
|
||||
selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--remove"
|
||||
data-rule-id="${escAttr(dl.ruleId)}"
|
||||
data-priority="${escAttr(dl.priority)}"
|
||||
data-action="entfernen">
|
||||
${escHtml(t("deadlines.detail.entfernen"))}
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="timeline-item-header${detailUnselected ? " timeline-item-header--unselected" : ""}">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${stateIconsHtml}
|
||||
${chipHtml}
|
||||
</span>
|
||||
${dateStr}
|
||||
${selectionChip}
|
||||
${choicesHtml}
|
||||
</div>
|
||||
${meta}
|
||||
@@ -1007,7 +1062,15 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
// timeline-item — dotted border + faded styling.
|
||||
dl.isConditional ? "fr-col-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return `<div class="${itemClasses}">
|
||||
// data-rule-id on the card root lets the Litigation Builder
|
||||
// overlay per-card state (planned/filed/skipped) + action
|
||||
// affordances onto cards rendered through this shared body
|
||||
// without re-implementing the columns renderer. Empty on
|
||||
// synthetic rows (appeal trigger marker etc.); the Builder
|
||||
// skips state lookup when missing.
|
||||
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
|
||||
const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
|
||||
return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
@@ -1075,6 +1138,10 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
: undefined,
|
||||
includeHidden: params.includeHidden ? true : undefined,
|
||||
appealTarget: params.appealTarget || undefined,
|
||||
includeOptional: params.includeOptional ? true : undefined,
|
||||
triggerEventAnchors: params.triggerEventAnchors && Object.keys(params.triggerEventAnchors).length > 0
|
||||
? params.triggerEventAnchors
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage
|
||||
// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`.
|
||||
//
|
||||
// The contract:
|
||||
// 1. URL params (proceeding, side, target, trigger_date) define which
|
||||
// timeline kind the user is looking at — paste-able, shareable,
|
||||
// refresh-resistant.
|
||||
// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the
|
||||
// per-user scenario tweaks (event_choices, court_id, flags,
|
||||
// show_hidden) — these never leak into a shared link.
|
||||
// 3. On hydrate, URL wins. localStorage fills the rest.
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
APPEAL_TARGETS,
|
||||
SCENARIO_KEYS,
|
||||
SCENARIO_PREFIX,
|
||||
URL_KEYS,
|
||||
applyFiltersToSearch,
|
||||
hydrate,
|
||||
makeMemoryStorage,
|
||||
parseAppealTargetFromSearch,
|
||||
parseProceedingFromSearch,
|
||||
parseSideFromSearch,
|
||||
parseTriggerDateFromSearch,
|
||||
readBoolFlag,
|
||||
readCourtId,
|
||||
readEventChoices,
|
||||
readScenario,
|
||||
writeBoolFlag,
|
||||
writeCourtId,
|
||||
writeEventChoices,
|
||||
} from "./verfahrensablauf-state";
|
||||
|
||||
describe("URL parsers — filter chips", () => {
|
||||
test("parseProceedingFromSearch returns empty string when absent", () => {
|
||||
expect(parseProceedingFromSearch("")).toBe("");
|
||||
expect(parseProceedingFromSearch("?side=claimant")).toBe("");
|
||||
});
|
||||
|
||||
test("parseProceedingFromSearch echoes the raw value", () => {
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi");
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified");
|
||||
});
|
||||
|
||||
test("parseSideFromSearch validates the enum", () => {
|
||||
expect(parseSideFromSearch("?side=claimant")).toBe("claimant");
|
||||
expect(parseSideFromSearch("?side=defendant")).toBe("defendant");
|
||||
expect(parseSideFromSearch("?side=neither")).toBe(null);
|
||||
expect(parseSideFromSearch("")).toBe(null);
|
||||
});
|
||||
|
||||
test("parseAppealTargetFromSearch only accepts canonical slugs", () => {
|
||||
for (const t of APPEAL_TARGETS) {
|
||||
expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t);
|
||||
}
|
||||
expect(parseAppealTargetFromSearch("?target=unknown")).toBe("");
|
||||
expect(parseAppealTargetFromSearch("")).toBe("");
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch validates the ISO-date shape", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe("");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month
|
||||
expect(parseTriggerDateFromSearch("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL encoder — applyFiltersToSearch", () => {
|
||||
test("empty filters preserve the existing query string", () => {
|
||||
expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep");
|
||||
});
|
||||
|
||||
test("setting a filter writes the canonical key", () => {
|
||||
expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi");
|
||||
expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant");
|
||||
expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung");
|
||||
expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("setting null / empty / undefined deletes the key", () => {
|
||||
expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe("");
|
||||
expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe("");
|
||||
});
|
||||
|
||||
test("invalid trigger_date is deleted (never written as-is)", () => {
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe("");
|
||||
});
|
||||
|
||||
test("setting all four filters together emits all four keys", () => {
|
||||
const out = applyFiltersToSearch("", {
|
||||
proceeding: "upc.apl.unified",
|
||||
side: "defendant",
|
||||
target: "endentscheidung",
|
||||
triggerDate: "2026-05-26",
|
||||
});
|
||||
expect(out).toContain("proceeding=upc.apl.unified");
|
||||
expect(out).toContain("side=defendant");
|
||||
expect(out).toContain("target=endentscheidung");
|
||||
expect(out).toContain("trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("other params (project, view) are preserved", () => {
|
||||
const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" });
|
||||
expect(out).toContain("project=abc");
|
||||
expect(out).toContain("view=timeline");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
|
||||
test("absent keys in the filter object don't touch existing URL values", () => {
|
||||
// Only updating side — proceeding should be untouched.
|
||||
const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" });
|
||||
expect(out).toContain("proceeding=upc.inf.cfi");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL round-trip — encode then parse yields the same value", () => {
|
||||
test("proceeding", () => {
|
||||
const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" });
|
||||
expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi");
|
||||
});
|
||||
|
||||
test("side", () => {
|
||||
const enc = applyFiltersToSearch("", { side: "defendant" });
|
||||
expect(parseSideFromSearch(enc)).toBe("defendant");
|
||||
});
|
||||
|
||||
test("target", () => {
|
||||
const enc = applyFiltersToSearch("", { target: "kostenentscheidung" });
|
||||
expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung");
|
||||
});
|
||||
|
||||
test("trigger_date", () => {
|
||||
const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" });
|
||||
expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scenario localStorage helpers", () => {
|
||||
test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => {
|
||||
expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario");
|
||||
for (const key of Object.values(SCENARIO_KEYS)) {
|
||||
expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("readEventChoices returns [] on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readEventChoices(s)).toEqual([]);
|
||||
});
|
||||
|
||||
test("writeEventChoices + readEventChoices round-trip", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const choices = [
|
||||
{ submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" },
|
||||
{ submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" },
|
||||
];
|
||||
writeEventChoices(s, choices);
|
||||
expect(readEventChoices(s)).toEqual(choices);
|
||||
});
|
||||
|
||||
test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null);
|
||||
writeEventChoices(s, []);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null);
|
||||
});
|
||||
|
||||
test("readEventChoices ignores unknown choice_kind values", () => {
|
||||
const s = makeMemoryStorage();
|
||||
s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1");
|
||||
expect(readEventChoices(s)).toEqual([
|
||||
{ submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" },
|
||||
{ submission_code: "r3", choice_kind: "skip", choice_value: "1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readCourtId(s)).toBe("");
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(readCourtId(s)).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("writeCourtId('') removes the key", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC");
|
||||
writeCourtId(s, "");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null);
|
||||
});
|
||||
|
||||
test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, false);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null);
|
||||
});
|
||||
|
||||
test("readScenario returns all fields defaulted on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readScenario(s)).toEqual({
|
||||
eventChoices: [],
|
||||
courtId: "",
|
||||
ccr: false,
|
||||
infAmend: false,
|
||||
revAmend: false,
|
||||
revCci: false,
|
||||
showHidden: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hydration order — URL wins, localStorage fills the rest", () => {
|
||||
test("URL fills filter chips, localStorage fills scenario state", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.showHidden, true);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26",
|
||||
s,
|
||||
);
|
||||
// URL-sourced
|
||||
expect(out.proceeding).toBe("upc.inf.cfi");
|
||||
expect(out.side).toBe("defendant");
|
||||
expect(out.target).toBe("endentscheidung");
|
||||
expect(out.triggerDate).toBe("2026-05-26");
|
||||
// localStorage-sourced
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
expect(out.showHidden).toBe(true);
|
||||
expect(out.ccr).toBe(true);
|
||||
});
|
||||
|
||||
test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
const out = hydrate("", s);
|
||||
expect(out.proceeding).toBe("");
|
||||
expect(out.side).toBe(null);
|
||||
expect(out.target).toBe("");
|
||||
expect(out.triggerDate).toBe("");
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("absent localStorage → URL still fills filter chips, scenario defaults", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01",
|
||||
s,
|
||||
);
|
||||
expect(out.proceeding).toBe("upc.apl.unified");
|
||||
expect(out.side).toBe("claimant");
|
||||
expect(out.target).toBe("anordnung");
|
||||
expect(out.triggerDate).toBe("2026-07-01");
|
||||
expect(out.courtId).toBe("");
|
||||
expect(out.eventChoices).toEqual([]);
|
||||
expect(out.showHidden).toBe(false);
|
||||
});
|
||||
|
||||
test("a shared link doesn't leak the recipient's scenario state in", () => {
|
||||
// Two storages: m's (loaded with court + flags) and a recipient's
|
||||
// (empty). The same URL should reproduce filter chips identically
|
||||
// but leave each user's scenario state untouched.
|
||||
const mStorage = makeMemoryStorage();
|
||||
writeCourtId(mStorage, "UPC-LD-MUC");
|
||||
writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true);
|
||||
const recipientStorage = makeMemoryStorage();
|
||||
|
||||
const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26";
|
||||
|
||||
const mView = hydrate(sharedURL, mStorage);
|
||||
const recipientView = hydrate(sharedURL, recipientStorage);
|
||||
|
||||
// Filter chips identical
|
||||
expect(mView.proceeding).toBe(recipientView.proceeding);
|
||||
expect(mView.side).toBe(recipientView.side);
|
||||
expect(mView.triggerDate).toBe(recipientView.triggerDate);
|
||||
|
||||
// Scenario state diverges — recipient sees defaults
|
||||
expect(mView.courtId).toBe("UPC-LD-MUC");
|
||||
expect(recipientView.courtId).toBe("");
|
||||
expect(mView.ccr).toBe(true);
|
||||
expect(recipientView.ccr).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL key constants match the documented contract", () => {
|
||||
test("URL_KEYS uses the spec'd snake_case names", () => {
|
||||
expect(URL_KEYS.proceeding).toBe("proceeding");
|
||||
expect(URL_KEYS.side).toBe("side");
|
||||
expect(URL_KEYS.target).toBe("target");
|
||||
expect(URL_KEYS.triggerDate).toBe("trigger_date");
|
||||
});
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
// /tools/verfahrensablauf URL + scenario-localStorage state contract
|
||||
// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into
|
||||
// two namespaces:
|
||||
//
|
||||
// URL params (filter chips — the timeline kind the user is looking
|
||||
// at; paste-able, shareable, refresh-resistant):
|
||||
// proceeding, side, target, trigger_date
|
||||
//
|
||||
// localStorage `paliad.verfahrensablauf.scenario.*` (per-user
|
||||
// scenario inputs — the noisy parts that don't belong in a URL):
|
||||
// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
|
||||
// show_hidden
|
||||
//
|
||||
// Hydration order: URL wins. On page load, URL fills the filter chips;
|
||||
// localStorage fills the rest. Filter-chip changes write to URL only.
|
||||
// Scenario changes write to localStorage only. A shared link from a
|
||||
// colleague reproduces the timeline kind (proceeding + side + target +
|
||||
// trigger_date) but never leaks the recipient's court / flag /
|
||||
// event_choices state in.
|
||||
//
|
||||
// All helpers in this module are pure: they take a search string (or a
|
||||
// StorageLike) and return values, no DOM. The wiring in
|
||||
// ../verfahrensablauf.ts mounts them onto window.location +
|
||||
// window.localStorage at runtime.
|
||||
|
||||
import type { EventChoice, ChoiceKind } from "./event-card-choices";
|
||||
|
||||
// ----- URL params (filter chips) ----------------------------------
|
||||
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
export const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
export type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
export const URL_KEYS = {
|
||||
proceeding: "proceeding",
|
||||
side: "side",
|
||||
target: "target",
|
||||
triggerDate: "trigger_date",
|
||||
} as const;
|
||||
|
||||
// parseProceedingFromSearch extracts the proceeding code. Returns ""
|
||||
// if absent. No validation against the proceeding registry — that's
|
||||
// the caller's job (an unknown code from a stale link should leave
|
||||
// the first-tile auto-select fallback running).
|
||||
export function parseProceedingFromSearch(search: string): string {
|
||||
const v = new URLSearchParams(search).get(URL_KEYS.proceeding);
|
||||
return v ?? "";
|
||||
}
|
||||
|
||||
export function parseSideFromSearch(search: string): Side {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.side);
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
export function parseAppealTargetFromSearch(search: string): AppealTarget {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.target) || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// parseTriggerDateFromSearch validates the ISO-date shape so a
|
||||
// malformed link can't poison the date input. Accepts "YYYY-MM-DD"
|
||||
// only. Round-tripped against Date to reject 2026-02-30 etc.
|
||||
export function parseTriggerDateFromSearch(search: string): string {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || "";
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "";
|
||||
const d = new Date(raw + "T00:00:00Z");
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
if (d.toISOString().slice(0, 10) !== raw) return "";
|
||||
return raw;
|
||||
}
|
||||
|
||||
// applyFiltersToSearch produces the canonical query string for the
|
||||
// four URL-owned params. Other params (e.g. ?view=, ?project=) are
|
||||
// preserved verbatim. Empty values are deleted, never written as
|
||||
// empty string, so the URL stays clean on the default.
|
||||
export function applyFiltersToSearch(
|
||||
search: string,
|
||||
filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams(search);
|
||||
if ("proceeding" in filters) {
|
||||
if (filters.proceeding && filters.proceeding !== "") {
|
||||
params.set(URL_KEYS.proceeding, filters.proceeding);
|
||||
} else {
|
||||
params.delete(URL_KEYS.proceeding);
|
||||
}
|
||||
}
|
||||
if ("side" in filters) {
|
||||
if (filters.side === "claimant" || filters.side === "defendant") {
|
||||
params.set(URL_KEYS.side, filters.side);
|
||||
} else {
|
||||
params.delete(URL_KEYS.side);
|
||||
}
|
||||
}
|
||||
if ("target" in filters) {
|
||||
if (filters.target && filters.target !== "") {
|
||||
params.set(URL_KEYS.target, filters.target);
|
||||
} else {
|
||||
params.delete(URL_KEYS.target);
|
||||
}
|
||||
}
|
||||
if ("triggerDate" in filters) {
|
||||
if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) {
|
||||
params.set(URL_KEYS.triggerDate, filters.triggerDate);
|
||||
} else {
|
||||
params.delete(URL_KEYS.triggerDate);
|
||||
}
|
||||
}
|
||||
const s = params.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
// ----- localStorage (scenario state) ------------------------------
|
||||
|
||||
export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario";
|
||||
export const SCENARIO_KEYS = {
|
||||
eventChoices: `${SCENARIO_PREFIX}.event_choices`,
|
||||
courtId: `${SCENARIO_PREFIX}.court_id`,
|
||||
ccr: `${SCENARIO_PREFIX}.ccr`,
|
||||
infAmend: `${SCENARIO_PREFIX}.inf_amend`,
|
||||
revAmend: `${SCENARIO_PREFIX}.rev_amend`,
|
||||
revCci: `${SCENARIO_PREFIX}.rev_cci`,
|
||||
showHidden: `${SCENARIO_PREFIX}.show_hidden`,
|
||||
} as const;
|
||||
|
||||
// StorageLike is the tiny subset of the Web Storage API the scenario
|
||||
// helpers actually use. Lets the tests pass a Map-backed fake without
|
||||
// pulling in a full localStorage polyfill.
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
// readEventChoices is forgiving: malformed tuples or unknown
|
||||
// choice_kinds are dropped silently. Same shape as the legacy URL
|
||||
// codec (comma-separated `submission_code:kind=value`).
|
||||
export function readEventChoices(storage: StorageLike): EventChoice[] {
|
||||
const raw = storage.getItem(SCENARIO_KEYS.eventChoices);
|
||||
if (!raw) return [];
|
||||
const out: EventChoice[] = [];
|
||||
for (const tuple of raw.split(",")) {
|
||||
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const kind = m[2] as ChoiceKind;
|
||||
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
||||
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void {
|
||||
if (choices.length === 0) {
|
||||
storage.removeItem(SCENARIO_KEYS.eventChoices);
|
||||
return;
|
||||
}
|
||||
const enc = choices
|
||||
.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`)
|
||||
.join(",");
|
||||
storage.setItem(SCENARIO_KEYS.eventChoices, enc);
|
||||
}
|
||||
|
||||
// readCourtId / writeCourtId — empty string == no court picked. The
|
||||
// "" value is stored as a removed key, not an empty string entry, so
|
||||
// reading it back yields null rather than "".
|
||||
export function readCourtId(storage: StorageLike): string {
|
||||
return storage.getItem(SCENARIO_KEYS.courtId) ?? "";
|
||||
}
|
||||
|
||||
export function writeCourtId(storage: StorageLike, courtId: string): void {
|
||||
if (courtId === "") {
|
||||
storage.removeItem(SCENARIO_KEYS.courtId);
|
||||
return;
|
||||
}
|
||||
storage.setItem(SCENARIO_KEYS.courtId, courtId);
|
||||
}
|
||||
|
||||
// Boolean flags — "1" / "0" string encoding, removeItem on default
|
||||
// (false for flags, also false for show_hidden) so the storage stays
|
||||
// uncluttered on a fresh page.
|
||||
export function readBoolFlag(storage: StorageLike, key: string): boolean {
|
||||
return storage.getItem(key) === "1";
|
||||
}
|
||||
|
||||
export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void {
|
||||
if (on) storage.setItem(key, "1");
|
||||
else storage.removeItem(key);
|
||||
}
|
||||
|
||||
// Read all scenario state in one call — convenience for the page's
|
||||
// load-time hydration. Caller decides whether to apply each field
|
||||
// (e.g. court_id is proceeding-specific; the page may discard the
|
||||
// stored value if the active proceeding doesn't expose a court row).
|
||||
export interface ScenarioState {
|
||||
eventChoices: EventChoice[];
|
||||
courtId: string;
|
||||
ccr: boolean;
|
||||
infAmend: boolean;
|
||||
revAmend: boolean;
|
||||
revCci: boolean;
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export function readScenario(storage: StorageLike): ScenarioState {
|
||||
return {
|
||||
eventChoices: readEventChoices(storage),
|
||||
courtId: readCourtId(storage),
|
||||
ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr),
|
||||
infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend),
|
||||
revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend),
|
||||
revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci),
|
||||
showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden),
|
||||
};
|
||||
}
|
||||
|
||||
// ----- URL → localStorage hydration order -------------------------
|
||||
|
||||
// The page's load-time contract: read URL filters, then read
|
||||
// scenario state from localStorage. URL wins on conflict — but the
|
||||
// only field that can conflict is none of them today (URL owns
|
||||
// proceeding/side/target/trigger_date; localStorage owns the rest).
|
||||
// The order matters for one edge case: if a future field migrates
|
||||
// from URL → localStorage with overlap, the URL value MUST be honored.
|
||||
|
||||
export interface HydratedState extends ScenarioState {
|
||||
proceeding: string;
|
||||
side: Side;
|
||||
target: AppealTarget;
|
||||
triggerDate: string;
|
||||
}
|
||||
|
||||
export function hydrate(search: string, storage: StorageLike): HydratedState {
|
||||
const scenario = readScenario(storage);
|
||||
return {
|
||||
proceeding: parseProceedingFromSearch(search),
|
||||
side: parseSideFromSearch(search),
|
||||
target: parseAppealTargetFromSearch(search),
|
||||
triggerDate: parseTriggerDateFromSearch(search),
|
||||
...scenario,
|
||||
};
|
||||
}
|
||||
|
||||
// makeMemoryStorage — tiny StorageLike for tests / SSR fallback.
|
||||
// Not used by the runtime page (which mounts real localStorage), but
|
||||
// kept here so test files have one well-known import.
|
||||
export function makeMemoryStorage(): StorageLike {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k, v) => { store.set(k, v); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function Header({ showLogout }: HeaderProps): string {
|
||||
{showLogout && (
|
||||
<Fragment>
|
||||
<a href="/tools/kostenrechner" className="nav-link" data-i18n="nav.kostenrechner">Kostenrechner</a>
|
||||
<a href="/tools/fristenrechner" className="nav-link" data-i18n="nav.fristenrechner">Fristenrechner</a>
|
||||
<a href="/tools/procedures" className="nav-link" data-i18n="nav.procedures">Verfahren & Fristen</a>
|
||||
<a href="/logout" className="nav-logout" data-i18n="nav.logout">Abmelden</a>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
@@ -177,8 +177,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
brief: calculators first, then reference (Checklisten /
|
||||
Gerichte / Glossar), then content (Links / Downloads). */}
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/tools/procedures", ICON_BOOK_OPEN, "nav.procedures", "Verfahren & Fristen", currentPath) +
|
||||
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
|
||||
@@ -1,666 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
interface ProceedingDef {
|
||||
code: string;
|
||||
i18nKey: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function proceedingBtn(p: ProceedingDef): string {
|
||||
return (
|
||||
<button type="button" className="proceeding-btn" data-code={p.code}>
|
||||
<strong data-i18n={p.i18nKey}>{p.name}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Quick-pick chip definition. Each chip targets ONE deadline_concepts
|
||||
// slug — clicking sets the search query to the concept's name in the
|
||||
// active language so trigram search lands on the right concept card.
|
||||
// Single source of truth for both fork-shortcut and B2-search-bar
|
||||
// chip rows. Dedup invariant: no two chips share a slug. Label flips
|
||||
// per language via the chip wiring in client/fristenrechner.ts.
|
||||
interface QuickChip {
|
||||
slug: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
}
|
||||
|
||||
const QUICK_CHIPS: QuickChip[] = [
|
||||
{ slug: "statement-of-defence", name_de: "Klageerwiderung", name_en: "Statement of Defence" },
|
||||
{ slug: "notice-of-appeal", name_de: "Berufungsschrift", name_en: "Notice of Appeal" },
|
||||
{ slug: "opposition", name_de: "Einspruchsfrist", name_en: "Opposition" },
|
||||
{ slug: "reply-to-defence", name_de: "Replik", name_en: "Reply to Defence" },
|
||||
{ slug: "nichtzulassungsbeschwerde", name_de: "Nichtzulassungsbeschwerde", name_en: "Non-admission Appeal (NZB)" },
|
||||
{ slug: "application-for-determination-of-damages",name_de: "Antrag auf Schadensbemessung", name_en: "Application for Determination of Damages" },
|
||||
{ slug: "wiedereinsetzung", name_de: "Wiedereinsetzung", name_en: "Re-establishment of Rights" },
|
||||
];
|
||||
|
||||
function quickChip(c: QuickChip): string {
|
||||
return (
|
||||
<button type="button" className="fristen-search-chip"
|
||||
data-chip-slug={c.slug}
|
||||
data-chip-name-de={c.name_de}
|
||||
data-chip-name-en={c.name_en}
|
||||
data-q={c.name_de}>
|
||||
{c.name_de}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Ma\u00dfnahmen" },
|
||||
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
|
||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
|
||||
// so a user scanning the picker sees the instance-and-role at a glance
|
||||
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
|
||||
// verfahren". Sub-group headers convey the type grouping. Combined-
|
||||
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
|
||||
const DE_INF_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
|
||||
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
|
||||
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
|
||||
];
|
||||
|
||||
const DE_NULL_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
|
||||
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
|
||||
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
|
||||
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
|
||||
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
export function renderFristenrechner(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="deadlines.title">Fristenrechner — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/tools/fristenrechner" />
|
||||
<BottomNav currentPath="/tools/fristenrechner" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="deadlines.heading">Fristenrechner</h1>
|
||||
<p className="tool-subtitle" data-i18n="deadlines.subtitle">
|
||||
Berechnung von Verfahrensfristen für UPC-, deutsche und EPA-Verfahren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-323 Slice S2 — overhaul result view mount root.
|
||||
Hidden by default; the client module shows this and hides
|
||||
the legacy panels when `?overhaul=1` is present in the
|
||||
URL. Deep-linkable on its own via
|
||||
`?overhaul=1&event=<code>&trigger_date=…`. Mode A (S3)
|
||||
and Mode B wizard (S4) will land users on this surface
|
||||
once they identify a trigger procedural_event. */}
|
||||
<div className="fristen-overhaul-root" id="fristen-overhaul-root" hidden></div>
|
||||
|
||||
{/* m's 2026-05-08 18:08 Determinator redesign — Step 1: pick the
|
||||
Akte (project) that scopes the rest of the flow. Filtered
|
||||
list of visible projects + "Neue Akte anlegen" link +
|
||||
four ad-hoc explore-mode chips for users who just want to
|
||||
look up a rule without saving anywhere. */}
|
||||
<div className="fristen-step1" id="fristen-step1" role="group" aria-label="Akte picker">
|
||||
<h2 className="fristen-step-heading" data-i18n="deadlines.step1.heading">
|
||||
Schritt 1 — Welche Akte?
|
||||
</h2>
|
||||
<div className="fristen-step1-search-row">
|
||||
<svg className="fristen-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input type="search" id="fristen-akte-search"
|
||||
className="fristen-akte-search" autocomplete="off"
|
||||
data-i18n-placeholder="deadlines.step1.search.placeholder"
|
||||
placeholder="Akte suchen…" />
|
||||
</div>
|
||||
<ul className="fristen-akte-list" id="fristen-akte-list" role="listbox" aria-label="Akten"></ul>
|
||||
|
||||
<div className="fristen-step1-divider">
|
||||
<span data-i18n="deadlines.step1.divider.new">oder eine neue Akte</span>
|
||||
</div>
|
||||
{/* return-bounce: projects-new.ts honours ?return= and
|
||||
redirects back to /tools/fristenrechner?project=<new_uuid>
|
||||
so the new Akte preselects itself in Step 1. */}
|
||||
<a href="/projects/new?return=/tools/fristenrechner" className="fristen-step1-new" id="fristen-step1-new"
|
||||
data-i18n="deadlines.step1.new.cta">
|
||||
+ Neue Akte anlegen
|
||||
</a>
|
||||
|
||||
<div className="fristen-step1-divider">
|
||||
<span data-i18n="deadlines.step1.divider.adhoc">oder ad-hoc, ohne Akte</span>
|
||||
</div>
|
||||
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
|
||||
data-i18n="deadlines.step1.adhoc.upc">
|
||||
UPC proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
|
||||
data-i18n="deadlines.step1.adhoc.de">
|
||||
DE proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
|
||||
data-i18n="deadlines.step1.adhoc.epa">
|
||||
EPA proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
|
||||
data-i18n="deadlines.step1.adhoc.dpma">
|
||||
DPMA proceeding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1 collapsed summary, shown after a pick. Mirrors the
|
||||
proceeding-summary collapse pattern from 097e21c. */}
|
||||
<div className="fristen-step1-summary" id="fristen-step1-summary" style="display:none" role="group">
|
||||
<span className="fristen-step1-summary-label" data-i18n="deadlines.step1.selected">Akte:</span>
|
||||
<strong className="fristen-step1-summary-name" id="fristen-step1-summary-name">—</strong>
|
||||
<span className="fristen-step1-summary-meta" id="fristen-step1-summary-meta"></span>
|
||||
<button type="button" className="fristen-step1-summary-reselect" id="fristen-step1-summary-reselect"
|
||||
data-i18n="deadlines.step1.reselect">
|
||||
Andere Akte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step 2 — Do / Happened bifurcation. Hidden until Step 1 is
|
||||
satisfied. Click on a card routes to the existing Pathway A
|
||||
(Verfahrensablauf wizard) or Pathway B (cascade) shells —
|
||||
we keep the routing primitive in showPathway()/showBMode(). */}
|
||||
<div className="fristen-step2" id="fristen-step2" hidden>
|
||||
<h2 className="fristen-step-heading" data-i18n="deadlines.step2.heading">
|
||||
Schritt 2 — Was möchten Sie tun?
|
||||
</h2>
|
||||
<div className="fristen-step2-cards">
|
||||
<button type="button" className="fristen-step2-card" data-action="file" id="fristen-step2-file">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">✏️</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.file.title">
|
||||
Etwas einreichen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.file.desc">
|
||||
Outgoing — eine Frist tritt aus eigener Handlung ein.
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card" data-action="happened" id="fristen-step2-happened">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📥</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.happened.title">
|
||||
Etwas ist passiert
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.happened.desc">
|
||||
Incoming — ein Ereignis hat eine Frist ausgelöst.
|
||||
</span>
|
||||
</button>
|
||||
{/* t-paliad-179 Slice 1: the third "Verfahrensablauf
|
||||
einsehen" card retired — abstract-browse intent now
|
||||
owns its own route at /tools/verfahrensablauf. */}
|
||||
</div>
|
||||
<div className="fristen-step2-shortcut">
|
||||
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
|
||||
oder direkt zu einer Frist springen:
|
||||
</div>
|
||||
<div className="fristen-search-chips" id="fristen-fork-chips" role="group" aria-label="Schnellzugriff">
|
||||
{QUICK_CHIPS.map((c) => quickChip(c))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pathway B container — search bar relocates here from the page top.
|
||||
Mode toggle (B1 tree / B2 filter) sits above the panels.
|
||||
Hidden until ?path=b. */}
|
||||
<div className="fristen-pathway-shell" id="fristen-pathway-b" data-path="b" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-pathway-b-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">📅</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
|
||||
</h2>
|
||||
|
||||
{/* B1 panel — row-stack cascade.
|
||||
`#fristen-row-stack` hosts the perspective / inbox /
|
||||
cascade rows (t-paliad-180 Slice 1; t-paliad-197 Slice 2
|
||||
added project-driven prefills + auto-walk). The
|
||||
stack-header above carries the inline-search trigger
|
||||
(t-paliad-198 Slice 3 — clicking expands
|
||||
`#fristen-row-search-panel` over the row stack instead
|
||||
of routing to the legacy B2 surface) and the reset link.
|
||||
`#fristen-b1-results` is unchanged — it renders concept
|
||||
cards for both cascade-narrowing AND inline-search
|
||||
results, so users see the same card layout regardless
|
||||
of how they reached a deadline rule. */}
|
||||
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
|
||||
<div className="fristen-row-stack-header" id="fristen-row-stack-header">
|
||||
<button type="button" className="fristen-row-search-link" id="fristen-row-search-link"
|
||||
data-i18n-title="deadlines.row.search.link.title"
|
||||
aria-expanded="false"
|
||||
aria-controls="fristen-row-search-panel"
|
||||
title="Direkt nach einer Frist suchen">
|
||||
<span aria-hidden="true">🔍</span>{" "}
|
||||
<span data-i18n="deadlines.row.search.link">Direkt suchen</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-row-reset-link" id="fristen-row-reset"
|
||||
data-i18n-title="deadlines.row.reset.title"
|
||||
title="Pfad zurücksetzen — alle Cascade-Antworten verwerfen">
|
||||
<span aria-hidden="true">↺</span>{" "}
|
||||
<span data-i18n="deadlines.row.reset">Pfad zurücksetzen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline search overlay (t-paliad-198 Slice 3). Hidden by
|
||||
default; the search icon-button in the stack header
|
||||
toggles it open / closed. While open, the row stack is
|
||||
hidden and the search input drives `#fristen-b1-results`
|
||||
directly — same surface the cascade leaf populates so
|
||||
the user sees one consistent concept-card list. */}
|
||||
<div className="fristen-row-search-panel" id="fristen-row-search-panel" hidden role="search">
|
||||
<button type="button" className="fristen-row-search-panel-back" id="fristen-row-search-panel-back"
|
||||
data-i18n-title="deadlines.row.search.panel.back.title"
|
||||
title="Zurück zum Entscheidungsbaum">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.row.search.panel.back">Zurück zum Entscheidungsbaum</span>
|
||||
</button>
|
||||
<div className="fristen-row-search-panel-input-wrap">
|
||||
<svg className="fristen-row-search-panel-icon" width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="fristen-row-search-panel-input"
|
||||
className="fristen-row-search-panel-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.row.search.panel.placeholder"
|
||||
placeholder="Frist suchen…"
|
||||
aria-label="Frist suchen"
|
||||
/>
|
||||
<button type="button" className="fristen-row-search-panel-clear" id="fristen-row-search-panel-clear"
|
||||
data-i18n-title="deadlines.row.search.panel.clear" title="Eingabe leeren" hidden>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fristen-row-stack" id="fristen-row-stack" aria-live="polite"></div>
|
||||
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
{/* B2 panel — search bar + chips + concept-card results.
|
||||
The search input + chips + results host live here so
|
||||
fristenrechner.ts can drive both Phase D (today) and the
|
||||
B1↔B2 state-share in Phase D (forum filter). */}
|
||||
<div className="fristen-b2-panel" id="fristen-b2-panel" data-mode="filter">
|
||||
<div className="fristen-search">
|
||||
<label htmlFor="fristen-search-input" className="visually-hidden" data-i18n="deadlines.search.label">Frist suchen</label>
|
||||
<div className="fristen-search-row">
|
||||
<svg className="fristen-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="fristen-search-input"
|
||||
className="fristen-search-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.search.placeholder"
|
||||
placeholder="Klageerwiderung, RoP 23, § 82, Wiedereinsetzung…"
|
||||
/>
|
||||
<button type="button" id="fristen-search-clear" className="fristen-search-clear" aria-label="Suche leeren" data-i18n-aria-label="deadlines.search.clear" hidden>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="fristen-search-chips" id="fristen-search-chips" role="group" aria-label="Schnellzugriff">
|
||||
<span className="fristen-search-chips-label" data-i18n="deadlines.search.chips.label">Schnellzugriff:</span>
|
||||
{QUICK_CHIPS.map((c) => quickChip(c))}
|
||||
</div>
|
||||
{/* Forum filter row — populated by Phase D. */}
|
||||
<div className="fristen-forum-filter" id="fristen-forum-filter" hidden>
|
||||
<span className="fristen-forum-filter-label" data-i18n="deadlines.filter.forum.label">Gericht / System:</span>
|
||||
<div className="fristen-forum-chips" id="fristen-forum-chips"></div>
|
||||
</div>
|
||||
<div id="fristen-search-results" className="fristen-search-results" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3a — outgoing-intent chooser. Reached when the user
|
||||
picks "Etwas einreichen" on Step 2. Three options per
|
||||
m's 2026-05-08 18:09 spec: File (drives the Pathway A
|
||||
wizard), Draft (future drafting surface; v1
|
||||
placeholder), Enter (routes to the existing manual-
|
||||
create form). */}
|
||||
<div className="fristen-pathway-shell" id="fristen-step3a" data-path="outgoing" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-step3a-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.step3a.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">✏️</span>{" "}
|
||||
<span data-i18n="deadlines.step3a.heading">Was möchten Sie einreichen?</span>
|
||||
</h2>
|
||||
<div className="fristen-step2-cards">
|
||||
<button type="button" className="fristen-step2-card" id="fristen-step3a-file" data-action="file">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📝</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.file.title">
|
||||
Schriftsatz einreichen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.file.desc">
|
||||
Verfahrensablauf laden — Frist berechnen und zur Akte hinzufügen.
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card fristen-step2-card--soon" id="fristen-step3a-draft" data-action="draft" disabled
|
||||
data-i18n-title="deadlines.step3a.soon">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">🖉</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.draft.title">
|
||||
Schriftsatz entwerfen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.draft.desc">
|
||||
Vorbereitung — später mit Drafting-Surface verknüpft.
|
||||
</span>
|
||||
<span className="fristen-step2-card-soon" data-i18n="deadlines.step3a.soon">kommt bald</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card" id="fristen-step3a-enter" data-action="enter">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">💾</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.enter.title">
|
||||
Frist manuell erfassen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.enter.desc">
|
||||
Direkt eintragen — bereits bekanntes Datum / bekannter Typ.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pathway A container — wraps the existing wizard.
|
||||
Hidden until ?path=a. */}
|
||||
<div className="fristen-pathway-shell" id="fristen-pathway-a" data-path="a" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-pathway-a-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">📖</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.a.title">Verfahrensablauf informieren</span>
|
||||
</h2>
|
||||
|
||||
{/* v3: legacy mode tabs retired (m's spec lock §10 Q1, 2026-05-05).
|
||||
Pathway A is Verfahrensablauf-only; trigger-event drill-in
|
||||
surfaces via concept-card pills with ?path=a&trigger=N URL,
|
||||
which resurfaces mode-event-panel programmatically below. */}
|
||||
<div className="fristen-wizard mode-panel" id="mode-procedure-panel" data-mode="procedure" role="tabpanel">
|
||||
<div className="wizard-step" id="step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* m's 2026-05-08 18:26: collapse the proceeding picker once
|
||||
a choice is made; this summary line replaces the four
|
||||
group blocks with a one-line "Selected: X [Reselect]"
|
||||
affordance. JS toggles `.proceeding-summary` visibility
|
||||
in lockstep with `.proceeding-group` blocks. */}
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
|
||||
</h3>
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
|
||||
<input type="date" id="trigger-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<div className="date-field-row" id="court-picker-row" style="display:none">
|
||||
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
|
||||
<select id="court-picker" className="date-input"></select>
|
||||
</div>
|
||||
<div className="date-field-row" id="priority-date-row" style="display:none">
|
||||
<label htmlFor="priority-date" className="date-label" data-i18n="deadlines.priority.date">Prioritätstag (optional):</label>
|
||||
<input type="date" id="priority-date" className="date-input" />
|
||||
</div>
|
||||
<div className="date-field-row" id="ccr-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="ccr-flag" />
|
||||
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="inf-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patentänderung (R.30)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patentänderung (R.49.2.a)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-cci-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
</div>
|
||||
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="fristen-save-cta" className="btn-primary btn-cta-lime" style="display:none" data-i18n="deadlines.save.cta">
|
||||
Als Frist(en) speichern
|
||||
</button>
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="reset-btn" className="reset-btn" style="display:none" data-i18n="deadlines.reset">
|
||||
← Neu berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="fristen-wizard mode-panel" id="mode-event-panel" data-mode="event" role="tabpanel" hidden>
|
||||
<div className="wizard-step" id="event-step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.event.step1">Trigger-Ereignis wählen</span>
|
||||
</h3>
|
||||
<p className="wizard-step-hint" data-i18n="deadlines.event.step1.hint">
|
||||
Welches Ereignis ist eingetreten? (z.B. Klageerhebung, Entscheidung des EPA, Zustellung einer Verfügung)
|
||||
</p>
|
||||
<div className="event-picker-row">
|
||||
<label htmlFor="event-search" className="visually-hidden" data-i18n="deadlines.event.search.label">Trigger-Ereignis suchen</label>
|
||||
<input
|
||||
type="search"
|
||||
id="event-search"
|
||||
className="event-search-input"
|
||||
autocomplete="off"
|
||||
data-i18n-placeholder="deadlines.event.search.placeholder"
|
||||
placeholder="Tippe, um zu suchen…"
|
||||
/>
|
||||
<ul id="event-list" className="event-list" role="listbox" aria-label="Trigger-Ereignisse"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="event-step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.event.step2">Datum des Ereignisses</span>
|
||||
</h3>
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
<label className="date-label" data-i18n="deadlines.event.selected">Gewähltes Ereignis:</label>
|
||||
<span id="event-selected-name" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="event-date" className="date-label" data-i18n="deadlines.event.date">Eintrittsdatum:</label>
|
||||
<input type="date" id="event-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<button type="button" id="event-calculate-btn" className="calculate-btn" data-i18n="deadlines.event.calculate">
|
||||
Folgefristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="event-step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.event.step3">Folgefristen</span>
|
||||
</h3>
|
||||
<div id="event-results-container"></div>
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="event-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="event-reset-btn" className="reset-btn" style="display:none" data-i18n="deadlines.reset">
|
||||
← Neu berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>{/* /pathway-a */}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/fristenrechner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -728,6 +728,138 @@ export type I18nKey =
|
||||
| "bottomnav.add.title"
|
||||
| "bottomnav.badge.deadlines"
|
||||
| "bottomnav.menu"
|
||||
| "builder.action.promote"
|
||||
| "builder.action.rename"
|
||||
| "builder.action.rename.prompt"
|
||||
| "builder.action.share"
|
||||
| "builder.akte.banner.prefix"
|
||||
| "builder.akte.none"
|
||||
| "builder.bucket.active"
|
||||
| "builder.bucket.archived"
|
||||
| "builder.bucket.empty"
|
||||
| "builder.bucket.promoted"
|
||||
| "builder.bucket.shared"
|
||||
| "builder.canvas.add_proceeding"
|
||||
| "builder.empty.cta"
|
||||
| "builder.empty.headline"
|
||||
| "builder.empty.hint"
|
||||
| "builder.empty.recent"
|
||||
| "builder.event.action.file"
|
||||
| "builder.event.action.reset"
|
||||
| "builder.event.action.skip"
|
||||
| "builder.event.actual_date.prompt"
|
||||
| "builder.event.horizon.hide"
|
||||
| "builder.event.horizon.label"
|
||||
| "builder.event.skip_reason.prompt"
|
||||
| "builder.event.state.filed"
|
||||
| "builder.event.state.planned"
|
||||
| "builder.event.state.skipped"
|
||||
| "builder.header.akte"
|
||||
| "builder.header.scenario"
|
||||
| "builder.header.search"
|
||||
| "builder.header.stichtag"
|
||||
| "builder.mobile.blocked"
|
||||
| "builder.mode.akte"
|
||||
| "builder.mode.cold"
|
||||
| "builder.mode.event"
|
||||
| "builder.panel.empty"
|
||||
| "builder.panel.new"
|
||||
| "builder.panel.title"
|
||||
| "builder.picker.aria"
|
||||
| "builder.picker.axis.forum"
|
||||
| "builder.picker.axis.proc"
|
||||
| "builder.picker.close"
|
||||
| "builder.picker.empty"
|
||||
| "builder.picker.future_jurisdiction"
|
||||
| "builder.picker.placeholder"
|
||||
| "builder.picker.title"
|
||||
| "builder.promote.back"
|
||||
| "builder.promote.cancel"
|
||||
| "builder.promote.commit"
|
||||
| "builder.promote.error.generic"
|
||||
| "builder.promote.error.title_required"
|
||||
| "builder.promote.meta.case_number"
|
||||
| "builder.promote.meta.client_number"
|
||||
| "builder.promote.meta.our_side"
|
||||
| "builder.promote.meta.our_side.claimant"
|
||||
| "builder.promote.meta.our_side.defendant"
|
||||
| "builder.promote.meta.our_side.none"
|
||||
| "builder.promote.meta.parent"
|
||||
| "builder.promote.meta.parent.none"
|
||||
| "builder.promote.meta.reference"
|
||||
| "builder.promote.meta.team"
|
||||
| "builder.promote.meta.team.hint"
|
||||
| "builder.promote.meta.title"
|
||||
| "builder.promote.meta.title.placeholder"
|
||||
| "builder.promote.next"
|
||||
| "builder.promote.parties.add"
|
||||
| "builder.promote.parties.empty"
|
||||
| "builder.promote.parties.hint"
|
||||
| "builder.promote.parties.name"
|
||||
| "builder.promote.parties.remove"
|
||||
| "builder.promote.parties.representative"
|
||||
| "builder.promote.parties.role"
|
||||
| "builder.promote.step1"
|
||||
| "builder.promote.step2"
|
||||
| "builder.promote.step3"
|
||||
| "builder.promote.success"
|
||||
| "builder.promote.summary.events_filed"
|
||||
| "builder.promote.summary.events_planned"
|
||||
| "builder.promote.summary.flags"
|
||||
| "builder.promote.summary.heading"
|
||||
| "builder.promote.summary.note_extra"
|
||||
| "builder.promote.summary.proceeding"
|
||||
| "builder.promote.title"
|
||||
| "builder.readonly.blocked"
|
||||
| "builder.readonly.watermark"
|
||||
| "builder.save.error"
|
||||
| "builder.save.idle"
|
||||
| "builder.save.saved"
|
||||
| "builder.save.saving"
|
||||
| "builder.search.anchor.divider"
|
||||
| "builder.search.group.events"
|
||||
| "builder.search.group.projects"
|
||||
| "builder.search.group.scenarios"
|
||||
| "builder.search.hint.akte_b4"
|
||||
| "builder.search.hint.empty"
|
||||
| "builder.search.hint.error"
|
||||
| "builder.search.hint.loading"
|
||||
| "builder.search.hint.short"
|
||||
| "builder.search.hint.start"
|
||||
| "builder.search.placeholder"
|
||||
| "builder.search.summary.events.one"
|
||||
| "builder.search.summary.events.other"
|
||||
| "builder.search.summary.projects.one"
|
||||
| "builder.search.summary.projects.other"
|
||||
| "builder.search.summary.scenarios.one"
|
||||
| "builder.search.summary.scenarios.other"
|
||||
| "builder.share.button"
|
||||
| "builder.share.close"
|
||||
| "builder.share.current.empty"
|
||||
| "builder.share.current.title"
|
||||
| "builder.share.error"
|
||||
| "builder.share.no_results"
|
||||
| "builder.share.revoke"
|
||||
| "builder.share.search.placeholder"
|
||||
| "builder.share.subtitle"
|
||||
| "builder.share.title"
|
||||
| "builder.subtitle"
|
||||
| "builder.triplet.collapse"
|
||||
| "builder.triplet.detailgrad.all_options"
|
||||
| "builder.triplet.detailgrad.label"
|
||||
| "builder.triplet.detailgrad.selected"
|
||||
| "builder.triplet.expand"
|
||||
| "builder.triplet.flags.label"
|
||||
| "builder.triplet.loading"
|
||||
| "builder.triplet.no_flags"
|
||||
| "builder.triplet.perspective.claimant"
|
||||
| "builder.triplet.perspective.defendant"
|
||||
| "builder.triplet.perspective.label"
|
||||
| "builder.triplet.perspective.none"
|
||||
| "builder.triplet.remove"
|
||||
| "builder.triplet.side.claimant"
|
||||
| "builder.triplet.side.defendant"
|
||||
| "builder.triplet.unknown_proceeding"
|
||||
| "cal.day.back_to_month"
|
||||
| "cal.day.fri"
|
||||
| "cal.day.mon"
|
||||
@@ -1246,6 +1378,8 @@ export type I18nKey =
|
||||
| "deadlines.de.inf.olg"
|
||||
| "deadlines.de.null.bgh"
|
||||
| "deadlines.de.null.bpatg"
|
||||
| "deadlines.detail.all_options"
|
||||
| "deadlines.detail.aufnehmen"
|
||||
| "deadlines.detail.back"
|
||||
| "deadlines.detail.cancel"
|
||||
| "deadlines.detail.complete"
|
||||
@@ -1259,12 +1393,17 @@ export type I18nKey =
|
||||
| "deadlines.detail.delete.confirm.title"
|
||||
| "deadlines.detail.due"
|
||||
| "deadlines.detail.edit"
|
||||
| "deadlines.detail.entfernen"
|
||||
| "deadlines.detail.label"
|
||||
| "deadlines.detail.loading"
|
||||
| "deadlines.detail.mandatory_only"
|
||||
| "deadlines.detail.notes"
|
||||
| "deadlines.detail.notfound"
|
||||
| "deadlines.detail.optional_unselected_hint"
|
||||
| "deadlines.detail.reopen"
|
||||
| "deadlines.detail.rule"
|
||||
| "deadlines.detail.save"
|
||||
| "deadlines.detail.selected"
|
||||
| "deadlines.detail.source"
|
||||
| "deadlines.detail.title"
|
||||
| "deadlines.dpma"
|
||||
@@ -1378,6 +1517,8 @@ export type I18nKey =
|
||||
| "deadlines.notes.show"
|
||||
| "deadlines.optional.badge"
|
||||
| "deadlines.overhaul.condition.badge"
|
||||
| "deadlines.overhaul.crossparty.badge"
|
||||
| "deadlines.overhaul.crossparty.tooltip"
|
||||
| "deadlines.overhaul.edit_date.label"
|
||||
| "deadlines.overhaul.edit_date.title"
|
||||
| "deadlines.overhaul.empty"
|
||||
@@ -2062,7 +2203,6 @@ export type I18nKey =
|
||||
| "nav.downloads"
|
||||
| "nav.einstellungen"
|
||||
| "nav.fristen"
|
||||
| "nav.fristenrechner"
|
||||
| "nav.gebuehrentabellen"
|
||||
| "nav.gerichte"
|
||||
| "nav.glossar"
|
||||
@@ -2079,13 +2219,13 @@ export type I18nKey =
|
||||
| "nav.logout"
|
||||
| "nav.neuigkeiten"
|
||||
| "nav.paliadin"
|
||||
| "nav.procedures"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.submissions"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
| "nav.verfahrensablauf"
|
||||
| "notes.cancel"
|
||||
| "notes.delete"
|
||||
| "notes.delete.confirm"
|
||||
@@ -2195,6 +2335,19 @@ export type I18nKey =
|
||||
| "partner_unit.members_label"
|
||||
| "partner_unit.none"
|
||||
| "partner_unit.subtitle"
|
||||
| "procedures.filter.axis.forum"
|
||||
| "procedures.filter.axis.kind"
|
||||
| "procedures.filter.axis.party"
|
||||
| "procedures.filter.axis.proc"
|
||||
| "procedures.filter.search.placeholder"
|
||||
| "procedures.heading"
|
||||
| "procedures.panel.akte.placeholder"
|
||||
| "procedures.subtitle"
|
||||
| "procedures.tab.akte"
|
||||
| "procedures.tab.proceeding"
|
||||
| "procedures.tab.search"
|
||||
| "procedures.tab.wizard"
|
||||
| "procedures.title"
|
||||
| "project.instance_level.appeal"
|
||||
| "project.instance_level.cassation"
|
||||
| "project.instance_level.first"
|
||||
@@ -2689,6 +2842,9 @@ export type I18nKey =
|
||||
| "submissions.draft.base.hint"
|
||||
| "submissions.draft.base.label"
|
||||
| "submissions.draft.import.button"
|
||||
| "submissions.draft.keyword.hint"
|
||||
| "submissions.draft.keyword.label"
|
||||
| "submissions.draft.keyword.placeholder"
|
||||
| "submissions.draft.language"
|
||||
| "submissions.draft.language.de"
|
||||
| "submissions.draft.language.en"
|
||||
@@ -2788,15 +2944,24 @@ export type I18nKey =
|
||||
| "team.selection.toggle_card"
|
||||
| "team.subtitle"
|
||||
| "team.title"
|
||||
| "templates.authoring.heading"
|
||||
| "templates.authoring.intro"
|
||||
| "templates.authoring.list.title"
|
||||
| "templates.authoring.slots.title"
|
||||
| "templates.authoring.title"
|
||||
| "templates.authoring.upload.file"
|
||||
| "templates.authoring.upload.firm"
|
||||
| "templates.authoring.upload.name_de"
|
||||
| "templates.authoring.upload.name_en"
|
||||
| "templates.authoring.upload.submit"
|
||||
| "templates.authoring.upload.title"
|
||||
| "templates.authoring.workspace.hint"
|
||||
| "theme.toggle.auto"
|
||||
| "theme.toggle.cycle.auto"
|
||||
| "theme.toggle.cycle.dark"
|
||||
| "theme.toggle.cycle.light"
|
||||
| "theme.toggle.dark"
|
||||
| "theme.toggle.light"
|
||||
| "tools.verfahrensablauf.heading"
|
||||
| "tools.verfahrensablauf.subtitle"
|
||||
| "tools.verfahrensablauf.title"
|
||||
| "unit_role.attorney"
|
||||
| "unit_role.lead"
|
||||
| "unit_role.pa"
|
||||
|
||||
@@ -74,7 +74,7 @@ export function renderIndex(): string {
|
||||
<p data-i18n="index.cost.desc">Schätzung der Verfahrenskosten für DE-Gerichte, UPC und EPA-Verfahren. Gerichts- und Anwaltskosten auf einen Blick.</p>
|
||||
</a>
|
||||
|
||||
<a href="/tools/fristenrechner" className="card card-link">
|
||||
<a href="/tools/procedures" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_CLOCK }} />
|
||||
<h2 data-i18n="index.deadline.title">Fristenrechner</h2>
|
||||
<p data-i18n="index.deadline.desc">Berechnung von Verfahrensfristen für UPC-, deutsche und EPA-Verfahren mit Feiertags-Anpassung.</p>
|
||||
|
||||
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal file
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// docforge-editor — the variable catalogue client.
|
||||
//
|
||||
// The catalogue (key + bilingual label + namespace group) is served by the
|
||||
// Go backend at GET /api/docforge/variables, built from the resolvers'
|
||||
// Keys() as the single source of truth. A consumer fetches it once and uses
|
||||
// labelMap() to label its sidebar form + authoring palette, instead of
|
||||
// hard-coding a parallel label table that can drift from the resolvers.
|
||||
|
||||
export interface VariableEntry {
|
||||
key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface VariablesResponse {
|
||||
variables: VariableEntry[];
|
||||
}
|
||||
|
||||
// fetchVariableCatalogue loads the catalogue from the backend. Throws on a
|
||||
// non-2xx response so the caller can decide how to degrade.
|
||||
export async function fetchVariableCatalogue(): Promise<VariableEntry[]> {
|
||||
const res = await fetch("/api/docforge/variables", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`docforge variables: HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as VariablesResponse;
|
||||
return body.variables ?? [];
|
||||
}
|
||||
|
||||
// labelMap turns a catalogue into a key → {de, en} lookup for a label
|
||||
// function. Keys absent from the map fall back to the raw key at the call
|
||||
// site, so a failed fetch degrades to dotted-key labels rather than a
|
||||
// broken form.
|
||||
export function labelMap(catalogue: VariableEntry[]): Record<string, { de: string; en: string }> {
|
||||
const out: Record<string, { de: string; en: string }> = {};
|
||||
for (const e of catalogue) {
|
||||
out[e.key] = { de: e.label_de, en: e.label_en };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal file
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { escapeHtml, cssEscape } from "./dom";
|
||||
|
||||
test("escapeHtml escapes the five HTML-significant characters", () => {
|
||||
expect(escapeHtml(`<a href="x" title='y'>& z</a>`)).toBe(
|
||||
"<a href="x" title='y'>& z</a>",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapeHtml is a no-op on plain text", () => {
|
||||
expect(escapeHtml("Aktenzeichen 4c O 12/23")).toBe("Aktenzeichen 4c O 12/23");
|
||||
});
|
||||
|
||||
test("escapeHtml escapes & first to avoid double-encoding", () => {
|
||||
expect(escapeHtml("<")).toBe("&lt;");
|
||||
});
|
||||
|
||||
test("cssEscape backslash-escapes the dots in a placeholder key", () => {
|
||||
// Both CSS.escape and the regex fallback escape '.' the same way, so the
|
||||
// result is stable across environments (bun has no CSS global → fallback).
|
||||
expect(cssEscape("project.case_number")).toBe("project\\.case_number");
|
||||
});
|
||||
|
||||
test("cssEscape leaves identifier-safe characters untouched", () => {
|
||||
expect(cssEscape("today")).toBe("today");
|
||||
});
|
||||
32
frontend/src/lib/docforge-editor/dom.ts
Normal file
32
frontend/src/lib/docforge-editor/dom.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// docforge-editor — shared, framework-agnostic editor utilities.
|
||||
//
|
||||
// Slice 5 of the docforge train (t-paliad-349 / m/paliad#157) begins
|
||||
// extracting the generic editor plumbing out of the submission-specific
|
||||
// client bundle so a second consumer (and the slice-6 authoring page) can
|
||||
// reuse it. This module holds the pure DOM-string helpers — no DOM
|
||||
// mutation, no editor state — so they unit-test cleanly under bun.
|
||||
|
||||
// escapeHtml escapes the five HTML-significant characters for safe
|
||||
// insertion into element text or an attribute value. Matches the
|
||||
// server-side emitTextWithDraftVars/htmlEscape contract so preview markup
|
||||
// round-trips identically.
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// cssEscape escapes a string for use inside a CSS attribute selector
|
||||
// (e.g. `[data-var="${cssEscape(key)}"]`). Prefers the native CSS.escape
|
||||
// and falls back to escaping CSS-special characters for older runtimes.
|
||||
// Placeholder keys ([A-Za-z][A-Za-z0-9_.]*) never carry whitespace or
|
||||
// quotes, so the fallback is straightforward.
|
||||
export function cssEscape(s: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
192
frontend/src/procedures.tsx
Normal file
192
frontend/src/procedures.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /tools/procedures — Litigation Builder (m/paliad#153 PRD §3).
|
||||
//
|
||||
// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed
|
||||
// builder shell. Server-rendered chrome is minimal — the page-header
|
||||
// scenario picker, side panel, and canvas are all hydrated by
|
||||
// `builder.ts` at boot. The builder loads scenarios from
|
||||
// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the
|
||||
// per-proceeding triplets with the existing verfahrensablauf-core calc.
|
||||
//
|
||||
// B1 — Builder shell + cold-open mode + single triplet end-to-end.
|
||||
// B2 — Multi-triplet stack + spawn nesting + per-event state machine.
|
||||
// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated).
|
||||
|
||||
export function renderProcedures(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="procedures.title">Verfahren & Fristen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-procedures page-builder">
|
||||
<Sidebar currentPath="/tools/procedures" />
|
||||
<BottomNav currentPath="/tools/procedures" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page builder-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="procedures.heading">Verfahren & Fristen</h1>
|
||||
<p className="tool-subtitle" data-i18n="builder.subtitle">
|
||||
Litigation Builder — Szenarien bauen, Verfahren stapeln, Fristen behalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Page header (PRD §3.1): scenario picker · save state · name · share · promote
|
||||
· Akte picker · Stichtag input. B1 wires the scenario picker
|
||||
+ name action + Stichtag + save indicator. Akte / share /
|
||||
promote land at B4 / B5; the affordances render disabled in
|
||||
B1 so the layout is stable across slices. */}
|
||||
<section className="builder-pageheader" aria-label="Builder-Steuerung">
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.scenario">Szenario:</span>
|
||||
<select id="builder-scenario-picker" className="builder-scenario-picker" aria-label="Szenario wählen"></select>
|
||||
</label>
|
||||
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
|
||||
<span data-i18n="builder.save.idle"> </span>
|
||||
</span>
|
||||
<span className="builder-pageheader-spacer"></span>
|
||||
<button type="button" id="builder-rename-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
data-i18n="builder.action.rename">Benennen</button>
|
||||
<button type="button" id="builder-share-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
data-i18n="builder.action.share">Teilen</button>
|
||||
<button type="button" id="builder-promote-btn"
|
||||
className="builder-action-btn builder-action-btn--primary"
|
||||
disabled
|
||||
data-i18n="builder.action.promote">Als Projekt anlegen</button>
|
||||
</div>
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.akte">Akte:</span>
|
||||
<select id="builder-akte-picker" className="builder-akte-picker" disabled aria-label="Akte wählen">
|
||||
<option value="" data-i18n="builder.akte.none">— ohne —</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.stichtag">Stichtag:</span>
|
||||
<input type="date" id="builder-stichtag-input" className="builder-stichtag-input"
|
||||
defaultValue={today} aria-label="Stichtag" />
|
||||
</label>
|
||||
<label className="builder-pageheader-field builder-pageheader-field--grow">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.search">Suche:</span>
|
||||
<input type="search" id="builder-search-input" className="builder-search-input"
|
||||
data-i18n-placeholder="builder.search.placeholder"
|
||||
placeholder="Ereignis, Szenario, Akte …"
|
||||
autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Entry-mode radio (PRD §0.2, §2). B1 ships cold-open active;
|
||||
event-triggered + akte ship at B3 / B4 and are disabled
|
||||
here so the layout stays stable across slices. */}
|
||||
<nav className="builder-modebar" role="tablist" aria-label="Einstieg">
|
||||
<button type="button"
|
||||
className="builder-mode is-active"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
data-mode="cold"
|
||||
id="builder-mode-cold">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.cold">Übersicht</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-mode="event"
|
||||
id="builder-mode-event">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.event">Ereignis</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-mode="akte"
|
||||
id="builder-mode-akte">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Two-column body: side panel (left, scenarios list) + canvas (right). */}
|
||||
<div className="builder-body">
|
||||
<aside className="builder-sidepanel" aria-label="Meine Szenarien">
|
||||
<header className="builder-sidepanel-header">
|
||||
<h2 className="builder-sidepanel-title" data-i18n="builder.panel.title">Meine Szenarien</h2>
|
||||
<button type="button" id="builder-new-scenario-btn"
|
||||
className="builder-sidepanel-newbtn"
|
||||
data-i18n="builder.panel.new">+ Neues Szenario</button>
|
||||
</header>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="active">
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
|
||||
</div>
|
||||
{/* B5 — Geteilt mit mir / Als Projekt angelegt / Archiviert.
|
||||
Each bucket hides itself when empty (builder.ts toggles
|
||||
the hidden attribute). */}
|
||||
<div className="builder-sidepanel-bucket" data-bucket="shared" id="builder-bucket-shared" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.shared">Geteilt mit mir</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-shared" aria-label="Mit mir geteilte Szenarien"></ul>
|
||||
</div>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="promoted" id="builder-bucket-promoted" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.promoted">Als Projekt angelegt</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-promoted" aria-label="Promotete Szenarien"></ul>
|
||||
</div>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="archived" id="builder-bucket-archived" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.archived">Archiviert</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-archived" aria-label="Archivierte Szenarien"></ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
|
||||
{/* B5 — read-only watermark for shared / promoted scenarios.
|
||||
builder.ts fills + unhides it when the active scenario
|
||||
is not editable by the current user. */}
|
||||
<div id="builder-readonly-watermark" className="builder-readonly-watermark" hidden></div>
|
||||
<div id="builder-canvas" className="builder-canvas">
|
||||
{/* Cold-open placeholder — replaced by triplet stack once a
|
||||
scenario is loaded. */}
|
||||
<div className="builder-empty" id="builder-empty">
|
||||
<p className="builder-empty-headline" data-i18n="builder.empty.headline">
|
||||
Noch kein Szenario geöffnet.
|
||||
</p>
|
||||
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
|
||||
Starte ein neues Szenario, wähle aus deiner Liste oder übernimm eine Akte (B4).
|
||||
</p>
|
||||
<button type="button" id="builder-cta-new" className="builder-cta-new"
|
||||
data-i18n="builder.empty.cta">
|
||||
Neues Szenario starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/procedures.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -171,6 +171,33 @@ export function renderSubmissionDraft(): string {
|
||||
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
|
||||
</p>
|
||||
|
||||
{/* t-paliad-354 — keyword that leads the exported
|
||||
document name "<date> <keyword> (<case>)". Empty
|
||||
falls back to the auto-derived rule name; the
|
||||
placeholder shows that default. Persisted to
|
||||
composer_meta.filename_keyword via the draft-save
|
||||
path on change. */}
|
||||
<div className="submission-draft-keyword-row">
|
||||
<label
|
||||
htmlFor="submission-draft-keyword"
|
||||
data-i18n="submissions.draft.keyword.label">
|
||||
Stichwort (Dateiname)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="submission-draft-keyword"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.draft.keyword.placeholder"
|
||||
placeholder="Automatisch aus dem Schriftsatztyp"
|
||||
/>
|
||||
<p
|
||||
className="submission-draft-keyword-hint"
|
||||
id="submission-draft-keyword-hint"
|
||||
data-i18n="submissions.draft.keyword.hint">
|
||||
Führt den Dateinamen an: <Datum> <Stichwort> (<Aktenzeichen>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
|
||||
{/* t-paliad-277: "Aus Projekt importieren" + last-
|
||||
|
||||
112
frontend/src/templates-authoring.tsx
Normal file
112
frontend/src/templates-authoring.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-349 docforge slice 6 — template authoring page at
|
||||
// /admin/templates.
|
||||
//
|
||||
// Admin uploads a base .docx, sees it rendered as run-addressable text,
|
||||
// selects a span + a variable from the palette to drop a {{slot}}, and the
|
||||
// result saves as a reusable docforge template. Pure shell:
|
||||
// client/templates-authoring.ts hydrates the list, upload form, preview,
|
||||
// palette, and slot list after load. The palette labels come from the Go
|
||||
// variable catalogue (GET /api/docforge/variables, the SSOT from slice 5).
|
||||
//
|
||||
// Design ref: docs/plans/prd-docforge-2026-05-29.md §2.1.
|
||||
|
||||
export function renderTemplatesAuthoring(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<PWAHead />
|
||||
<title data-i18n="templates.authoring.title">Vorlagen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-templates-authoring">
|
||||
<Sidebar currentPath="/admin" />
|
||||
<BottomNav currentPath="/admin" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page docforge-templates-page">
|
||||
<div className="container">
|
||||
<header className="docforge-templates-header">
|
||||
<h1 data-i18n="templates.authoring.heading">Vorlagen</h1>
|
||||
<p
|
||||
className="docforge-templates-intro"
|
||||
data-i18n="templates.authoring.intro">
|
||||
Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Upload a new base .docx */}
|
||||
<section className="docforge-upload" id="docforge-upload">
|
||||
<h2 data-i18n="templates.authoring.upload.title">Neue Vorlage hochladen</h2>
|
||||
<form id="docforge-upload-form" className="entity-form">
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.file">Word-Datei (.docx)</span>
|
||||
<input type="file" name="file" accept=".docx,.dotx,.docm,.dotm" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.name_de">Name (DE)</span>
|
||||
<input type="text" name="name_de" className="entity-form-input" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.name_en">Name (EN)</span>
|
||||
<input type="text" name="name_en" className="entity-form-input" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.firm">Kanzlei (optional)</span>
|
||||
<input type="text" name="firm" className="entity-form-input" />
|
||||
</label>
|
||||
<button type="submit" className="btn-primary" data-i18n="templates.authoring.upload.submit">
|
||||
Hochladen
|
||||
</button>
|
||||
<span className="docforge-upload-status" id="docforge-upload-status" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Existing templates */}
|
||||
<section className="docforge-template-list-wrap">
|
||||
<h2 data-i18n="templates.authoring.list.title">Vorhandene Vorlagen</h2>
|
||||
<ul className="entity-table docforge-template-list" id="docforge-template-list" />
|
||||
</section>
|
||||
|
||||
{/* Authoring workspace — hidden until a template is opened. */}
|
||||
<section className="docforge-workspace" id="docforge-workspace" hidden>
|
||||
<header className="docforge-workspace-header">
|
||||
<h2 id="docforge-workspace-title" />
|
||||
<span className="docforge-workspace-hint" data-i18n="templates.authoring.workspace.hint">
|
||||
Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.
|
||||
</span>
|
||||
<span className="docforge-workspace-status" id="docforge-workspace-status" />
|
||||
</header>
|
||||
<div className="docforge-workspace-grid">
|
||||
{/* Variable palette (left) — populated from the catalogue. */}
|
||||
<aside className="docforge-palette" id="docforge-palette" />
|
||||
{/* Run-addressable preview (center) — selection target. */}
|
||||
<div className="docforge-preview" id="docforge-preview" />
|
||||
{/* Placed slots (right). */}
|
||||
<aside className="docforge-slots">
|
||||
<h3 data-i18n="templates.authoring.slots.title">Platzhalter</h3>
|
||||
<ul className="docforge-slot-list" id="docforge-slot-list" />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
<script src="/assets/templates-authoring.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Slice 1 (t-paliad-179) — the dedicated abstract-browse surface for
|
||||
// procedural shape. Same backend (POST /api/tools/fristenrechner) +
|
||||
// same renderer module (./client/views/verfahrensablauf-core) as
|
||||
// /tools/fristenrechner; this page strips the Step 1 Akte picker /
|
||||
// Step 2 cards / Pathway A wizard / Pathway B cascade / save modal,
|
||||
// leaving just: proceeding-type tile picker + trigger date + court
|
||||
// picker + result panel. Variant chips, lane view and compare arrive in
|
||||
// Slices 2-4.
|
||||
|
||||
interface ProceedingDef {
|
||||
code: string;
|
||||
i18nKey: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function proceedingBtn(p: ProceedingDef): string {
|
||||
return (
|
||||
<button type="button" className="proceeding-btn" data-code={p.code}>
|
||||
<strong data-i18n={p.i18nKey}>{p.name}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
|
||||
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
|
||||
// unified "Berufung" tile (upc.apl). After picking it, the user
|
||||
// selects which decision the appeal is directed AT via the
|
||||
// .appeal-target-row chip group below — the engine then filters
|
||||
// rules whose applies_to_target contains the picked slug.
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
];
|
||||
|
||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
|
||||
// so a user scanning the picker sees the instance-and-role at a glance
|
||||
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
|
||||
// verfahren". Sub-group headers convey the type grouping. Combined-
|
||||
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
|
||||
const DE_INF_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
|
||||
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
|
||||
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
|
||||
];
|
||||
|
||||
const DE_NULL_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
|
||||
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
|
||||
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
|
||||
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
|
||||
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
export function renderVerfahrensablauf(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-verfahrensablauf">
|
||||
<Sidebar currentPath="/tools/verfahrensablauf" />
|
||||
<BottomNav currentPath="/tools/verfahrensablauf" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="tools.verfahrensablauf.heading">Verfahrensablauf</h1>
|
||||
<p className="tool-subtitle" data-i18n="tools.verfahrensablauf.subtitle">
|
||||
Typischen Verfahrensablauf einsehen — Verfahrensart wählen, Datum optional setzen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Verfahrensart picker (single-tile mode — same DOM ids as
|
||||
/tools/fristenrechner so the shared renderer module and
|
||||
court-picker primitives bind without parameterisation). */}
|
||||
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
|
||||
<div className="wizard-step" id="step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.step2.perspective">Perspektive und Datum</span>
|
||||
</h3>
|
||||
|
||||
{/* Perspective strip (t-paliad-250 / m/paliad#81, reordered
|
||||
in t-paliad-279 / m/paliad#111). Side defines whose
|
||||
perspective the columns project; appellant collapses
|
||||
party=both rows for role-swap proceedings (Appeal etc.).
|
||||
Moved above .date-input-group because party-side is the
|
||||
most-defining input after proceeding-type — without
|
||||
side, the column labels can't pick "your filings". Both
|
||||
selectors are URL-driven (?side= + ?appellant=) so the
|
||||
perspective survives reload and is shareable.
|
||||
|
||||
When the page is opened with ?project=<id> and that
|
||||
project's our_side is set, side-row renders as a
|
||||
read-only chip with an "Andere Seite wählen" override
|
||||
link — see client/verfahrensablauf.ts. */}
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="side-radio-cluster" id="side-radio-cluster">
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
|
||||
</label>
|
||||
</div>
|
||||
{/* Prompt shown while the user hasn't picked a side
|
||||
(m/paliad#120). Hidden by client when side is
|
||||
claimant or defendant. Both columns still
|
||||
render every rule in this state — picking a
|
||||
side just focuses the user's column. */}
|
||||
<span className="side-hint" id="side-hint"
|
||||
data-i18n="deadlines.side.hint">
|
||||
Wählen Sie eine Seite, um die Spalten zu fokussieren.
|
||||
</span>
|
||||
</div>
|
||||
{/* Auto-fill chip — populated by the client when a
|
||||
?project=<id> URL resolves a project with our_side
|
||||
set. Hidden by default; the radio cluster above is
|
||||
hidden whenever this chip is shown. */}
|
||||
<div className="side-chip" id="side-chip" style="display:none">
|
||||
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
|
||||
<strong className="side-chip-value" id="side-chip-value">—</strong>
|
||||
<button type="button" className="side-chip-override" id="side-chip-override"
|
||||
data-i18n="deadlines.side.override">
|
||||
Andere Seite wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Appeal-target chip row (Slice B1 / m/paliad#124 §18.1).
|
||||
Shown only when the unified upc.apl Berufung tile is
|
||||
selected; lets the user narrow the timeline to the
|
||||
rules whose applies_to_target contains the picked
|
||||
decision kind. URL state ?target=<slug>. */}
|
||||
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="endentscheidung" checked />
|
||||
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="kostenentscheidung" />
|
||||
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="anordnung" />
|
||||
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="schadensbemessung" />
|
||||
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="bucheinsicht" />
|
||||
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
|
||||
Re-surfaces optional cards the user has previously
|
||||
marked "Überspringen" via the per-card popover.
|
||||
The row hides itself when the projection has no
|
||||
hidden cards (handled in client/verfahrensablauf.ts).
|
||||
Default OFF; URL state ?show_hidden=1. */}
|
||||
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
|
||||
<label className="fristen-view-option">
|
||||
<input type="checkbox" id="show-hidden-toggle" />
|
||||
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
|
||||
</label>
|
||||
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite"> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual divider — keeps the perspective block (most-
|
||||
defining inputs after proceeding-type) optically
|
||||
separate from the date / court / flag knobs below. */}
|
||||
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
|
||||
<input type="date" id="trigger-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<div className="date-field-row" id="court-picker-row" style="display:none">
|
||||
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
|
||||
<select id="court-picker" className="date-input"></select>
|
||||
</div>
|
||||
{/* Proceeding-specific flag rows — mirror /tools/fristenrechner
|
||||
so an abstract-browse user can model the same variants
|
||||
(CCR, Patentänderung, Verletzungswiderklage,
|
||||
Vorab-Einrede). Show/hide driven by selectedType in
|
||||
the client. */}
|
||||
<div className="date-field-row" id="ccr-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="ccr-flag" />
|
||||
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="inf-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patentänderung (R.30)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patentänderung (R.49.2.a)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-cci-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
{/* Durations toggle (m/paliad#133, t-paliad-302).
|
||||
Default off — hover-tooltips on date spans are
|
||||
the always-on path. */}
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="verfahrensablauf-durations-show" />
|
||||
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
</div>
|
||||
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/verfahrensablauf.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
21
internal/db/migrations/154_scenario_flags_ssot.down.sql
Normal file
21
internal/db/migrations/154_scenario_flags_ssot.down.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- 154_scenario_flags_ssot.down — t-paliad-331 / m/paliad#149 Phase 2 P0
|
||||
--
|
||||
-- Best-effort rollback of mig 154. Drops the catalog table and the
|
||||
-- jsonb SSoT column. Any scenario state that downstream slices have
|
||||
-- already written is lost — this is by design: down migs are operator
|
||||
-- recovery, not a feature toggle.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 154 down: revert scenario_flags SSoT',
|
||||
true
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS paliad.scenario_flag_catalog;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP COLUMN IF EXISTS scenario_flags;
|
||||
|
||||
COMMIT;
|
||||
139
internal/db/migrations/154_scenario_flags_ssot.up.sql
Normal file
139
internal/db/migrations/154_scenario_flags_ssot.up.sql
Normal file
@@ -0,0 +1,139 @@
|
||||
-- 154_scenario_flags_ssot — t-paliad-331 / m/paliad#149 Phase 2 P0
|
||||
--
|
||||
-- Single source of truth for per-project scenario state. Per the
|
||||
-- design (docs/design-deadline-system-revision-2026-05-27.md §2.3
|
||||
-- and §2.4a), every scenario decision a user makes on a project
|
||||
-- lives in one jsonb column on paliad.projects:
|
||||
--
|
||||
-- { "with_ccr": true, "with_amend": false,
|
||||
-- "rule:<uuid_of_optional_X>": true,
|
||||
-- "rule:<uuid_of_recommended_Y>": false }
|
||||
--
|
||||
-- Entries are either:
|
||||
-- * named scenario flags (whitelist via paliad.scenario_flag_catalog), or
|
||||
-- * per-rule selection deviations of shape "rule:<uuid>".
|
||||
--
|
||||
-- The application validates writes against the catalog and the
|
||||
-- project's active sequencing-rules set; this migration only adds the
|
||||
-- storage. The three known flags (with_ccr / with_amend / with_cci)
|
||||
-- are seeded into the catalog so the API layer has something to
|
||||
-- validate against on day one — extra flags are admin-added later
|
||||
-- (see §4.2.1 R.109 worked example: with_interpreter_denied /
|
||||
-- with_translation_granted both land via the editor when m walks the
|
||||
-- backfill, no fresh migration needed).
|
||||
--
|
||||
-- Purely additive: ADD COLUMN with safe DEFAULT, CREATE TABLE, seed
|
||||
-- inserts. Three existing scenario storage surfaces (project_event_
|
||||
-- choices, scenarios.spec, DOM-only) are all empty per athena's audit
|
||||
-- (zero rows in either persistent surface), so there is nothing to
|
||||
-- migrate.
|
||||
--
|
||||
-- No audit trigger fires on paliad.projects today; set_config is
|
||||
-- defensive so any future audit trigger inherits the reason.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 154: scenario_flags SSoT (t-paliad-331 / m/paliad#149 Phase 2 P0)',
|
||||
true
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. paliad.projects.scenario_flags — the jsonb SSoT.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
CHECK (jsonb_typeof(scenario_flags) = 'object');
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.scenario_flags IS
|
||||
'Per-project scenario state — single source of truth (m/paliad#149 '
|
||||
'Phase 2 P0, design §2.3 + §2.4a). Flat jsonb object whose keys are '
|
||||
'either named scenario flags (whitelist via paliad.scenario_flag_catalog) '
|
||||
'or per-rule selection deviations of shape "rule:<uuid>". Values are '
|
||||
'always JSON booleans; missing keys take the priority-driven default '
|
||||
'(mandatory always selected; recommended default-selected; optional '
|
||||
'default-unselected). Validated at write time by the '
|
||||
'ScenarioFlagsService.Patch handler; this column''s CHECK only '
|
||||
'enforces that the top-level shape is an object.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. paliad.scenario_flag_catalog — the named-flag whitelist.
|
||||
-- Per design §4.1: a small admin-editable vocabulary that powers
|
||||
-- both the write-time validator and the UI's scenario-flag strip.
|
||||
-- Per-rule entries ("rule:<uuid>") are NOT enumerated here — they
|
||||
-- match a pattern and are validated by resolving the UUID against
|
||||
-- the project's active sequencing-rules set.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.scenario_flag_catalog (
|
||||
flag_key text PRIMARY KEY
|
||||
CHECK (flag_key ~ '^[a-z][a-z0-9_]*$'
|
||||
AND flag_key NOT LIKE 'rule:%'
|
||||
AND char_length(flag_key) BETWEEN 1 AND 64),
|
||||
label_de text NOT NULL CHECK (char_length(label_de) > 0),
|
||||
label_en text NOT NULL CHECK (char_length(label_en) > 0),
|
||||
description text NULL,
|
||||
-- hidden_unless_set: when true, the flag is only surfaced in the
|
||||
-- UI's scenario strip once a rule's condition_expr references it
|
||||
-- (or once it's explicitly set on a project). Per design §4.2.1,
|
||||
-- with_interpreter_denied + with_translation_granted are good
|
||||
-- candidates for this once they're seeded — the flag exists for
|
||||
-- write validation but doesn't clutter the default UI.
|
||||
hidden_unless_set boolean NOT NULL DEFAULT false,
|
||||
added_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.scenario_flag_catalog IS
|
||||
'Named-flag vocabulary for paliad.projects.scenario_flags '
|
||||
'(m/paliad#149 Phase 2 P0, design §4.1). Read by the write-time '
|
||||
'validator in ScenarioFlagsService.Patch and by the Verfahrensablauf '
|
||||
'scenario-strip UI. Per-rule selection entries ("rule:<uuid>") are '
|
||||
'NOT enumerated here — they match a pattern and are validated by '
|
||||
'UUID lookup against the project''s active sequencing-rules set.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_flag_catalog.hidden_unless_set IS
|
||||
'When true, the flag does not appear in the default UI scenario '
|
||||
'strip — it is surfaced only when a rule''s condition_expr '
|
||||
'references it or when the project already has it set. Lets us '
|
||||
'register rare flags (e.g. with_interpreter_denied) without '
|
||||
'cluttering the default strip.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Seed the three known flags. These are the flags referenced by
|
||||
-- the 18 condition_expr rows in paliad.sequencing_rules today
|
||||
-- (4 composite condition_expr rows are and/or-of these three).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.scenario_flag_catalog (flag_key, label_de, label_en, description, hidden_unless_set)
|
||||
VALUES
|
||||
('with_ccr', 'Mit Widerklage auf Nichtigkeit',
|
||||
'With counterclaim for revocation (CCR)',
|
||||
'Active when the defendant has filed a CCR. Gates R.025 + the R.029 reply/rejoinder chain on upc.inf.cfi and the R.030 amendment branch nested under it.',
|
||||
false),
|
||||
('with_amend', 'Mit Antrag auf Patentänderung (R.30)',
|
||||
'With application to amend the patent (R.30)',
|
||||
'Active when the patentee has filed an R.30 application. Gates the R.032 def-to-amend / reply / rejoinder chain on the amendment branch.',
|
||||
false),
|
||||
('with_cci', 'Mit Widerklage auf Verletzung',
|
||||
'With counterclaim for infringement (CCI)',
|
||||
'Active when the defendant on a revocation action has filed an infringement counterclaim. Gates the analogous chain on upc.rev.cfi (the inverse of with_ccr).',
|
||||
false);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Sanity check + informational notice.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO n FROM paliad.scenario_flag_catalog;
|
||||
IF n <> 3 THEN
|
||||
RAISE EXCEPTION '[mig 154] expected 3 seeded flags, found %', n;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 154] scenario_flags SSoT ready — % flag(s) in catalog', n;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
43
internal/db/migrations/155_upc_apl_resplit.down.sql
Normal file
43
internal/db/migrations/155_upc_apl_resplit.down.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- 155_upc_apl_resplit.down — t-paliad-331 / m/paliad#149 Phase 2 P1
|
||||
--
|
||||
-- Best-effort rollback. Restores from the same-TX snapshots written by
|
||||
-- mig 155. Drops the snapshots once restoration is verified.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 155 down: revert upc.apl re-split (restore unified id=160)',
|
||||
true
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Restore proceeding_types.is_active from snapshot.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types pt
|
||||
SET is_active = pre.is_active
|
||||
FROM paliad.proceeding_types_pre_155 pre
|
||||
WHERE pt.id = pre.id
|
||||
AND pt.is_active IS DISTINCT FROM pre.is_active;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Restore rule bindings from snapshot.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET proceeding_type_id = pre.proceeding_type_id,
|
||||
spawn_proceeding_type_id = pre.spawn_proceeding_type_id
|
||||
FROM paliad.sequencing_rules_pre_155 pre
|
||||
WHERE sr.id = pre.id
|
||||
AND (sr.proceeding_type_id IS DISTINCT FROM pre.proceeding_type_id
|
||||
OR sr.spawn_proceeding_type_id IS DISTINCT FROM pre.spawn_proceeding_type_id);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Drop the snapshots.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_155;
|
||||
DROP TABLE IF EXISTS paliad.proceeding_types_pre_155;
|
||||
|
||||
COMMIT;
|
||||
191
internal/db/migrations/155_upc_apl_resplit.up.sql
Normal file
191
internal/db/migrations/155_upc_apl_resplit.up.sql
Normal file
@@ -0,0 +1,191 @@
|
||||
-- 155_upc_apl_resplit — t-paliad-331 / m/paliad#149 Phase 2 P1
|
||||
--
|
||||
-- Reverts the upc.apl unification that mig 096 introduced. m's Q5
|
||||
-- (2026-05-27, verbatim):
|
||||
--
|
||||
-- "Reverse the unification as suggested in 3. They are different
|
||||
-- proceedings, I only wanted the approach to be unified in the
|
||||
-- 'determinator' — but they are actually different proceedings!"
|
||||
--
|
||||
-- The current state (audited 2026-05-27, mig 155 pre-flight):
|
||||
--
|
||||
-- id=160 upc.apl.unified is_active=true (carries all 16 rules)
|
||||
-- id=11 upc.apl.merits is_active=false
|
||||
-- id=19 upc.apl.cost is_active=false
|
||||
-- id=20 upc.apl.order is_active=false
|
||||
--
|
||||
-- The 16 rules under id=160 split cleanly by event_code prefix:
|
||||
-- 7 rows match 'upc.apl.merits.%' → target id=11
|
||||
-- 2 rows match 'upc.apl.cost.%' → target id=19
|
||||
-- 7 rows match 'upc.apl.order.%' → target id=20
|
||||
--
|
||||
-- Every parent_id chain among those 16 rows stays inside its bucket
|
||||
-- (audited: 10/10 parent edges are bucket-local), so retargeting by
|
||||
-- event_code prefix preserves the tree shape — no extra parent_id
|
||||
-- surgery needed.
|
||||
--
|
||||
-- Spawn FKs: 4 rules currently target id=11 (was inactive — this is
|
||||
-- the R3 finding athena flagged, re-interpreted by m's Q5 as correct
|
||||
-- intent rather than broken state):
|
||||
--
|
||||
-- upc.inf.cfi.appeal_spawn → 11 (merits) — keep
|
||||
-- upc.rev.cfi.appeal_spawn → 11 (merits) — keep
|
||||
-- upc.dmgs.cfi.appeal_spawn → 11 (merits) — keep
|
||||
-- upc.pi.cfi.appeal_spawn → 11 (merits) — RETARGET to 20 (order),
|
||||
-- since PI appeals
|
||||
-- land on the orders
|
||||
-- track per design §3.1.
|
||||
--
|
||||
-- Active scenarios / projects pointing at id=160: zero (verified
|
||||
-- pre-flight: 0 projects, 0 scenarios reference 'upc.apl'). No data
|
||||
-- migration on the project side; no production traffic is mid-flight
|
||||
-- on id=160.
|
||||
--
|
||||
-- Mig 153's `projects_proceeding_type_kind_check` trigger gates
|
||||
-- inserts/updates against kind='proceeding'. id=11/19/20 already
|
||||
-- carry kind='proceeding' (verified pre-flight), so the trigger
|
||||
-- won't fire on the re-activations.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 155: upc.apl re-split — reactivate merits/cost/order, retire unified (t-paliad-331 / m/paliad#149 P1)',
|
||||
true
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Snapshot for audit + rollback.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.proceeding_types_pre_155 AS
|
||||
SELECT * FROM paliad.proceeding_types WHERE id IN (11, 19, 20, 160);
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules_pre_155 AS
|
||||
SELECT * FROM paliad.sequencing_rules
|
||||
WHERE proceeding_type_id = 160
|
||||
OR (is_spawn AND spawn_proceeding_type_id IN (11, 19, 20, 160));
|
||||
|
||||
COMMENT ON TABLE paliad.proceeding_types_pre_155 IS
|
||||
'Snapshot of the 4 appeal-related proceeding_types rows taken in '
|
||||
'the same TX as mig 155 (upc.apl re-split). Audit + rollback safety.';
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules_pre_155 IS
|
||||
'Snapshot of the 16 rules under id=160 + the 4 spawn rules targeting '
|
||||
'the appeal cluster, taken in the same TX as mig 155. Audit + rollback.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Re-activate the three discrete appeal PTs; retire the unified row.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types SET is_active = true WHERE id IN (11, 19, 20);
|
||||
UPDATE paliad.proceeding_types SET is_active = false WHERE id = 160;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_active int;
|
||||
n_inactive int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO n_active FROM paliad.proceeding_types
|
||||
WHERE id IN (11, 19, 20) AND is_active = true;
|
||||
SELECT COUNT(*) INTO n_inactive FROM paliad.proceeding_types
|
||||
WHERE id = 160 AND is_active = false;
|
||||
IF n_active <> 3 OR n_inactive <> 1 THEN
|
||||
RAISE EXCEPTION '[mig 155] activation check failed — active(11,19,20)=% / inactive(160)=%', n_active, n_inactive;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Retarget the 16 rules on id=160 to merits/cost/order by event_code
|
||||
-- prefix. parent_id stays intact (all parent edges are bucket-local
|
||||
-- per pre-flight audit).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET proceeding_type_id = 11
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id = sr.procedural_event_id
|
||||
AND sr.proceeding_type_id = 160
|
||||
AND pe.code LIKE 'upc.apl.merits.%';
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET proceeding_type_id = 19
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id = sr.procedural_event_id
|
||||
AND sr.proceeding_type_id = 160
|
||||
AND pe.code LIKE 'upc.apl.cost.%';
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET proceeding_type_id = 20
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id = sr.procedural_event_id
|
||||
AND sr.proceeding_type_id = 160
|
||||
AND pe.code LIKE 'upc.apl.order.%';
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
remaining int;
|
||||
merits int; cost int; ord int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO remaining
|
||||
FROM paliad.sequencing_rules WHERE proceeding_type_id = 160;
|
||||
IF remaining <> 0 THEN
|
||||
RAISE EXCEPTION '[mig 155] rebind failed — % rules still on id=160 (expected 0)', remaining;
|
||||
END IF;
|
||||
SELECT COUNT(*) INTO merits
|
||||
FROM paliad.sequencing_rules WHERE proceeding_type_id = 11;
|
||||
SELECT COUNT(*) INTO cost
|
||||
FROM paliad.sequencing_rules WHERE proceeding_type_id = 19;
|
||||
SELECT COUNT(*) INTO ord
|
||||
FROM paliad.sequencing_rules WHERE proceeding_type_id = 20;
|
||||
IF merits <> 7 OR cost <> 2 OR ord <> 7 THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 155] post-rebind counts wrong — merits=% (want 7) / cost=% (want 2) / order=% (want 7)',
|
||||
merits, cost, ord;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 155] rebind OK — merits=% cost=% order=%', merits, cost, ord;
|
||||
END $$;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Retarget the upc.pi.cfi.appeal_spawn rule to id=20 (orders track).
|
||||
-- PI appeals don't go to the merits track — they're orders.
|
||||
-- The inf/rev/dmgs spawns keep target=11 (now active, was inactive
|
||||
-- by accident of the unification).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.sequencing_rules
|
||||
SET spawn_proceeding_type_id = 20
|
||||
WHERE is_spawn = true
|
||||
AND procedural_event_id = (
|
||||
SELECT id FROM paliad.procedural_events WHERE code = 'upc.pi.cfi.appeal_spawn'
|
||||
)
|
||||
AND spawn_proceeding_type_id = 11;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
pi_target int;
|
||||
others int;
|
||||
BEGIN
|
||||
SELECT spawn_proceeding_type_id INTO pi_target
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.code = 'upc.pi.cfi.appeal_spawn' AND sr.is_spawn = true
|
||||
LIMIT 1;
|
||||
IF pi_target IS DISTINCT FROM 20 THEN
|
||||
RAISE EXCEPTION '[mig 155] pi.cfi spawn retarget failed — got %, want 20', pi_target;
|
||||
END IF;
|
||||
SELECT COUNT(*) INTO others
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE sr.is_spawn = true
|
||||
AND sr.spawn_proceeding_type_id = 11
|
||||
AND pe.code IN ('upc.inf.cfi.appeal_spawn',
|
||||
'upc.rev.cfi.appeal_spawn',
|
||||
'upc.dmgs.cfi.appeal_spawn');
|
||||
IF others <> 3 THEN
|
||||
RAISE EXCEPTION '[mig 155] inf/rev/dmgs spawn target check failed — % rows point at 11 (want 3)', others;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 155] spawn graph OK — pi → 20 (order); inf/rev/dmgs → 11 (merits)';
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 156_trigger_event_id_partial_deprecation.down — t-paliad-331 / m/paliad#149
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 156 down: restore trigger_event_id on the 2 hybrid rules',
|
||||
true
|
||||
);
|
||||
|
||||
-- Restore the trigger_event_id values from the same-TX snapshot.
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET trigger_event_id = pre.trigger_event_id
|
||||
FROM paliad.sequencing_rules_pre_156 pre
|
||||
WHERE sr.id = pre.id
|
||||
AND sr.trigger_event_id IS NULL
|
||||
AND pre.trigger_event_id IS NOT NULL;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_156;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,80 @@
|
||||
-- 156_trigger_event_id_partial_deprecation — t-paliad-331 / m/paliad#149 Phase 2 P4 (partial)
|
||||
--
|
||||
-- Partial deprecation step toward retiring paliad.trigger_events.
|
||||
-- The full table-drop (and the route + service + 5 read-site removals
|
||||
-- the design's §3.4 + §4.3 lay out) is gated on the editorial backfill
|
||||
-- of the 73 orphan globals — sequencing_rules rows that carry
|
||||
-- trigger_event_id NOT NULL AND proceeding_type_id IS NULL today. m
|
||||
-- drives that walk via /admin/procedural-events at his cadence (no
|
||||
-- coder time blocked); this mig prepares the way without breaking the
|
||||
-- legacy route the orphans still depend on.
|
||||
--
|
||||
-- What this mig does (live-DB audited 2026-05-27 pre-flight):
|
||||
--
|
||||
-- 1. NULL out the 2 hybrid rules that carry BOTH parent_id AND
|
||||
-- trigger_event_id. Per design §2.1 / m's Q1: parent_id is the
|
||||
-- canonical predecessor link; trigger_event_id on those 2 rows is
|
||||
-- redundant. The parent_id chain keeps the live edge — no data
|
||||
-- loss, no route disruption (the route only reads trigger_event_id
|
||||
-- for the 73 orphan globals, which have no parent_id).
|
||||
--
|
||||
-- 2. NOT-DROP the column or the table. Both stay live so the
|
||||
-- /api/tools/event-deadlines route continues to serve the 73
|
||||
-- orphan globals until editorial reparenting lands.
|
||||
--
|
||||
-- The full P4 (mig that DROPs paliad.trigger_events + the
|
||||
-- `sequencing_rules.trigger_event_id` column + the legacy route +
|
||||
-- EventDeadlineService + ExportService::1680 + cmd/gen-upc-snapshot/
|
||||
-- main.go:185-202) lands AFTER the 73 orphans are reparented. Until
|
||||
-- then, the legacy surface remains.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 156: trigger_event_id partial deprecation — NULL out 2 hybrid rules (t-paliad-331 / m/paliad#149 Phase 2 P4 partial)',
|
||||
true
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Snapshot the 2 hybrid rows for audit + rollback.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules_pre_156 AS
|
||||
SELECT * FROM paliad.sequencing_rules
|
||||
WHERE trigger_event_id IS NOT NULL
|
||||
AND parent_id IS NOT NULL
|
||||
AND is_active = true;
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules_pre_156 IS
|
||||
'Snapshot of the 2 hybrid rules (trigger_event_id NOT NULL AND '
|
||||
'parent_id NOT NULL) taken in the same TX as mig 156, before their '
|
||||
'trigger_event_id is NULL''ed. Rollback aid until P4 final lands.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. NULL out trigger_event_id on hybrid rules — parent_id is the
|
||||
-- canonical predecessor link per design §2.1.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.sequencing_rules
|
||||
SET trigger_event_id = NULL
|
||||
WHERE trigger_event_id IS NOT NULL
|
||||
AND parent_id IS NOT NULL
|
||||
AND is_active = true;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
remaining_hybrids int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO remaining_hybrids
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE trigger_event_id IS NOT NULL
|
||||
AND parent_id IS NOT NULL
|
||||
AND is_active = true;
|
||||
IF remaining_hybrids <> 0 THEN
|
||||
RAISE EXCEPTION '[mig 156] expected 0 active hybrid rules, found %', remaining_hybrids;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 156] hybrid-rule cleanup OK — 0 active rules carry both parent_id and trigger_event_id';
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -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;
|
||||
12
internal/db/migrations/158_docforge_templates.down.sql
Normal file
12
internal/db/migrations/158_docforge_templates.down.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- t-paliad-349: revert docforge template authoring tables.
|
||||
--
|
||||
-- Drop the FK first so the templates ↔ template_versions cycle unwinds,
|
||||
-- then the tables (template_slots + template_versions cascade from their
|
||||
-- parents, but drop explicitly for clarity and order-independence).
|
||||
|
||||
ALTER TABLE IF EXISTS paliad.templates
|
||||
DROP CONSTRAINT IF EXISTS templates_current_version_fk;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.template_slots;
|
||||
DROP TABLE IF EXISTS paliad.template_versions;
|
||||
DROP TABLE IF EXISTS paliad.templates;
|
||||
127
internal/db/migrations/158_docforge_templates.up.sql
Normal file
127
internal/db/migrations/158_docforge_templates.up.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- t-paliad-349 (m/paliad#157): docforge slice 4 — template authoring tables.
|
||||
--
|
||||
-- These three tables are the persistence home for the docforge authoring
|
||||
-- flow (upload a base .docx → place variable slots → save as a reusable
|
||||
-- template) and the generation flow (pick a template → bind data →
|
||||
-- export). They are paliad's implementation of the docforge.TemplateStore
|
||||
-- contract; docforge itself owns no tables (the litigationplanner pattern).
|
||||
--
|
||||
-- Generic on purpose (NOT submission_*-named): authoring is a
|
||||
-- domain-neutral capability, so the eventual second docforge consumer can
|
||||
-- reuse the same shape. submission_bases (Gitea-backed, section_spec) stays
|
||||
-- for the legacy base catalog during the transition; convergence is a
|
||||
-- later, separate task.
|
||||
--
|
||||
-- paliad.templates — one row per template (the catalog entry).
|
||||
-- paliad.template_versions — immutable snapshots; editing a template
|
||||
-- inserts a new version. The carrier .docx
|
||||
-- bytes live here (bytea) — the TemplateStore
|
||||
-- bytea backend. A draft pins a version
|
||||
-- (snapshot-at-create, PRD §4 A3) so later
|
||||
-- edits don't shift an in-flight draft.
|
||||
-- paliad.template_slots — the variable slots placed in a version's
|
||||
-- carrier. anchor is the sentinel token the
|
||||
-- authoring surface injects into the carrier
|
||||
-- OOXML to locate the slot (PRD §5 lean);
|
||||
-- slot_key is the variable bound there.
|
||||
--
|
||||
-- Visibility: the template catalog is shared firm-wide (every
|
||||
-- authenticated user generates from it), so SELECT is open to
|
||||
-- authenticated, mirroring submission_bases. Mutations (upload, edit) are
|
||||
-- admin-only and gated in Go at the handler layer — no INSERT/UPDATE/DELETE
|
||||
-- RLS path means RLS denies them by default.
|
||||
--
|
||||
-- Slice 4 ships the schema + the TemplateStore only; no rows are seeded and
|
||||
-- no UI writes here yet (authoring is slice 6, generation-on-templates is
|
||||
-- slice 7).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.templates (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text UNIQUE,
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
kind text NOT NULL DEFAULT 'submission',
|
||||
source_format text NOT NULL DEFAULT 'docx',
|
||||
firm text,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
current_version_id uuid,
|
||||
created_by uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT templates_source_format_check CHECK (source_format IN ('docx'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.template_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
template_id uuid NOT NULL REFERENCES paliad.templates(id) ON DELETE CASCADE,
|
||||
version int NOT NULL,
|
||||
carrier_blob bytea NOT NULL,
|
||||
stylemap jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_by uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT template_versions_unique_per_template UNIQUE (template_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.template_slots (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
template_version_id uuid NOT NULL REFERENCES paliad.template_versions(id) ON DELETE CASCADE,
|
||||
slot_key text NOT NULL,
|
||||
anchor text NOT NULL,
|
||||
label text,
|
||||
order_index int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT template_slots_unique_anchor UNIQUE (template_version_id, anchor)
|
||||
);
|
||||
|
||||
-- current_version_id FK is added after template_versions exists to avoid a
|
||||
-- circular CREATE-TABLE dependency. ON DELETE SET NULL: dropping the
|
||||
-- pinned version detaches it rather than cascading the template away.
|
||||
ALTER TABLE paliad.templates
|
||||
DROP CONSTRAINT IF EXISTS templates_current_version_fk;
|
||||
ALTER TABLE paliad.templates
|
||||
ADD CONSTRAINT templates_current_version_fk
|
||||
FOREIGN KEY (current_version_id)
|
||||
REFERENCES paliad.template_versions(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS templates_firm_kind_idx
|
||||
ON paliad.templates (firm, kind) WHERE is_active;
|
||||
CREATE INDEX IF NOT EXISTS template_versions_template_idx
|
||||
ON paliad.template_versions (template_id, version);
|
||||
CREATE INDEX IF NOT EXISTS template_slots_version_idx
|
||||
ON paliad.template_slots (template_version_id, order_index);
|
||||
|
||||
ALTER TABLE paliad.templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.template_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.template_slots ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Firm-shared catalog: any authenticated user reads. Mutations are
|
||||
-- admin-only, gated in Go (no mutation RLS policy = RLS denies by default).
|
||||
DROP POLICY IF EXISTS templates_select ON paliad.templates;
|
||||
CREATE POLICY templates_select
|
||||
ON paliad.templates FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS template_versions_select ON paliad.template_versions;
|
||||
CREATE POLICY template_versions_select
|
||||
ON paliad.template_versions FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS template_slots_select ON paliad.template_slots;
|
||||
CREATE POLICY template_slots_select
|
||||
ON paliad.template_slots FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
DROP TRIGGER IF EXISTS templates_set_updated_at ON paliad.templates;
|
||||
CREATE TRIGGER templates_set_updated_at
|
||||
BEFORE UPDATE ON paliad.templates
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.templates IS
|
||||
't-paliad-349: docforge template catalog. One row per uploaded template; current_version_id pins the live version.';
|
||||
COMMENT ON TABLE paliad.template_versions IS
|
||||
't-paliad-349: immutable docforge template snapshots. carrier_blob holds the base .docx bytes (TemplateStore bytea backend).';
|
||||
COMMENT ON TABLE paliad.template_slots IS
|
||||
't-paliad-349: variable slots placed in a template version. anchor = sentinel token locating the slot in the carrier OOXML; slot_key = the bound variable.';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- t-paliad-349: revert the template-version pin on submission drafts.
|
||||
|
||||
DROP INDEX IF EXISTS paliad.submission_drafts_template_version_idx;
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS template_version_id;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- t-paliad-349 (m/paliad#157): docforge slice 7 — pin an uploaded template
|
||||
-- version onto a submission draft (generation-on-uploaded-templates).
|
||||
--
|
||||
-- A draft can now source its document from a docforge uploaded template
|
||||
-- (paliad.template_versions) instead of a legacy Gitea base. template_version_id
|
||||
-- is the snapshot pin (PRD §4 A3): the draft renders the exact carrier of the
|
||||
-- version it was bound to, so a later template edit (which creates a new
|
||||
-- version) doesn't shift an in-flight draft.
|
||||
--
|
||||
-- Nullable + additive: existing drafts keep template_version_id NULL and
|
||||
-- render via their existing path (Composer base_id, or the v1 fallback).
|
||||
-- The three sources are mutually exclusive in practice; the export path
|
||||
-- checks template_version_id first, then base_id, then v1.
|
||||
--
|
||||
-- ON DELETE SET NULL: if the pinned version is removed, the draft detaches
|
||||
-- and falls back rather than failing — same posture as base_id's
|
||||
-- ON DELETE SET NULL.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN IF NOT EXISTS template_version_id uuid
|
||||
REFERENCES paliad.template_versions(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_drafts_template_version_idx
|
||||
ON paliad.submission_drafts (template_version_id)
|
||||
WHERE template_version_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.template_version_id IS
|
||||
't-paliad-349: pinned docforge template version (snapshot-at-create). NULL = render via base_id Composer path or v1 fallback.';
|
||||
199
internal/handlers/builder_search.go
Normal file
199
internal/handlers/builder_search.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// t-paliad-346 / m/paliad#153 B3 — universal search for the Litigation
|
||||
// Builder. Returns events + scenarios + projects (Akten) keyed by type
|
||||
// so the search dropdown can render typed result groups.
|
||||
//
|
||||
// GET /api/builder/search?q=<term>&limit=<n>
|
||||
//
|
||||
// Response shape:
|
||||
//
|
||||
// {
|
||||
// "query": "<echoed q>",
|
||||
// "events": [ EventSearchHit, ... ], // anchor_rule_id + proceeding_type embedded
|
||||
// "scenarios": [ { id, name, status, updated_at }, ... ],
|
||||
// "projects": [ { id, title, type, reference, case_number, matter_number, client_number }, ... ],
|
||||
// "counts": { "events": N, "scenarios": M, "projects": K }
|
||||
// }
|
||||
//
|
||||
// Each group is independently capped (default 8 events / 5 scenarios /
|
||||
// 5 projects, max 30 per group). Missing services degrade gracefully —
|
||||
// an unavailable group is returned as an empty array, not an error,
|
||||
// so a knowledge-only deploy (DATABASE_URL unset) can still serve a
|
||||
// best-effort empty response shape rather than a 503 wall.
|
||||
|
||||
type builderSearchScenarioHit struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type builderSearchProjectHit struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
MatterNumber *string `json:"matter_number,omitempty"`
|
||||
ClientNumber *string `json:"client_number,omitempty"`
|
||||
}
|
||||
|
||||
type builderSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Events []services.EventSearchHit `json:"events"`
|
||||
Scenarios []builderSearchScenarioHit `json:"scenarios"`
|
||||
Projects []builderSearchProjectHit `json:"projects"`
|
||||
Counts builderSearchCounts `json:"counts"`
|
||||
}
|
||||
|
||||
type builderSearchCounts struct {
|
||||
Events int `json:"events"`
|
||||
Scenarios int `json:"scenarios"`
|
||||
Projects int `json:"projects"`
|
||||
}
|
||||
|
||||
// handleBuilderSearch — GET /api/builder/search?q=<term>&limit=<n>
|
||||
//
|
||||
// Auth required. Returns 200 with empty groups when q is empty (matches
|
||||
// the fristenrechner search ergonomic — frontend can boot without a
|
||||
// pre-flight round trip).
|
||||
func handleBuilderSearch(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
perGroupLimit := parseBuilderSearchLimit(r.URL.Query().Get("limit"))
|
||||
|
||||
resp := builderSearchResponse{
|
||||
Query: q,
|
||||
Events: []services.EventSearchHit{},
|
||||
Scenarios: []builderSearchScenarioHit{},
|
||||
Projects: []builderSearchProjectHit{},
|
||||
}
|
||||
|
||||
if q == "" {
|
||||
// Match fristenrechner search: empty query → empty groups, not 400.
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Events: reuse the SearchEvents shape so anchor_rule_id +
|
||||
// proceeding_type travel with each hit. UPC v1 (PRD §0.4) — the
|
||||
// jurisdiction filter pins the corpus the builder serves today.
|
||||
if dbSvc != nil && dbSvc.deadlineSearch != nil {
|
||||
eventsResp, err := dbSvc.deadlineSearch.SearchEvents(ctx, q, services.EventSearchOptions{
|
||||
Jurisdiction: "UPC",
|
||||
Limit: perGroupLimit.events,
|
||||
})
|
||||
if err == nil && eventsResp != nil {
|
||||
resp.Events = eventsResp.Events
|
||||
}
|
||||
}
|
||||
|
||||
// Scenarios: caller's own scenarios filtered by ILIKE on name.
|
||||
// Borrows ListMyScenarios + filters in-memory; the list endpoint
|
||||
// already caps at the small per-user fan-out and there's no index
|
||||
// on (owner_id, name) yet — in-memory filter is cheap at 10s-of-
|
||||
// rows scale.
|
||||
if dbSvc != nil && dbSvc.scenarioBuilder != nil {
|
||||
scenarios, err := dbSvc.scenarioBuilder.ListMyScenarios(ctx, uid, "active")
|
||||
if err == nil {
|
||||
needle := strings.ToLower(q)
|
||||
hits := []builderSearchScenarioHit{}
|
||||
for _, sc := range scenarios {
|
||||
if !strings.Contains(strings.ToLower(sc.Name), needle) {
|
||||
continue
|
||||
}
|
||||
hits = append(hits, builderSearchScenarioHit{
|
||||
ID: sc.ID,
|
||||
Name: sc.Name,
|
||||
Status: sc.Status,
|
||||
UpdatedAt: sc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
if len(hits) >= perGroupLimit.scenarios {
|
||||
break
|
||||
}
|
||||
}
|
||||
resp.Scenarios = hits
|
||||
}
|
||||
}
|
||||
|
||||
// Projects (Akten): visible projects filtered by trigram/ILIKE on
|
||||
// title, reference, client_number, matter_number. ProjectService.List
|
||||
// already applies team-based RLS via visibilityPredicate.
|
||||
if dbSvc != nil && dbSvc.projects != nil {
|
||||
projects, err := dbSvc.projects.List(ctx, uid, services.ProjectFilter{
|
||||
Search: q,
|
||||
})
|
||||
if err == nil {
|
||||
hits := make([]builderSearchProjectHit, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
hits = append(hits, builderSearchProjectHit{
|
||||
ID: p.ID,
|
||||
Type: p.Type,
|
||||
Title: p.Title,
|
||||
Reference: p.Reference,
|
||||
CaseNumber: p.CaseNumber,
|
||||
MatterNumber: p.MatterNumber,
|
||||
ClientNumber: p.ClientNumber,
|
||||
})
|
||||
if len(hits) >= perGroupLimit.projects {
|
||||
break
|
||||
}
|
||||
}
|
||||
resp.Projects = hits
|
||||
}
|
||||
}
|
||||
|
||||
resp.Counts = builderSearchCounts{
|
||||
Events: len(resp.Events),
|
||||
Scenarios: len(resp.Scenarios),
|
||||
Projects: len(resp.Projects),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type builderSearchPerGroup struct {
|
||||
events int
|
||||
scenarios int
|
||||
projects int
|
||||
}
|
||||
|
||||
// parseBuilderSearchLimit reads ?limit=<n> as a hint for the events
|
||||
// group (largest expected hit count). Scenarios + projects use smaller
|
||||
// caps because their drop-down rows are visually heavier. The shared
|
||||
// caller-supplied bound is interpreted as the events cap; scenarios
|
||||
// and projects are derived from it.
|
||||
func parseBuilderSearchLimit(raw string) builderSearchPerGroup {
|
||||
def := builderSearchPerGroup{events: 8, scenarios: 5, projects: 5}
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return def
|
||||
}
|
||||
if n > 30 {
|
||||
n = 30
|
||||
}
|
||||
return builderSearchPerGroup{
|
||||
events: n,
|
||||
scenarios: max(1, n/2),
|
||||
projects: max(1, n/2),
|
||||
}
|
||||
}
|
||||
48
internal/handlers/docforge_variables.go
Normal file
48
internal/handlers/docforge_variables.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package handlers
|
||||
|
||||
// docforge variable catalogue handler (t-paliad-349 slice 5).
|
||||
//
|
||||
// Endpoint: GET /api/docforge/variables → the full variable catalogue
|
||||
// (key + bilingual label + namespace group) the sidebar form and the
|
||||
// authoring palette render. The catalogue is the Go-side single source of
|
||||
// truth, built from the submission resolvers' Keys(); it replaces the
|
||||
// duplicated TS VARIABLE_LABELS table so labels can't drift between the
|
||||
// resolver that produces a value and the form that labels it.
|
||||
//
|
||||
// Static — no DB call, no per-user state. Auth-gated only (anonymous 401);
|
||||
// the catalogue is the same for every authenticated user.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
type docforgeVariablesResponse struct {
|
||||
Variables []variableEntry `json:"variables"`
|
||||
}
|
||||
|
||||
type variableEntry struct {
|
||||
Key string `json:"key"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// handleDocforgeVariables backs GET /api/docforge/variables.
|
||||
func handleDocforgeVariables(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
cat := services.SubmissionVariableCatalogue()
|
||||
out := make([]variableEntry, 0, len(cat))
|
||||
for _, e := range cat {
|
||||
out = append(out, variableEntry{
|
||||
Key: e.Key,
|
||||
LabelDE: e.LabelDE,
|
||||
LabelEN: e.LabelEN,
|
||||
Group: e.Group,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, docforgeVariablesResponse{Variables: out})
|
||||
}
|
||||
@@ -12,28 +12,41 @@ import (
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// Fristenrechner page handler: serves the static HTML. No DB dependency.
|
||||
//
|
||||
// Back-compat: the pre-split sidebar entry for "Verfahrensablauf" pointed at
|
||||
// /tools/fristenrechner?path=a. After the t-paliad-179 split, that landing is
|
||||
// owned by /tools/verfahrensablauf. A naked ?path=a (no Akte context — i.e.
|
||||
// no ?project=) is the bookmarked-legacy-entry case → 302 to the new route.
|
||||
// ?project=<uuid>&path=a is the Akte-mode internal wizard pathway and stays
|
||||
// on /tools/fristenrechner so the wizard state survives a refresh.
|
||||
func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if q.Get("path") == "a" && q.Get("project") == "" {
|
||||
http.Redirect(w, r, "/tools/verfahrensablauf", http.StatusFound)
|
||||
return
|
||||
// U4 (m/paliad#151) — legacy /tools/fristenrechner and
|
||||
// /tools/verfahrensablauf folded into /tools/procedures via hard 301
|
||||
// redirects. Per m's Q11 divergence in the design (no 2-week dual-ship
|
||||
// window), bookmarks resolve via Location preservation of query params;
|
||||
// no `?legacy=1` escape, no in-product affordance points back at the
|
||||
// retired URLs after the merge.
|
||||
|
||||
func redirectToProcedures(w http.ResponseWriter, r *http.Request) {
|
||||
loc := "/tools/procedures"
|
||||
if raw := r.URL.RawQuery; raw != "" {
|
||||
loc += "?" + raw
|
||||
}
|
||||
http.ServeFile(w, r, "dist/fristenrechner.html")
|
||||
http.Redirect(w, r, loc, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
// Verfahrensablauf page handler (t-paliad-179 Slice 1): the dedicated
|
||||
// abstract-browse surface for procedural shape. No DB dependency — the page
|
||||
// shell is static HTML; the calculator API still drives the timeline render.
|
||||
// handleFristenrechnerPage — kept as a registration name for the legacy
|
||||
// URL so bookmarks (and the existing Sidebar history a former user may
|
||||
// have cached) keep resolving. 301s to /tools/procedures.
|
||||
func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) {
|
||||
redirectToProcedures(w, r)
|
||||
}
|
||||
|
||||
// handleVerfahrensablaufPage — symmetrical 301 to /tools/procedures.
|
||||
func handleVerfahrensablaufPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/verfahrensablauf.html")
|
||||
redirectToProcedures(w, r)
|
||||
}
|
||||
|
||||
// Unified procedural-events tool page (m/paliad#151, design
|
||||
// docs/design-unified-procedural-events-tool-2026-05-27.md). Consolidates
|
||||
// Fristenrechner Mode A + Mode B + result + Verfahrensablauf into a
|
||||
// single surface at /tools/procedures. No DB dependency — the page
|
||||
// itself is static HTML; per-tab data flows over the existing
|
||||
// /api/tools/fristenrechner/* endpoints.
|
||||
func handleProceduresPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/procedures.html")
|
||||
}
|
||||
|
||||
// POST /api/tools/fristenrechner — calculate the UI timeline for a proceeding.
|
||||
@@ -78,6 +91,19 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// slugs are silently dropped (no filter) so a stale frontend
|
||||
// chip doesn't 400 the request.
|
||||
AppealTarget string `json:"appealTarget,omitempty"`
|
||||
// t-paliad-348 / yoUPC#178 — surface the engine's two new
|
||||
// CalcOptions axes to the HTTP boundary:
|
||||
//
|
||||
// IncludeOptional: when true, priority='optional' rules
|
||||
// surface on the timeline. Default false matches the
|
||||
// engine's default (mandatory backbone only).
|
||||
// TriggerEventAnchors: per-event-code anchor dates the
|
||||
// engine consults for rules carrying trigger_event_id.
|
||||
// When a rule's anchor is absent the engine renders the
|
||||
// rule as IsConditional rather than fabricating a date
|
||||
// off the proceeding's trigger date.
|
||||
IncludeOptional bool `json:"includeOptional,omitempty"`
|
||||
TriggerEventAnchors map[string]string `json:"triggerEventAnchors,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -117,15 +143,17 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
IncludeOptional: req.IncludeOptional,
|
||||
TriggerEventAnchors: req.TriggerEventAnchors,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
@@ -253,7 +281,26 @@ func handleTriggerEventsList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// POST /api/tools/event-deadlines — compute all deadlines flowing from a
|
||||
// trigger event + date. Body: {"triggerEventId": <int>, "triggerDate": "YYYY-MM-DD"}.
|
||||
//
|
||||
// DEPRECATED (m/paliad#149 Phase 2 P4 partial, t-paliad-331). This route
|
||||
// serves the 73 orphan globals (sequencing_rules with proceeding_type_id
|
||||
// IS NULL, addressed only via trigger_event_id). The route is held live
|
||||
// until those 73 are reparented onto real proceeding-type chains via
|
||||
// /admin/procedural-events (editorial work; tracked separately).
|
||||
//
|
||||
// Once the orphan count hits zero, the planned final-P4 lands:
|
||||
// - DROP TABLE paliad.trigger_events
|
||||
// - ALTER TABLE paliad.sequencing_rules DROP COLUMN trigger_event_id
|
||||
// - remove this handler + EventDeadlineService + the 5 read sites
|
||||
// enumerated in the design (deadline_rule_service.go:226,
|
||||
// event_deadline_service.go:79+244, event_type_service.go:40+414,
|
||||
// export_service.go:1680, cmd/gen-upc-snapshot/main.go:185-202).
|
||||
//
|
||||
// The Deprecation + Sunset response headers below let callers see the
|
||||
// signal without breaking — see RFC 8594 / RFC 9745.
|
||||
func handleEventDeadlinesCalculate(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Deprecation", "true")
|
||||
w.Header().Set("Link", `<https://mgit.msbls.de/m/paliad/issues/149>; rel="deprecation"; type="text/html"`)
|
||||
if dbSvc == nil || dbSvc.eventDeadline == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
|
||||
@@ -128,6 +128,10 @@ type Services struct {
|
||||
// editor. Per Q2: paste sources only, no lineage on sections.
|
||||
SubmissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store backing
|
||||
// the authoring surface.
|
||||
TemplateStore *services.PgTemplateStore
|
||||
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
|
||||
// the Verfahrensablauf timeline.
|
||||
EventChoice *services.EventChoiceService
|
||||
@@ -137,6 +141,17 @@ type Services struct {
|
||||
// unset; the /api/scenarios routes return 503 in that case.
|
||||
Scenario *services.ScenarioService
|
||||
|
||||
// m/paliad#149 Phase 2 P0 — per-project scenario_flags SSoT (mig 154).
|
||||
// Drives Verfahrensablauf + Mode B result-view conditional rendering
|
||||
// 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
|
||||
@@ -204,8 +219,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
submissionSection: svc.SubmissionSection,
|
||||
submissionComposer: svc.SubmissionComposer,
|
||||
submissionBuildingBlock: svc.SubmissionBuildingBlock,
|
||||
templateStore: svc.TemplateStore,
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
scenarioFlags: svc.ScenarioFlags,
|
||||
scenarioBuilder: svc.ScenarioBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +317,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/tools/kostenrechner", handleKostenrechnerAPI)
|
||||
protected.HandleFunc("GET /tools/fristenrechner", handleFristenrechnerPage)
|
||||
protected.HandleFunc("GET /tools/verfahrensablauf", handleVerfahrensablaufPage)
|
||||
protected.HandleFunc("GET /tools/procedures", handleProceduresPage)
|
||||
protected.HandleFunc("POST /api/tools/fristenrechner", handleFristenrechnerAPI)
|
||||
protected.HandleFunc("POST /api/tools/fristenrechner/calculate-rule", handleFristenrechnerCalculateRule)
|
||||
protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes)
|
||||
@@ -382,6 +401,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PATCH /api/projects/{id}", handleUpdateProject)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}", handleDeleteProject)
|
||||
protected.HandleFunc("GET /api/projects/{id}/events", handleListProjectEvents)
|
||||
// m/paliad#149 Phase 2 P0 — per-project scenario_flags SSoT (mig 154).
|
||||
// Verfahrensablauf + Mode B result-view bind their conditional
|
||||
// checkboxes here; P3 will add per-rule "rule:<uuid>" selection entries
|
||||
// on top of the same endpoint.
|
||||
protected.HandleFunc("GET /api/projects/{id}/scenario-flags", handleGetScenarioFlags)
|
||||
protected.HandleFunc("PATCH /api/projects/{id}/scenario-flags", handlePatchScenarioFlags)
|
||||
// t-paliad-171 / t-paliad-173 — SmartTimeline (Verlauf-tab redesign).
|
||||
// /timeline returns the merged timeline (actuals + Slice 2 projections).
|
||||
// /timeline/milestone is the "Eigener Meilenstein" write path.
|
||||
@@ -435,6 +460,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// the sidebar picker. Wide-open SELECT (any authenticated user);
|
||||
// admin mutations are not exposed yet (Slice C).
|
||||
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 5 — the variable
|
||||
// catalogue (Go-side SSOT) the sidebar form + authoring palette read.
|
||||
protected.HandleFunc("GET /api/docforge/variables", handleDocforgeVariables)
|
||||
// t-paliad-349 slice 7 — firm-shared template picker list for
|
||||
// generation (any authenticated lawyer; admin authoring stays gated).
|
||||
protected.HandleFunc("GET /api/templates", handlePickerTemplates)
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH
|
||||
// for inline editor autosave. URL keyed on draft_id + section_id;
|
||||
// owner-scoped via SubmissionDraftService.Get.
|
||||
@@ -501,6 +532,39 @@ 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)
|
||||
// m/paliad#153 B4 — Akte mode entry point. Creates a project-backed
|
||||
// scenario from a paliad.projects row; subsequent edits dual-write
|
||||
// through to paliad.deadlines + paliad.projects.scenario_flags.
|
||||
protected.HandleFunc("POST /api/builder/scenarios/from-project", handleBuilderScenarioFromProject)
|
||||
// m/paliad#153 B5 — "Geteilt mit mir" bucket. Literal segment wins
|
||||
// over {id} in Go 1.22+ ServeMux precedence, so this never shadows GET .../{id}.
|
||||
protected.HandleFunc("GET /api/builder/scenarios/shared", handleBuilderScenariosShared)
|
||||
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)
|
||||
// m/paliad#153 B5 — transactional promote-to-project wizard commit.
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/promote", handleBuilderScenarioPromote)
|
||||
// m/paliad#153 B2 — read-only passthrough so the builder can render
|
||||
// per-triplet flag toggles without a per-project round-trip.
|
||||
protected.HandleFunc("GET /api/builder/scenario-flag-catalog", handleBuilderFlagCatalog)
|
||||
// m/paliad#153 B3 — universal search (events + scenarios + projects).
|
||||
protected.HandleFunc("GET /api/builder/search", handleBuilderSearch)
|
||||
// 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)
|
||||
@@ -699,6 +763,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
|
||||
|
||||
// t-paliad-349 docforge slice 6 — template authoring surface
|
||||
// (upload base .docx → place variable slots → save). Admin-only,
|
||||
// firm-shared catalog like submission_bases.
|
||||
protected.HandleFunc("GET /admin/templates", adminGate(users, gateOnboarded(handleTemplatesAuthoringPage)))
|
||||
protected.HandleFunc("GET /api/admin/templates", adminGate(users, handleListTemplates))
|
||||
protected.HandleFunc("POST /api/admin/templates", adminGate(users, handleUploadTemplate))
|
||||
protected.HandleFunc("GET /api/admin/templates/{id}", adminGate(users, handleGetTemplateAuthoring))
|
||||
protected.HandleFunc("POST /api/admin/templates/{id}/slots", adminGate(users, handlePlaceTemplateSlot))
|
||||
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
||||
|
||||
@@ -77,11 +77,22 @@ type dbServices struct {
|
||||
submissionComposer *services.SubmissionComposer
|
||||
submissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store.
|
||||
templateStore *services.PgTemplateStore
|
||||
|
||||
// t-paliad-265 — per-event-card optional choices.
|
||||
eventChoice *services.EventChoiceService
|
||||
|
||||
// Slice D — named scenario compositions (m/paliad#124 §5).
|
||||
scenario *services.ScenarioService
|
||||
|
||||
// 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
|
||||
|
||||
728
internal/handlers/scenario_builder.go
Normal file
728
internal/handlers/scenario_builder.go
Normal file
@@ -0,0 +1,728 @@
|
||||
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})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Akte mode (B4, t-paliad-347)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderScenarioFromProject — POST /api/builder/scenarios/from-project
|
||||
//
|
||||
// Body: {"project_id": "<uuid>"}
|
||||
//
|
||||
// Creates a fresh project-backed scenario by snapshotting the project's
|
||||
// proceeding_type_id + our_side + scenario_flags into one top-level
|
||||
// triplet, and seeds scenario_events from every existing
|
||||
// paliad.deadlines row tied to a sequencing_rule. The new scenario's
|
||||
// origin_project_id pins the Akte link so subsequent edits dual-write
|
||||
// through to paliad.deadlines + paliad.projects.scenario_flags (PRD §2.3).
|
||||
//
|
||||
// Visibility: caller must be able to see the project. Bad input
|
||||
// (missing proceeding_type_id, invisible project) returns 400 / 404
|
||||
// via the standard service-error mapping.
|
||||
func handleBuilderScenarioFromProject(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
if body.ProjectID == uuid.Nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id ist erforderlich"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.CreateScenarioFromProject(r.Context(), uid, body.ProjectID)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared-with-me + Promote (B5, m/paliad#153)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderScenariosShared — GET /api/builder/scenarios/shared
|
||||
//
|
||||
// Lists scenarios shared read-only with the caller (the "Geteilt mit mir"
|
||||
// side-panel bucket, PRD §2.5). The caller's own scenarios are excluded.
|
||||
func handleBuilderScenariosShared(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.ListSharedWithMe(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderScenarioPromote — POST /api/builder/scenarios/{id}/promote
|
||||
//
|
||||
// Body: PromoteScenarioInput (wizard steps 2 + 3). Promotes the scenario
|
||||
// into a real paliad.projects 'case' row transactionally (PRD §10 — no
|
||||
// partial promotions) and returns PromoteResult with the new project id
|
||||
// the wizard navigates to (/projects/{project_id}).
|
||||
func handleBuilderScenarioPromote(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.PromoteScenarioInput
|
||||
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.PromoteScenario(r.Context(), uid, sid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario flag catalog passthrough (m/paliad#153 B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderFlagCatalog — GET /api/builder/scenario-flag-catalog
|
||||
//
|
||||
// Returns every row of paliad.scenario_flag_catalog so the Litigation
|
||||
// Builder can render per-triplet flag toggles without a per-project
|
||||
// round-trip. The catalog itself is global (no jurisdiction or
|
||||
// proceeding scope baked into the table); which flags actually apply
|
||||
// to a given proceeding type is decided by the calc engine via
|
||||
// condition_expr at calculation time. The client renders every catalog
|
||||
// flag and lets the user toggle them — flags with no effect on the
|
||||
// active proceeding's rules simply have no condition_expr referencing
|
||||
// them, so toggling is a no-op.
|
||||
//
|
||||
// 503 when ScenarioFlagsService is nil (DATABASE_URL unset); per-row
|
||||
// visibility checks aren't needed because the catalog is global.
|
||||
func handleBuilderFlagCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.scenarioFlags == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Flag-Katalog vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioFlags.ListCatalog(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "Flag-Katalog konnte nicht geladen werden",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>`
|
||||
85
internal/handlers/scenario_flags.go
Normal file
85
internal/handlers/scenario_flags.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GET /api/projects/{id}/scenario-flags returns the project's current
|
||||
// flag map and the catalog. See ScenarioFlagsService.Get for semantics.
|
||||
func handleGetScenarioFlags(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
view, err := dbSvc.scenarioFlags.Get(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// PATCH /api/projects/{id}/scenario-flags merges a partial delta into
|
||||
// the project's scenario_flags. Body shape:
|
||||
//
|
||||
// { "with_ccr": true, "with_amend": null, "rule:<uuid>": false }
|
||||
//
|
||||
// `null` deletes a key from the map so the priority-driven default
|
||||
// returns; bool values are persisted verbatim.
|
||||
func handlePatchScenarioFlags(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode as map[string]*bool so JSON null cleanly resolves to nil
|
||||
// (= delete the key) while bool literals stay distinguishable from
|
||||
// the zero value.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
delta := make(map[string]*bool, len(raw))
|
||||
for k, v := range raw {
|
||||
if len(v) == 0 || string(v) == "null" {
|
||||
delta[k] = nil
|
||||
continue
|
||||
}
|
||||
var b bool
|
||||
if err := json.Unmarshal(v, &b); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "scenario-flag values must be bool or null (got non-bool for key " + k + ")",
|
||||
})
|
||||
return
|
||||
}
|
||||
bv := b
|
||||
delta[k] = &bv
|
||||
}
|
||||
|
||||
view, err := dbSvc.scenarioFlags.Patch(r.Context(), uid, id, delta)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import (
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// submissionDraftPreviewTimeout caps a single preview round-trip.
|
||||
@@ -115,10 +116,14 @@ type submissionDraftJSON struct {
|
||||
// pre-Composer drafts; the editor sidebar surfaces this in the
|
||||
// base picker. PATCH accepts {"base_id": "<uuid>"} or
|
||||
// {"base_id": null} to set or clear.
|
||||
BaseID *uuid.UUID `json:"base_id"`
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
BaseID *uuid.UUID `json:"base_id"`
|
||||
// TemplateVersionID — pinned uploaded docforge template version
|
||||
// (t-paliad-349 slice 7). NULL = base_id/v1 path. The editor's picker
|
||||
// surfaces this; PATCH accepts {"template_version_id": "<uuid>"} | null.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id"`
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// submissionSectionJSON is the on-the-wire row for each per-draft
|
||||
@@ -126,15 +131,15 @@ type submissionDraftJSON struct {
|
||||
// section stack but doesn't yet edit prose. Slice B makes content_md_*
|
||||
// editable + adds the PATCH endpoint.
|
||||
type submissionSectionJSON struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SectionKey string `json:"section_key"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
SectionKey string `json:"section_key"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
}
|
||||
|
||||
type submissionRuleSummary struct {
|
||||
@@ -170,6 +175,16 @@ type submissionDraftPatchInput struct {
|
||||
// admin-recovery flows).
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
BaseIDSet bool `json:"-"`
|
||||
// TemplateVersionID pins an uploaded docforge template version
|
||||
// (t-paliad-349 slice 7). Same three-state presence contract as
|
||||
// base_id: absent = no change, uuid = pin, null = clear.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
TemplateVersionIDSet bool `json:"-"`
|
||||
// FilenameKeyword overrides the leading keyword of the exported
|
||||
// document name (t-paliad-354). Absent = no change; "" = clear back
|
||||
// to the auto-derived rule name; "x" = set. Persisted in
|
||||
// composer_meta.filename_keyword.
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
|
||||
@@ -193,6 +208,9 @@ func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
if _, ok := raw["base_id"]; ok {
|
||||
p.BaseIDSet = true
|
||||
}
|
||||
if _, ok := raw["template_version_id"]; ok {
|
||||
p.TemplateVersionIDSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -433,10 +451,17 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
Variables: input.Variables,
|
||||
SelectedParties: input.SelectedParties,
|
||||
Language: input.Language,
|
||||
FilenameKeyword: input.FilenameKeyword,
|
||||
}
|
||||
if input.BaseIDSet {
|
||||
patch.BaseID = &input.BaseID
|
||||
}
|
||||
if input.TemplateVersionIDSet {
|
||||
if !validateTemplateVersionPin(w, r.Context(), input.TemplateVersionID) {
|
||||
return
|
||||
}
|
||||
patch.TemplateVersionID = &input.TemplateVersionID
|
||||
}
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
@@ -517,7 +542,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
tplBytes, err := previewTemplateBytes(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
@@ -573,7 +598,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
|
||||
|
||||
// Audit + provenance updates are best-effort on a background
|
||||
// context so the download still succeeds if the DB races.
|
||||
@@ -597,6 +622,48 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// validateTemplateVersionPin checks that a non-nil template-version pin
|
||||
// refers to an existing version (404 otherwise), so a PATCH can't bind a
|
||||
// draft to a vanished template. A nil pin (clear) is always valid. Returns
|
||||
// true when the patch may proceed; writes the error response otherwise.
|
||||
func validateTemplateVersionPin(w http.ResponseWriter, ctx context.Context, pin *uuid.UUID) bool {
|
||||
if pin == nil {
|
||||
return true
|
||||
}
|
||||
if dbSvc.templateStore == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
|
||||
return false
|
||||
}
|
||||
if _, err := dbSvc.templateStore.GetVersion(ctx, pin.String()); err != nil {
|
||||
if errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template version not found"})
|
||||
} else {
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// previewTemplateBytes returns the carrier bytes to render a draft's
|
||||
// preview: the pinned uploaded-template version's carrier when set
|
||||
// (t-paliad-349 slice 7), otherwise the resolved upstream submission
|
||||
// template (v1/legacy path). A missing pinned version falls through to the
|
||||
// upstream resolution rather than failing.
|
||||
func previewTemplateBytes(ctx context.Context, d *services.SubmissionDraft) ([]byte, error) {
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
|
||||
if err == nil {
|
||||
return tmpl.CarrierBytes, nil
|
||||
}
|
||||
if !errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
b, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
return b, err
|
||||
}
|
||||
|
||||
// exportSubmissionDraft is the shared render entry point used by both
|
||||
// the project-scoped and global export handlers (t-paliad-313 Slice B).
|
||||
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
|
||||
@@ -607,6 +674,27 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
|
||||
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
|
||||
// t-paliad-349 slice 7 — uploaded-template path, checked first. The
|
||||
// pinned version's carrier already carries {{slots}}; Export resolves
|
||||
// the bag + substitutes them via the same renderer the v1 path uses
|
||||
// (no Composer/sections — the uploaded doc IS the document). A missing
|
||||
// pinned version falls through to the base_id / v1 paths.
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
|
||||
switch {
|
||||
case err == nil:
|
||||
docx, resolved, rerr := dbSvc.submissionDraft.Export(ctx, d, tmpl.CarrierBytes)
|
||||
if rerr != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("render: %w", rerr)
|
||||
}
|
||||
return docx, resolved, "", false, nil
|
||||
case errors.Is(err, docforge.ErrTemplateNotFound):
|
||||
log.Printf("submission_drafts: pinned template version missing (draft=%s version=%s) — falling back", d.ID, *d.TemplateVersionID)
|
||||
default:
|
||||
return nil, nil, "", false, fmt.Errorf("template version lookup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
|
||||
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
|
||||
switch {
|
||||
@@ -853,16 +941,26 @@ type globalDraftPatchInput struct {
|
||||
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
baseIDProvided bool
|
||||
// TemplateVersionID + provided flag — uploaded-template pin
|
||||
// (t-paliad-349 slice 7), same present/absent contract as base_id.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
templateVersionIDProvided bool
|
||||
// FilenameKeyword overrides the leading keyword of the exported
|
||||
// document name (t-paliad-354). Absent = no change; "" = clear; "x" =
|
||||
// set. Persisted in composer_meta.filename_keyword.
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
|
||||
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
type alias struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
@@ -874,14 +972,17 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
g.ProjectID = a.ProjectID
|
||||
g.SelectedParties = a.SelectedParties
|
||||
g.BaseID = a.BaseID
|
||||
// Detect whether "project_id" / "base_id" were present in the JSON
|
||||
// object.
|
||||
g.TemplateVersionID = a.TemplateVersionID
|
||||
g.FilenameKeyword = a.FilenameKeyword
|
||||
// Detect whether "project_id" / "base_id" / "template_version_id" were
|
||||
// present in the JSON object.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
_, g.projectIDProvided = raw["project_id"]
|
||||
_, g.baseIDProvided = raw["base_id"]
|
||||
_, g.templateVersionIDProvided = raw["template_version_id"]
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -917,6 +1018,7 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
Variables: in.Variables,
|
||||
SelectedParties: in.SelectedParties,
|
||||
Language: in.Language,
|
||||
FilenameKeyword: in.FilenameKeyword,
|
||||
}
|
||||
if in.projectIDProvided {
|
||||
pid := in.ProjectID // may be nil → detach
|
||||
@@ -926,6 +1028,13 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
bid := in.BaseID // may be nil → clear
|
||||
patch.BaseID = &bid
|
||||
}
|
||||
if in.templateVersionIDProvided {
|
||||
if !validateTemplateVersionPin(w, r.Context(), in.TemplateVersionID) {
|
||||
return
|
||||
}
|
||||
tv := in.TemplateVersionID // may be nil → clear
|
||||
patch.TemplateVersionID = &tv
|
||||
}
|
||||
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
@@ -1045,7 +1154,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
|
||||
|
||||
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelBG()
|
||||
@@ -1155,6 +1264,23 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
|
||||
}
|
||||
|
||||
// t-paliad-349 slice 7 — uploaded-template draft: render the pinned
|
||||
// carrier. The Gitea tier / language-fallback notions don't apply (they
|
||||
// describe the upstream fallback chain), so they stay at their zero
|
||||
// values. A missing pinned version falls through to upstream resolution.
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
if tmpl, terr := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String()); terr == nil {
|
||||
html, rerr := dbSvc.submissionDraft.RenderPreview(ctx, d, tmpl.CarrierBytes)
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
view.PreviewHTML = html
|
||||
return view, nil
|
||||
} else if !errors.Is(terr, docforge.ErrTemplateNotFound) {
|
||||
return nil, terr
|
||||
}
|
||||
}
|
||||
|
||||
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
|
||||
@@ -1184,11 +1310,11 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
type submissionTemplateTier string
|
||||
|
||||
const (
|
||||
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
|
||||
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
|
||||
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
|
||||
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
|
||||
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
|
||||
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
|
||||
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
|
||||
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
|
||||
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
|
||||
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
|
||||
)
|
||||
|
||||
// resolveSubmissionTemplate returns the .docx bytes for the given
|
||||
@@ -1306,21 +1432,22 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
return submissionDraftJSON{
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
SubmissionCode: d.SubmissionCode,
|
||||
UserID: d.UserID,
|
||||
Name: d.Name,
|
||||
Language: lang,
|
||||
Variables: vars,
|
||||
SelectedParties: selected,
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
BaseID: d.BaseID,
|
||||
ComposerMeta: meta,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
SubmissionCode: d.SubmissionCode,
|
||||
UserID: d.UserID,
|
||||
Name: d.Name,
|
||||
Language: lang,
|
||||
Variables: vars,
|
||||
SelectedParties: selected,
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
BaseID: d.BaseID,
|
||||
TemplateVersionID: d.TemplateVersionID,
|
||||
ComposerMeta: meta,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
144
internal/handlers/submission_filename_test.go
Normal file
144
internal/handlers/submission_filename_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
// Regression tests for the generated-document download name
|
||||
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
|
||||
// The date segment is environment-dependent (Europe/Berlin "today"),
|
||||
// so the assertions pin the keyword + bracketed case-number frame and
|
||||
// the .docx suffix rather than the literal date.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
func strptr(s string) *string { return &s }
|
||||
|
||||
func todayBerlin() string {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
return day.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func TestSubmissionFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
rule := &models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of defence"}
|
||||
date := todayBerlin()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rule *models.DeadlineRule
|
||||
project *models.Project
|
||||
lang string
|
||||
keyword string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "full data — rule name + case number",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "de",
|
||||
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "missing case number falls back to placeholder",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: nil},
|
||||
lang: "de",
|
||||
want: date + " Klageerwiderung (Az. folgt).docx",
|
||||
},
|
||||
{
|
||||
name: "user override keyword wins over rule name",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "de",
|
||||
keyword: "Replik Hauptantrag",
|
||||
want: date + " Replik Hauptantrag (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "EN lang uses NameEN when no override",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "en",
|
||||
want: date + " Statement of defence (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "case number containing slash is sanitised inside brackets",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123/2026")},
|
||||
lang: "de",
|
||||
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "blank override falls back to rule name",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "de",
|
||||
keyword: " ",
|
||||
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "empty rule name + no override falls back to submission",
|
||||
rule: &models.DeadlineRule{Name: "", NameEN: ""},
|
||||
project: &models.Project{CaseNumber: nil},
|
||||
lang: "de",
|
||||
want: date + " submission (Az. folgt).docx",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := submissionFileName(tc.rule, tc.project, tc.lang, tc.keyword)
|
||||
if got != tc.want {
|
||||
t.Errorf("submissionFileName() = %q, want %q", got, tc.want)
|
||||
}
|
||||
if !strings.HasSuffix(got, ".docx") {
|
||||
t.Errorf("filename %q missing .docx suffix", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionFilenameKeyword(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
draft *services.SubmissionDraft
|
||||
want string
|
||||
}{
|
||||
{"nil draft", nil, ""},
|
||||
{"nil meta", &services.SubmissionDraft{}, ""},
|
||||
{
|
||||
"key absent",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"other": "x"}},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"key set",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": "Replik"}},
|
||||
"Replik",
|
||||
},
|
||||
{
|
||||
"key set with surrounding whitespace is trimmed",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": " Replik "}},
|
||||
"Replik",
|
||||
},
|
||||
{
|
||||
"non-string value ignored",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": 42}},
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := submissionFilenameKeyword(tc.draft); got != tc.want {
|
||||
t.Errorf("submissionFilenameKeyword() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -336,7 +336,9 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
// One-click /generate has no saved draft row → no override store, so
|
||||
// the keyword stays the auto-derived rule name (t-paliad-354).
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, "")
|
||||
|
||||
// Audit write is best-effort with a background context so the
|
||||
// download still succeeds if the DB races. Audit failure here only
|
||||
@@ -355,34 +357,66 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// submissionFileName produces the user-facing download name per
|
||||
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
|
||||
// Empty case_number drops the segment entirely (no fallback hash —
|
||||
// the lawyer can rename if the project lacks an Aktenzeichen).
|
||||
// Umlauts in the rule name are ASCII-folded by SanitiseSubmissionFileName
|
||||
// so the file lands cleanly on legacy SMB shares.
|
||||
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang string) string {
|
||||
// submissionNoCaseNumberPlaceholder fills the bracketed case-number slot
|
||||
// when the project has no Aktenzeichen yet. Kept as a named const so the
|
||||
// wording is one-line changeable (m left the exact text open, t-paliad-354).
|
||||
const submissionNoCaseNumberPlaceholder = "Az. folgt"
|
||||
|
||||
// submissionFileName produces the user-facing download name
|
||||
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
|
||||
//
|
||||
// - Date first (Europe/Berlin) so the files sort chronologically.
|
||||
// - keyword is the user override when set, else the lang-aware rule
|
||||
// name, else "submission".
|
||||
// - The case number is always rendered in parentheses; when the project
|
||||
// has no Aktenzeichen it falls back to submissionNoCaseNumberPlaceholder.
|
||||
//
|
||||
// Each segment is run through SanitiseSubmissionFileName (umlaut-folds for
|
||||
// legacy SMB shares, strips the Windows-reserved set so a case number like
|
||||
// "UPC_CFI_123/2026" stays safe) while the assembled "<date> <kw> (<case>)"
|
||||
// frame keeps its spaces and brackets — the sanitiser preserves both.
|
||||
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
ruleName := strings.TrimSpace(rule.Name)
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
||||
ruleName = strings.TrimSpace(rule.NameEN)
|
||||
kw := strings.TrimSpace(keyword)
|
||||
if kw == "" {
|
||||
kw = strings.TrimSpace(rule.Name)
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
||||
kw = strings.TrimSpace(rule.NameEN)
|
||||
}
|
||||
}
|
||||
if ruleName == "" {
|
||||
ruleName = "submission"
|
||||
if kw == "" {
|
||||
kw = "submission"
|
||||
}
|
||||
parts := []string{services.SanitiseSubmissionFileName(ruleName)}
|
||||
caseNo := ""
|
||||
if project != nil && project.CaseNumber != nil {
|
||||
caseNo = strings.TrimSpace(*project.CaseNumber)
|
||||
}
|
||||
if caseNo != "" {
|
||||
parts = append(parts, services.SanitiseSubmissionFileName(caseNo))
|
||||
if caseNo == "" {
|
||||
caseNo = submissionNoCaseNumberPlaceholder
|
||||
}
|
||||
parts = append(parts, day.Format("2006-01-02"))
|
||||
return strings.Join(parts, "-") + ".docx"
|
||||
return fmt.Sprintf("%s %s (%s).docx",
|
||||
day.Format("2006-01-02"),
|
||||
services.SanitiseSubmissionFileName(kw),
|
||||
services.SanitiseSubmissionFileName(caseNo),
|
||||
)
|
||||
}
|
||||
|
||||
// submissionFilenameKeyword pulls the user's filename keyword override
|
||||
// from a saved draft's composer_meta jsonb (t-paliad-354). Empty when the
|
||||
// key is absent or blank — callers then fall back to the auto-derived rule
|
||||
// name inside submissionFileName. The one-click /generate path has no draft
|
||||
// row and always passes "".
|
||||
func submissionFilenameKeyword(d *services.SubmissionDraft) string {
|
||||
if d == nil || d.ComposerMeta == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := d.ComposerMeta["filename_keyword"].(string); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// writeSubmissionAuditRow files one row in paliad.system_audit_log per
|
||||
|
||||
306
internal/handlers/templates.go
Normal file
306
internal/handlers/templates.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package handlers
|
||||
|
||||
// docforge template authoring handlers (t-paliad-349 slice 6).
|
||||
//
|
||||
// The admin-only authoring surface: upload a base .docx, see it rendered as
|
||||
// run-addressable text, place {{variable}} slots into it, and save the
|
||||
// result as a reusable template. Backed by docforge.TemplateStore
|
||||
// (Postgres bytea carrier) + the docx authoring engine
|
||||
// (ImportForAuthoring / InjectSlot).
|
||||
//
|
||||
// Endpoints (all under adminGate — templates are firm-shared, admin-
|
||||
// authored, like submission_bases):
|
||||
// GET /api/admin/templates — catalog list
|
||||
// POST /api/admin/templates — multipart upload → create v1
|
||||
// GET /api/admin/templates/{id} — authoring view (preview+slots)
|
||||
// POST /api/admin/templates/{id}/slots — place a slot → new version
|
||||
//
|
||||
// Slot placement creates a new template version (immutable snapshot) per
|
||||
// placement. That keeps the snapshot guarantee simple; batching a whole
|
||||
// authoring session into one version on an explicit "save" is a documented
|
||||
// future refinement (it trades the version-per-slot churn for a client- or
|
||||
// session-held draft carrier).
|
||||
//
|
||||
// VERIFICATION CEILING: the live upload→render→select→inject→save flow
|
||||
// needs the app running with DATABASE_URL + Supabase auth + Playwright; it
|
||||
// is verified post-merge. The docx surgery (ImportForAuthoring/InjectSlot)
|
||||
// and the store are unit/live-tested independently.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// maxTemplateUpload bounds an uploaded .docx. Templates are firm letterhead
|
||||
// + chrome — tens of KB in practice; 10 MB is a generous ceiling.
|
||||
const maxTemplateUpload = 10 << 20
|
||||
|
||||
type templateMetaJSON struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
NameDE string `json:"name_de"`
|
||||
NameEN string `json:"name_en"`
|
||||
Kind string `json:"kind"`
|
||||
SourceFormat string `json:"source_format"`
|
||||
Firm string `json:"firm,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Version int `json:"version"`
|
||||
VersionID string `json:"version_id,omitempty"`
|
||||
}
|
||||
|
||||
type templateSlotJSON struct {
|
||||
Key string `json:"key"`
|
||||
Anchor string `json:"anchor"`
|
||||
Label string `json:"label,omitempty"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
}
|
||||
|
||||
type authoringViewJSON struct {
|
||||
Template templateMetaJSON `json:"template"`
|
||||
PreviewHTML string `json:"preview_html"`
|
||||
Slots []templateSlotJSON `json:"slots"`
|
||||
}
|
||||
|
||||
func metaJSON(m docforge.TemplateMeta) templateMetaJSON {
|
||||
return templateMetaJSON{
|
||||
ID: m.ID, Slug: m.Slug, NameDE: m.NameDE, NameEN: m.NameEN,
|
||||
Kind: m.Kind, SourceFormat: m.SourceFormat, Firm: m.Firm,
|
||||
IsActive: m.IsActive, Version: m.Version, VersionID: m.VersionID,
|
||||
}
|
||||
}
|
||||
|
||||
func slotsJSON(slots []docforge.TemplateSlot) []templateSlotJSON {
|
||||
out := make([]templateSlotJSON, 0, len(slots))
|
||||
for _, s := range slots {
|
||||
out = append(out, templateSlotJSON{Key: s.Key, Anchor: s.Anchor, Label: s.Label, OrderIndex: s.OrderIndex})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeTemplateError maps docforge's not-found sentinel to 404 and falls
|
||||
// back to the shared service-error mapper.
|
||||
func writeTemplateError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
|
||||
func requireTemplateStore(w http.ResponseWriter) bool {
|
||||
if !requireDB(w) {
|
||||
return false
|
||||
}
|
||||
if dbSvc.templateStore == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleTemplatesAuthoringPage serves the authoring page shell. The client
|
||||
// bundle hydrates the list, upload, preview, palette, and slots.
|
||||
func handleTemplatesAuthoringPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/templates-authoring.html")
|
||||
}
|
||||
|
||||
// handleListTemplates backs GET /api/admin/templates.
|
||||
func handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
metas, err := dbSvc.templateStore.List(r.Context(), docforge.TemplateFilter{ActiveOnly: true})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]templateMetaJSON, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
out = append(out, metaJSON(m))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
|
||||
}
|
||||
|
||||
// handlePickerTemplates backs GET /api/templates — the firm-shared catalog
|
||||
// any authenticated lawyer reads to pick an uploaded template for
|
||||
// generation (t-paliad-349 slice 7). Unlike the admin list it filters by
|
||||
// firm (the deployment's branding firm + firm-agnostic templates), matching
|
||||
// the submission_bases picker contract. Metadata only — no carrier bytes.
|
||||
func handlePickerTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
metas, err := dbSvc.templateStore.List(r.Context(),
|
||||
docforge.TemplateFilter{Firm: branding.Name, ActiveOnly: true})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]templateMetaJSON, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
out = append(out, metaJSON(m))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
|
||||
}
|
||||
|
||||
// handleUploadTemplate backs POST /api/admin/templates (multipart). Reads
|
||||
// the uploaded .docx, validates it parses, detects any slots already in it,
|
||||
// and creates the template at version 1.
|
||||
func handleUploadTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxTemplateUpload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
carrier, err := io.ReadAll(io.LimitReader(file, maxTemplateUpload))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not read uploaded file"})
|
||||
return
|
||||
}
|
||||
nameDE := r.FormValue("name_de")
|
||||
nameEN := r.FormValue("name_en")
|
||||
if nameDE == "" || nameEN == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name_de and name_en required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate + detect existing slots before persisting.
|
||||
view, err := docx.ImportForAuthoring(carrier)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "not a parseable .docx: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := dbSvc.templateStore.Create(r.Context(),
|
||||
docforge.TemplateMetaInput{
|
||||
Slug: r.FormValue("slug"),
|
||||
NameDE: nameDE,
|
||||
NameEN: nameEN,
|
||||
Firm: r.FormValue("firm"),
|
||||
CreatedBy: uid.String(),
|
||||
},
|
||||
docforge.TemplateVersionInput{
|
||||
CarrierBytes: carrier,
|
||||
Slots: view.Slots,
|
||||
CreatedBy: uid.String(),
|
||||
})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, authoringViewJSON{
|
||||
Template: metaJSON(tmpl.TemplateMeta),
|
||||
PreviewHTML: view.PreviewHTML,
|
||||
Slots: slotsJSON(tmpl.Slots),
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetTemplateAuthoring backs GET /api/admin/templates/{id} — the
|
||||
// authoring view: current carrier rendered run-addressable + its slots.
|
||||
func handleGetTemplateAuthoring(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
tmpl, err := dbSvc.templateStore.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := docx.ImportForAuthoring(tmpl.CarrierBytes)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "stored carrier failed to parse: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, authoringViewJSON{
|
||||
Template: metaJSON(tmpl.TemplateMeta),
|
||||
PreviewHTML: view.PreviewHTML,
|
||||
Slots: slotsJSON(view.Slots),
|
||||
})
|
||||
}
|
||||
|
||||
type placeSlotInput struct {
|
||||
RunIndex int `json:"run_index"`
|
||||
SelectedText string `json:"selected_text"`
|
||||
SlotKey string `json:"slot_key"`
|
||||
}
|
||||
|
||||
// handlePlaceTemplateSlot backs POST /api/admin/templates/{id}/slots —
|
||||
// inject a slot at the selection and persist as a new version.
|
||||
func handlePlaceTemplateSlot(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in placeSlotInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
tmpl, err := dbSvc.templateStore.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
newCarrier, err := docx.InjectSlot(tmpl.CarrierBytes, in.RunIndex, in.SelectedText, in.SlotKey)
|
||||
if err != nil {
|
||||
// Injection failures are client-fixable (bad selection / key).
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Re-detect slots from the new carrier so template_slots mirrors the
|
||||
// carrier's actual {{tokens}} (single source of truth).
|
||||
newView, err := docx.ImportForAuthoring(newCarrier)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("post-inject parse: %v", err)})
|
||||
return
|
||||
}
|
||||
updated, err := dbSvc.templateStore.AddVersion(r.Context(), id,
|
||||
docforge.TemplateVersionInput{
|
||||
CarrierBytes: newCarrier,
|
||||
Stylemap: tmpl.Stylemap,
|
||||
Slots: newView.Slots,
|
||||
CreatedBy: uid.String(),
|
||||
})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, authoringViewJSON{
|
||||
Template: metaJSON(updated.TemplateMeta),
|
||||
PreviewHTML: newView.PreviewHTML,
|
||||
Slots: slotsJSON(updated.Slots),
|
||||
})
|
||||
}
|
||||
@@ -6,78 +6,54 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// /tools/fristenrechner?path=a was the pre-split sidebar entry for the
|
||||
// "Verfahrensablauf" surface. After t-paliad-179 Slice 1 that intent
|
||||
// owns its own /tools/verfahrensablauf route — so a naked ?path=a hit
|
||||
// must 302 to the new URL to preserve bookmarked legacy links.
|
||||
//
|
||||
// The Akte-mode internal wizard pathway (?project=<uuid>&path=a) is
|
||||
// NOT a top-level entry — it's wizard state set by client-side
|
||||
// history.replaceState. That URL must keep serving the fristenrechner
|
||||
// shell so a mid-wizard refresh doesn't bounce away.
|
||||
func TestHandleFristenrechnerPage_LegacyPathARedirect(t *testing.T) {
|
||||
// U4 (m/paliad#151) — both legacy URLs hard-cut to /tools/procedures
|
||||
// with HTTP 301. Query strings carry through so bookmarks like
|
||||
// /tools/fristenrechner?event=upc.inf.cfi.soc&trigger_date=2026-04-01
|
||||
// resolve to /tools/procedures?event=…&trigger_date=… without losing
|
||||
// the user's intent.
|
||||
func TestLegacyToolsPagesRedirect(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantLoc string
|
||||
name string
|
||||
path string
|
||||
handler func(http.ResponseWriter, *http.Request)
|
||||
wantLoc string
|
||||
}{
|
||||
{
|
||||
name: "naked path=a → redirect",
|
||||
path: "/tools/fristenrechner?path=a",
|
||||
wantStatus: http.StatusFound,
|
||||
wantLoc: "/tools/verfahrensablauf",
|
||||
name: "fristenrechner naked",
|
||||
path: "/tools/fristenrechner",
|
||||
handler: handleFristenrechnerPage,
|
||||
wantLoc: "/tools/procedures",
|
||||
},
|
||||
{
|
||||
name: "path=a with project= → no redirect (Akte-mode wizard)",
|
||||
path: "/tools/fristenrechner?project=abc-123&path=a",
|
||||
wantStatus: http.StatusOK,
|
||||
name: "fristenrechner with query",
|
||||
path: "/tools/fristenrechner?event=upc.inf.cfi.soc&trigger_date=2026-04-01",
|
||||
handler: handleFristenrechnerPage,
|
||||
wantLoc: "/tools/procedures?event=upc.inf.cfi.soc&trigger_date=2026-04-01",
|
||||
},
|
||||
{
|
||||
name: "no path param → no redirect",
|
||||
path: "/tools/fristenrechner",
|
||||
wantStatus: http.StatusOK,
|
||||
name: "verfahrensablauf naked",
|
||||
path: "/tools/verfahrensablauf",
|
||||
handler: handleVerfahrensablaufPage,
|
||||
wantLoc: "/tools/procedures",
|
||||
},
|
||||
{
|
||||
name: "path=b → no redirect (Pathway B stays)",
|
||||
path: "/tools/fristenrechner?path=b",
|
||||
wantStatus: http.StatusOK,
|
||||
name: "verfahrensablauf with proceeding",
|
||||
path: "/tools/verfahrensablauf?proceeding=upc.inf.cfi&side=claimant",
|
||||
handler: handleVerfahrensablaufPage,
|
||||
wantLoc: "/tools/procedures?proceeding=upc.inf.cfi&side=claimant",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleFristenrechnerPage(w, req)
|
||||
if w.Code != tc.wantStatus {
|
||||
// http.ServeFile may write 404 if dist/fristenrechner.html
|
||||
// is missing under `go test` (CI runs without a frontend
|
||||
// build). We only care that we did NOT redirect in those
|
||||
// cases — collapse 200 and 404 into "not a redirect".
|
||||
if tc.wantStatus == http.StatusOK && w.Code != http.StatusFound {
|
||||
return
|
||||
}
|
||||
t.Fatalf("status = %d, want %d", w.Code, tc.wantStatus)
|
||||
tc.handler(w, req)
|
||||
if w.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusMovedPermanently)
|
||||
}
|
||||
if tc.wantLoc != "" {
|
||||
if got := w.Header().Get("Location"); got != tc.wantLoc {
|
||||
t.Fatalf("Location = %q, want %q", got, tc.wantLoc)
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != tc.wantLoc {
|
||||
t.Fatalf("Location = %q, want %q", got, tc.wantLoc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The new /tools/verfahrensablauf route registers as a 1-liner page
|
||||
// handler that ServeFiles dist/verfahrensablauf.html. We assert the
|
||||
// handler does NOT redirect — if the dist artefact is missing under
|
||||
// `go test`, ServeFile may return 404, but it must never return a 3xx.
|
||||
func TestHandleVerfahrensablaufPage_NoRedirect(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/tools/verfahrensablauf", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleVerfahrensablaufPage(w, req)
|
||||
if w.Code >= 300 && w.Code < 400 {
|
||||
t.Fatalf("verfahrensablauf must not redirect; got %d → %s",
|
||||
w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
136
internal/services/condition_expr_validator.go
Normal file
136
internal/services/condition_expr_validator.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// condition_expr grammar per design §4.1 (m/paliad#149 Phase 2 P2):
|
||||
//
|
||||
// CondExpr := { "flag": "<known_flag>" }
|
||||
// | { "op": "and"|"or", "args": [<CondExpr>, <CondExpr>, ...] }
|
||||
//
|
||||
// Leaf nodes reference a flag in paliad.scenario_flag_catalog by key.
|
||||
// Composite nodes are recursive — and/or take ≥1 arg each. JSON null
|
||||
// (or empty bytes) is also accepted — that's the "no gate" shape and
|
||||
// stores as a NULL column.
|
||||
//
|
||||
// The validator is called from RuleEditorService.Create and
|
||||
// UpdateDraft before the row is written. Surfaces friendly errors
|
||||
// wrapping ErrInvalidInput so the handler maps cleanly to 400.
|
||||
|
||||
// ValidateConditionExpr parses the bytes as a CondExpr and verifies
|
||||
// every leaf flag is present in the scenario_flag_catalog (one DB
|
||||
// lookup, regardless of expression depth). Empty/null input is OK —
|
||||
// caller stores NULL.
|
||||
func ValidateConditionExpr(ctx context.Context, db *sqlx.DB, raw json.RawMessage) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
var parsed condExprNode
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return fmt.Errorf("%w: condition_expr is not valid JSON: %v", ErrInvalidInput, err)
|
||||
}
|
||||
flagNames := map[string]struct{}{}
|
||||
if err := walkCondExpr(&parsed, flagNames); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(flagNames) == 0 {
|
||||
// Empty leaf set is impossible for a valid CondExpr — walkCondExpr
|
||||
// would have rejected it. Defensive belt-and-braces.
|
||||
return fmt.Errorf("%w: condition_expr resolved to zero leaf flags", ErrInvalidInput)
|
||||
}
|
||||
keys := make([]string, 0, len(flagNames))
|
||||
for k := range flagNames {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
known, err := loadCatalogFlagKeys(ctx, db, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if _, ok := known[k]; !ok {
|
||||
return fmt.Errorf("%w: condition_expr references unknown flag %q (not in paliad.scenario_flag_catalog)", ErrInvalidInput, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// condExprNode is the loose-typed parse target. Either Flag is set
|
||||
// (leaf) or Op + Args (composite); the validator below enforces
|
||||
// mutual exclusivity.
|
||||
type condExprNode struct {
|
||||
Flag *string `json:"flag,omitempty"`
|
||||
Op *string `json:"op,omitempty"`
|
||||
Args []condExprNode `json:"args,omitempty"`
|
||||
// Extra catches stray keys so we can reject typos like "fla" or
|
||||
// "operator" loudly instead of silently treating them as composite.
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// walkCondExpr descends the tree, collecting flag names and validating
|
||||
// every node's shape.
|
||||
func walkCondExpr(n *condExprNode, flagNames map[string]struct{}) error {
|
||||
hasFlag := n.Flag != nil
|
||||
hasOp := n.Op != nil
|
||||
hasArgs := n.Args != nil
|
||||
|
||||
if hasFlag && (hasOp || hasArgs) {
|
||||
return fmt.Errorf("%w: condition_expr node has both 'flag' and 'op'/'args' — leaf and composite shapes are mutually exclusive", ErrInvalidInput)
|
||||
}
|
||||
if !hasFlag && !hasOp {
|
||||
return fmt.Errorf("%w: condition_expr node must carry either 'flag' (leaf) or 'op'+'args' (composite)", ErrInvalidInput)
|
||||
}
|
||||
|
||||
if hasFlag {
|
||||
if *n.Flag == "" {
|
||||
return fmt.Errorf("%w: condition_expr leaf has empty flag", ErrInvalidInput)
|
||||
}
|
||||
flagNames[*n.Flag] = struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Composite — op must be "and" or "or"; args must be non-empty.
|
||||
op := *n.Op
|
||||
if op != "and" && op != "or" {
|
||||
return fmt.Errorf("%w: condition_expr op=%q must be 'and' or 'or'", ErrInvalidInput, op)
|
||||
}
|
||||
if len(n.Args) == 0 {
|
||||
return fmt.Errorf("%w: condition_expr composite op=%q has empty args", ErrInvalidInput, op)
|
||||
}
|
||||
for i := range n.Args {
|
||||
if err := walkCondExpr(&n.Args[i], flagNames); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadCatalogFlagKeys returns the subset of `flagKeys` present in
|
||||
// paliad.scenario_flag_catalog. One round-trip regardless of how many
|
||||
// keys the expression carries.
|
||||
func loadCatalogFlagKeys(ctx context.Context, db *sqlx.DB, flagKeys []string) (map[string]struct{}, error) {
|
||||
if len(flagKeys) == 0 {
|
||||
return map[string]struct{}{}, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT flag_key FROM paliad.scenario_flag_catalog WHERE flag_key = ANY($1)`,
|
||||
pq.Array(flagKeys))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup scenario_flag_catalog: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
166
internal/services/condition_expr_validator_test.go
Normal file
166
internal/services/condition_expr_validator_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// openTestPool returns a sqlx.DB connected via TEST_DATABASE_URL.
|
||||
// Returns nil + skips the test when the env var is unset, mirroring
|
||||
// the pattern used by sibling live-DB tests in this package.
|
||||
func openTestPool(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// TestValidateConditionExprShapes covers the grammar shapes (leaf,
|
||||
// composite, nested composite) and the rejection paths. The catalog
|
||||
// lookup is exercised via the live DB in TestValidateConditionExpr_Live18
|
||||
// below; here we use json-only shape checks to keep the unit tests
|
||||
// independent of database availability.
|
||||
func TestValidateConditionExprShapes(t *testing.T) {
|
||||
// Bypass the DB-backed flag-existence check by passing nil db with
|
||||
// an expression that has no leaves once unmarshalled. Since the
|
||||
// grammar walker rejects empty/invalid shapes BEFORE the DB lookup,
|
||||
// shape-only assertions work without a pool. For the leaf-flag
|
||||
// existence check we'd need a fixture DB — that's the live test.
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantError string // empty = success-path placeholder
|
||||
wantInvalid bool
|
||||
}{
|
||||
{name: "empty input", input: ``, wantInvalid: false},
|
||||
{name: "JSON null", input: `null`, wantInvalid: false},
|
||||
{name: "bad JSON", input: `{flag:`, wantInvalid: true, wantError: "valid JSON"},
|
||||
{name: "leaf with empty flag", input: `{"flag":""}`, wantInvalid: true, wantError: "empty flag"},
|
||||
{name: "leaf AND op", input: `{"flag":"x","op":"and"}`, wantInvalid: true, wantError: "mutually exclusive"},
|
||||
{name: "neither flag nor op", input: `{}`, wantInvalid: true, wantError: "must carry either"},
|
||||
{name: "bad op", input: `{"op":"xor","args":[{"flag":"x"}]}`, wantInvalid: true, wantError: "must be 'and' or 'or'"},
|
||||
{name: "empty args", input: `{"op":"and","args":[]}`, wantInvalid: true, wantError: "empty args"},
|
||||
{name: "nested bad shape", input: `{"op":"and","args":[{"flag":"x"},{"flag":""}]}`, wantInvalid: true, wantError: "empty flag"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := ValidateConditionExpr(ctx, nil, json.RawMessage(c.input))
|
||||
if c.wantInvalid {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("error %v is not ErrInvalidInput", err)
|
||||
}
|
||||
if c.wantError != "" && !strings.Contains(err.Error(), c.wantError) {
|
||||
t.Errorf("error %q missing substring %q", err.Error(), c.wantError)
|
||||
}
|
||||
return
|
||||
}
|
||||
// success-path: empty/null inputs go through without an err.
|
||||
// Anything else hits the DB lookup with nil pool → nil-deref;
|
||||
// that path is covered by the live test below.
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for %q, got %v", c.input, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateConditionExpr_LiveCatalog runs the validator against the
|
||||
// real paliad.scenario_flag_catalog (the 3 seeded flags) using a sample
|
||||
// of each grammar shape. Skips when DATABASE_URL isn't set.
|
||||
func TestValidateConditionExpr_LiveCatalog(t *testing.T) {
|
||||
pool := openTestPool(t)
|
||||
if pool == nil {
|
||||
t.Skip("DATABASE_URL not set — skipping live-catalog validation")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
good := []string{
|
||||
`{"flag":"with_ccr"}`,
|
||||
`{"flag":"with_amend"}`,
|
||||
`{"flag":"with_cci"}`,
|
||||
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
||||
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_cci"}]}`,
|
||||
`{"op":"and","args":[{"flag":"with_ccr"},{"op":"or","args":[{"flag":"with_amend"},{"flag":"with_cci"}]}]}`,
|
||||
}
|
||||
for _, g := range good {
|
||||
if err := ValidateConditionExpr(ctx, pool, json.RawMessage(g)); err != nil {
|
||||
t.Errorf("expected %q to validate, got %v", g, err)
|
||||
}
|
||||
}
|
||||
|
||||
bad := []struct{ in, contains string }{
|
||||
{`{"flag":"with_nonsense"}`, "unknown flag"},
|
||||
{`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"never_seen"}]}`, "unknown flag"},
|
||||
}
|
||||
for _, b := range bad {
|
||||
err := ValidateConditionExpr(ctx, pool, json.RawMessage(b.in))
|
||||
if err == nil {
|
||||
t.Errorf("expected %q to fail validation", b.in)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), b.contains) {
|
||||
t.Errorf("error %q for %q missing substring %q", err.Error(), b.in, b.contains)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConditionExpr_AllLiveRowsValidate exercises the validator on every
|
||||
// row currently in paliad.sequencing_rules. Per design §4.1: "all 18
|
||||
// existing rows must validate" — this test enforces the invariant on
|
||||
// every deploy so a new editorial entry that breaks the grammar fails
|
||||
// CI before it lands.
|
||||
func TestConditionExpr_AllLiveRowsValidate(t *testing.T) {
|
||||
pool := openTestPool(t)
|
||||
if pool == nil {
|
||||
t.Skip("DATABASE_URL not set — skipping live-rows test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := pool.QueryContext(ctx,
|
||||
`SELECT id, condition_expr::text
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE condition_expr IS NOT NULL
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'`)
|
||||
if err != nil {
|
||||
t.Fatalf("load condition_expr rows: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var id, expr string
|
||||
if err := rows.Scan(&id, &expr); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
count++
|
||||
if err := ValidateConditionExpr(ctx, pool, json.RawMessage(expr)); err != nil {
|
||||
t.Errorf("rule %s carries non-conforming condition_expr %s: %v", id, expr, err)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows err: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Skip("no condition_expr rows in DB — nothing to validate")
|
||||
}
|
||||
t.Logf("validated %d live condition_expr rows", count)
|
||||
}
|
||||
@@ -853,19 +853,24 @@ func buildPill(p pillRow) Pill {
|
||||
}
|
||||
|
||||
func pillDrillURL(p pillRow) string {
|
||||
// m/paliad#151 U4 — drill-in URLs target /tools/procedures, the
|
||||
// unified successor to /tools/fristenrechner and
|
||||
// /tools/verfahrensablauf. The legacy URLs still 301-redirect, so
|
||||
// any cached snapshot keeps working, but new searches land on the
|
||||
// new page directly.
|
||||
switch p.Kind {
|
||||
case "rule":
|
||||
if p.ProceedingCode.Valid && p.RuleLocalCode != "" {
|
||||
return "/tools/fristenrechner?proc=" + p.ProceedingCode.String + "&focus=" + p.RuleLocalCode
|
||||
return "/tools/procedures?proc=" + p.ProceedingCode.String + "&focus=" + p.RuleLocalCode
|
||||
}
|
||||
return "/tools/fristenrechner"
|
||||
return "/tools/procedures"
|
||||
case "trigger":
|
||||
if p.TriggerEventID.Valid {
|
||||
return fmt.Sprintf("/tools/fristenrechner?mode=event&triggerId=%d", p.TriggerEventID.Int64)
|
||||
return fmt.Sprintf("/tools/procedures?mode=event&triggerId=%d", p.TriggerEventID.Int64)
|
||||
}
|
||||
return "/tools/fristenrechner?mode=event"
|
||||
return "/tools/procedures?mode=event"
|
||||
}
|
||||
return "/tools/fristenrechner"
|
||||
return "/tools/procedures"
|
||||
}
|
||||
|
||||
// pillSortKey orders pills inside a card. Rule pills before triggers;
|
||||
|
||||
@@ -265,7 +265,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
|
||||
query := `
|
||||
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.warning_date, f.source, f.sequencing_rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
|
||||
f.created_at, f.updated_at,
|
||||
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
|
||||
|
||||
66
internal/services/docforge_shims.go
Normal file
66
internal/services/docforge_shims.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package services
|
||||
|
||||
// Shims bridging the submission generator to the extracted docforge .docx
|
||||
// adapter (pkg/docforge/docx). Slice 1 of the docforge train
|
||||
// (t-paliad-349 / m/paliad#157) relocated the Markdown→OOXML walker, the
|
||||
// placeholder substitution engine, and the .dotm→.docx converter into
|
||||
// pkg/docforge/docx with no behaviour change. These type aliases and
|
||||
// forwarders keep every existing caller in internal/services and
|
||||
// internal/handlers compiling and behaving identically — the names,
|
||||
// signatures, and semantics are unchanged; only the implementation moved.
|
||||
//
|
||||
// Later slices retire these shims as the submission services are
|
||||
// refactored to call docforge directly through the neutral model and the
|
||||
// VariableResolver interface.
|
||||
|
||||
import (
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// PlaceholderMap is the variable bag (dotted-key → substituted value),
|
||||
// built by SubmissionVarsService and consumed by the renderer. The
|
||||
// canonical type lives in the docforge root (the format-neutral
|
||||
// variable-bag contract).
|
||||
type PlaceholderMap = docforge.PlaceholderMap
|
||||
|
||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||
// in-document marker token.
|
||||
type MissingPlaceholderFn = docforge.MissingPlaceholderFn
|
||||
|
||||
// SubmissionRenderer renders a .docx template by substituting
|
||||
// {{placeholder}} tokens. Stateless; safe for concurrent use.
|
||||
type SubmissionRenderer = docx.SubmissionRenderer
|
||||
|
||||
// HyperlinkAllocator hands the Markdown walker a rId for each external
|
||||
// URL it encounters in [label](url) inline links.
|
||||
type HyperlinkAllocator = docx.HyperlinkAllocator
|
||||
|
||||
// NewSubmissionRenderer constructs the renderer.
|
||||
func NewSubmissionRenderer() *SubmissionRenderer { return docx.NewSubmissionRenderer() }
|
||||
|
||||
// DefaultMissingMarker returns the standard missing-value marker for the
|
||||
// given UI language ("[KEIN WERT: <key>]" / "[NO VALUE: <key>]").
|
||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
return docforge.DefaultMissingMarker(lang)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXML renders Markdown source into OOXML paragraph
|
||||
// elements using a single paragraph style.
|
||||
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
|
||||
return docx.RenderMarkdownToOOXML(md, paragraphStyle)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXMLWithStyles is the full rich-prose entry point
|
||||
// (headings, lists, blockquote, inline hyperlinks via the allocator).
|
||||
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
|
||||
return docx.RenderMarkdownToOOXMLWithStyles(md, stylemap, links)
|
||||
}
|
||||
|
||||
// ConvertDotmToDocx rewrites a .dotm/.docm/.dotx zip into a clean .docx
|
||||
// zip. Idempotent on a zip that is already a plain .docx.
|
||||
func ConvertDotmToDocx(dotmBytes []byte) ([]byte, error) { return docx.ConvertDotmToDocx(dotmBytes) }
|
||||
|
||||
// SanitiseSubmissionFileName cleans a string for use inside a download
|
||||
// filename (strips path separators / quotes, ASCII-folds DE umlauts).
|
||||
func SanitiseSubmissionFileName(s string) string { return docx.SanitiseSubmissionFileName(s) }
|
||||
@@ -145,7 +145,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
|
||||
AND pe.event_kind = $%d
|
||||
)`, opts.EventKind)
|
||||
}
|
||||
query := `SELECT code, name, name_en, jurisdiction
|
||||
query := `SELECT id, code, name, name_en, jurisdiction
|
||||
FROM paliad.proceeding_types
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY sort_order`
|
||||
@@ -160,7 +160,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
|
||||
for rows.Next() {
|
||||
var t lp.FristenrechnerType
|
||||
var juris sql.NullString
|
||||
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if juris.Valid {
|
||||
|
||||
@@ -52,6 +52,14 @@ type FollowUpRule struct {
|
||||
TitleEN string `json:"title_en"`
|
||||
Priority string `json:"priority"`
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
// IsCrossParty is true when the requesting party is "claimant" or
|
||||
// "defendant" AND the rule's primary_party is the opposite side
|
||||
// (m/paliad#149 Phase 2 S1, design §2.4). Cross-party rows are
|
||||
// displayed with a `Gegenseitig` badge + muted style + unchecked
|
||||
// default, and are unconditionally excluded from the Akte
|
||||
// "Save as project deadlines" write-back. NULL/both/court rules
|
||||
// are never cross-party regardless of perspective.
|
||||
IsCrossParty bool `json:"is_cross_party"`
|
||||
DurationValue *int `json:"duration_value,omitempty"`
|
||||
DurationUnit *string `json:"duration_unit,omitempty"`
|
||||
Timing *string `json:"timing,omitempty"`
|
||||
@@ -80,10 +88,14 @@ type FollowUpRule struct {
|
||||
// (the sequencing_rule with procedural_event_id matching), then walks
|
||||
// one hop down via parent_id to collect immediate follow-ups.
|
||||
//
|
||||
// When party is non-empty, follow-ups are filtered to rules whose
|
||||
// primary_party matches OR is "both" (so a defendant filter still
|
||||
// returns bilateral procedural moves like Vertraulichkeitsantrag-
|
||||
// Erwiderung).
|
||||
// Cross-party display (m/paliad#149 Phase 2 S1, design §2.4): the server
|
||||
// returns ALL follow-ups regardless of party — including the opposing
|
||||
// side's filings — and annotates each row with `is_cross_party` so the
|
||||
// UI can render the Gegenseitig badge + muted style. The party param
|
||||
// is kept as a perspective qualifier (it drives is_cross_party computation
|
||||
// and remains in the response context), but no longer filters which
|
||||
// rows are returned. Honest UX: the workflow continues on the other
|
||||
// side and the lawyer can see what they file vs what they receive.
|
||||
func (s *FristenrechnerService) LookupFollowUps(
|
||||
ctx context.Context,
|
||||
eventRef string,
|
||||
@@ -116,7 +128,7 @@ func (s *FristenrechnerService) LookupFollowUps(
|
||||
// Pull the proceeding_type metadata once so we can pass it
|
||||
// downstream to populate the trigger card and to seed the
|
||||
// CalculateRule lookup (which uses RuleID anyway).
|
||||
rows, err := s.queryFollowUpRows(ctx, anchor.AnchorRuleID, party)
|
||||
rows, err := s.queryFollowUpRows(ctx, anchor.AnchorRuleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -137,6 +149,7 @@ func (s *FristenrechnerService) LookupFollowUps(
|
||||
v := r.PrimaryParty.String
|
||||
fr.PrimaryParty = &v
|
||||
}
|
||||
fr.IsCrossParty = isCrossPartyRow(party, r.PrimaryParty)
|
||||
if r.DurationValue.Valid {
|
||||
v := int(r.DurationValue.Int32)
|
||||
fr.DurationValue = &v
|
||||
@@ -340,12 +353,18 @@ type followUpRow struct {
|
||||
}
|
||||
|
||||
// queryFollowUpRows pulls the immediate-children rules of an anchor.
|
||||
// Party filter is inclusive of "both" so bilateral moves stay visible
|
||||
// when the user picks claimant or defendant.
|
||||
//
|
||||
// Cross-party display (m/paliad#149 Phase 2 S1, design §2.4): no longer
|
||||
// filters by party. The server returns every published+active child;
|
||||
// LookupFollowUps annotates each row with `is_cross_party` so the UI
|
||||
// can render opposing-side rows with the Gegenseitig badge instead of
|
||||
// silently dropping them. Hiding cross-party rows lied about what the
|
||||
// workflow does next (cf. RoP.029.d falling off when perspective=claimant
|
||||
// on def_to_ccr — the workflow continues, just on the defendant's
|
||||
// docket, and the lawyer needs to see that move exists).
|
||||
func (s *FristenrechnerService) queryFollowUpRows(
|
||||
ctx context.Context,
|
||||
anchorRuleID uuid.UUID,
|
||||
party string,
|
||||
) ([]followUpRow, error) {
|
||||
where := []string{
|
||||
"sr.parent_id = $1",
|
||||
@@ -355,16 +374,6 @@ func (s *FristenrechnerService) queryFollowUpRows(
|
||||
"pe.lifecycle_state = 'published'",
|
||||
}
|
||||
args := []any{anchorRuleID}
|
||||
if party == "claimant" || party == "defendant" {
|
||||
args = append(args, party)
|
||||
where = append(where, fmt.Sprintf(
|
||||
"(sr.primary_party = $%d OR sr.primary_party = 'both' OR sr.primary_party IS NULL)",
|
||||
len(args)))
|
||||
} else if party != "" {
|
||||
// "court" / "both" — exact match
|
||||
args = append(args, party)
|
||||
where = append(where, fmt.Sprintf("sr.primary_party = $%d", len(args)))
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT sr.id AS rule_id,
|
||||
@@ -402,3 +411,31 @@ func (s *FristenrechnerService) queryFollowUpRows(
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// isCrossPartyRow reports whether the row represents an opposing-side
|
||||
// filing relative to the requesting perspective.
|
||||
//
|
||||
// Returns true only when:
|
||||
// - the perspective is "claimant" or "defendant" (the two
|
||||
// binary-opposed sides Paliad models today)
|
||||
// - the row's primary_party is the opposite side
|
||||
//
|
||||
// "both" / "court" / NULL primary_party are never cross-party — they
|
||||
// apply to all sides or to the court itself. An empty perspective
|
||||
// (kontextfrei / "I'm just browsing") also yields false: with no
|
||||
// perspective there is no opposing side. The flag is purely display
|
||||
// metadata; cross-party rows still appear in the result, just with the
|
||||
// Gegenseitig badge + muted styling per design §2.4.
|
||||
func isCrossPartyRow(perspective string, primaryParty sql.NullString) bool {
|
||||
if perspective != "claimant" && perspective != "defendant" {
|
||||
return false
|
||||
}
|
||||
if !primaryParty.Valid {
|
||||
return false
|
||||
}
|
||||
p := primaryParty.String
|
||||
if p == "" || p == "both" || p == "court" {
|
||||
return false
|
||||
}
|
||||
return p != perspective
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,15 @@ func (s *DeadlineSearchService) SearchEvents(ctx context.Context, q string, opts
|
||||
"pe.is_active = true",
|
||||
"pe.lifecycle_state = 'published'",
|
||||
"pt.is_active = true",
|
||||
// S1a (m/paliad#149 Phase 2 design §2.2): spawn-only rules are
|
||||
// consequences, not triggers — a user who picks "Berufung
|
||||
// einlegen" wants the appeal-tree root, not the inf.cfi spawn
|
||||
// link that *opens* that tree. Filter them out at the picker.
|
||||
// Terminal leaves (Duplik etc.) stay pickable: their own anchor
|
||||
// rule is non-spawn, so they surface and their result-view
|
||||
// renders an empty follow-up list — honest UX per t-paliad-327
|
||||
// §3a.4 / the design's "stay pickable" carve-out.
|
||||
"sr.is_spawn = false",
|
||||
}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
|
||||
@@ -117,10 +117,13 @@ func TestLookupEvents(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// Should hit the 7 rules under the unified upc.apl that
|
||||
// carry applies_to_target={endentscheidung} (Slice B1 mig 134).
|
||||
// Should hit the 7 merits-track rules that carry
|
||||
// applies_to_target={endentscheidung} (Slice B1 mig 134).
|
||||
// Post-mig 155 (m/paliad#149 P1): the unified upc.apl was split
|
||||
// back into merits/cost/order — the endentscheidung anchors live
|
||||
// under upc.apl.merits (id=11).
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl endentscheidung rules after B1 mig")
|
||||
t.Fatal("expected upc.apl.merits endentscheidung rules after B1 mig")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
@@ -137,8 +140,8 @@ func TestLookupEvents(t *testing.T) {
|
||||
t.Errorf("anchor row %s missing endentscheidung target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
if m.ProceedingType.Code != "upc.apl.merits" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.merits",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
@@ -153,10 +156,11 @@ func TestLookupEvents(t *testing.T) {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 merits-track
|
||||
// rules under upc.apl.unified with applies_to_target ⊇ {schadensbemessung}
|
||||
// because R.224 is uniform across substantive R.118 decisions.
|
||||
// rules with applies_to_target ⊇ {schadensbemessung} because
|
||||
// R.224 is uniform across substantive R.118 decisions. Post-mig
|
||||
// 155 the merits track lives at upc.apl.merits (id=11).
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl schadensbemessung rules after mig 138 backfill")
|
||||
t.Fatal("expected upc.apl.merits schadensbemessung rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
@@ -173,8 +177,8 @@ func TestLookupEvents(t *testing.T) {
|
||||
t.Errorf("anchor row %s missing schadensbemessung target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
if m.ProceedingType.Code != "upc.apl.merits" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.merits",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
@@ -189,11 +193,12 @@ func TestLookupEvents(t *testing.T) {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 order-track
|
||||
// rules under upc.apl.unified with applies_to_target ⊇ {bucheinsicht}
|
||||
// because R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are
|
||||
// uniform across the orders they appeal.
|
||||
// rules with applies_to_target ⊇ {bucheinsicht} because R.220.2 /
|
||||
// R.224.2.b / R.235.2 / R.237 / R.238.2 are uniform across the
|
||||
// orders they appeal. Post-mig 155 the order track lives at
|
||||
// upc.apl.order (id=20).
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl bucheinsicht rules after mig 138 backfill")
|
||||
t.Fatal("expected upc.apl.order bucheinsicht rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
@@ -210,8 +215,8 @@ func TestLookupEvents(t *testing.T) {
|
||||
t.Errorf("anchor row %s missing bucheinsicht target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
if m.ProceedingType.Code != "upc.apl.order" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.order",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1247,7 +1247,7 @@ func (s *ProjectionService) collectActualsForOverrides(
|
||||
}
|
||||
var dRows []drow
|
||||
scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly)
|
||||
q := `SELECT d.rule_id, d.rule_code, d.due_date, d.completed_at, d.status
|
||||
q := `SELECT d.sequencing_rule_id AS rule_id, d.rule_code, d.due_date, d.completed_at, d.status
|
||||
FROM paliad.deadlines d
|
||||
WHERE ` + scopeFilter
|
||||
if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil {
|
||||
|
||||
@@ -213,6 +213,15 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// m/paliad#149 Phase 2 P2 (design §4.1) — lock the condition_expr
|
||||
// grammar to leaf {flag} or composite {op:'and'|'or', args:[…]}.
|
||||
// Surfaces an ErrInvalidInput before the row hits the DB so the
|
||||
// rule editor gets a friendly 400 instead of relying on a future
|
||||
// jsonb CHECK constraint that would land as a generic 500.
|
||||
if err := ValidateConditionExpr(ctx, s.db, input.ConditionExpr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
@@ -310,6 +319,15 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
|
||||
}
|
||||
}
|
||||
|
||||
// m/paliad#149 Phase 2 P2 (design §4.1) — validate condition_expr
|
||||
// patches. Nil patch field = "don't change" (no validation needed);
|
||||
// non-nil = the new value must match the grammar.
|
||||
if patch.ConditionExpr != nil {
|
||||
if err := ValidateConditionExpr(ctx, s.db, patch.ConditionExpr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
|
||||
1744
internal/services/scenario_builder_service.go
Normal file
1744
internal/services/scenario_builder_service.go
Normal file
File diff suppressed because it is too large
Load Diff
658
internal/services/scenario_builder_service_test.go
Normal file
658
internal/services/scenario_builder_service_test.go
Normal file
@@ -0,0 +1,658 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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, nil, nil, nil)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioBuilderAkteDualWrite pins B4's load-bearing contract
|
||||
// (m/paliad#153 / t-paliad-347 / PRD §2.3 + §10):
|
||||
//
|
||||
// - PatchProceeding on a project-backed scenario (origin_project_id
|
||||
// IS NOT NULL) MUST mirror scenario_flags onto
|
||||
// paliad.projects.scenario_flags;
|
||||
// - PatchEvent flipping state→'filed' on a project-backed scenario
|
||||
// MUST upsert a paliad.deadlines row (status='completed',
|
||||
// completed_at=actual_date);
|
||||
// - PatchProceeding/PatchEvent on a non-Akte (kontextfrei) scenario
|
||||
// MUST NOT touch paliad.projects.scenario_flags or
|
||||
// paliad.deadlines.
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL. Mirrors the live-DB pattern used
|
||||
// by TestScenarioBuilderService above.
|
||||
func TestScenarioBuilderAkteDualWrite(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()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.projects WHERE created_by = $1`, owner)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.users WHERE id = $1`, owner)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM auth.users WHERE id = $1`, owner)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
// Seed owner.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
|
||||
owner, "builder-akte-test@hlc.com"); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, lang, global_role)
|
||||
VALUES ($1, $2, $3, 'munich', 'de', 'global_admin')`,
|
||||
owner, "builder-akte-test@hlc.com", "Builder Akte Owner"); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
// Look up a real proceeding_type_id + a sequencing_rule_id on that
|
||||
// proceeding so the deadline upsert has a real rule to point at.
|
||||
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)
|
||||
}
|
||||
var ruleID uuid.UUID
|
||||
if err := pool.GetContext(ctx, &ruleID,
|
||||
`SELECT id FROM paliad.sequencing_rules
|
||||
WHERE proceeding_type_id = $1
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
ORDER BY sequence_order NULLS LAST, id LIMIT 1`, ptID); err != nil {
|
||||
t.Fatalf("look up sequencing_rule: %v", err)
|
||||
}
|
||||
|
||||
// Seed a paliad.projects (type='case') row pinned to that
|
||||
// proceeding_type. our_side='defendant' so the builder triplet's
|
||||
// primary_party derives from it.
|
||||
projectID := uuid.New()
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, title, status, proceeding_type_id, our_side, created_by)
|
||||
VALUES ($1, 'case', 'Builder Akte Test Project', 'active', $2, 'defendant', $3)`,
|
||||
projectID, ptID, owner); err != nil {
|
||||
t.Fatalf("seed project: %v", err)
|
||||
}
|
||||
// Place the owner on the project team so visibility checks pass.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited)
|
||||
VALUES ($1, $2, 'lead', 'lead', false)`, projectID, owner); err != nil {
|
||||
t.Fatalf("seed project_teams: %v", err)
|
||||
}
|
||||
|
||||
// Wire up the service with the real project + flag deps so dual-
|
||||
// write hits live tables. NewProjectService + NewScenarioFlags
|
||||
// match the production wiring in cmd/server/main.go.
|
||||
userSvc := NewUserService(pool)
|
||||
projSvc := NewProjectService(pool, userSvc)
|
||||
flagsSvc := NewScenarioFlagsService(pool, projSvc)
|
||||
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Phase A — Akte-backed scenario writes through to project tables.
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
akte, err := svc.CreateScenarioFromProject(ctx, owner, projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenarioFromProject: %v", err)
|
||||
}
|
||||
if akte.OriginProjectID == nil || *akte.OriginProjectID != projectID {
|
||||
t.Fatalf("origin_project_id = %v, want %v", akte.OriginProjectID, projectID)
|
||||
}
|
||||
if len(akte.Proceedings) != 1 {
|
||||
t.Fatalf("seed proceedings = %d, want 1", len(akte.Proceedings))
|
||||
}
|
||||
procID := akte.Proceedings[0].ID
|
||||
if akte.Proceedings[0].PrimaryParty == nil || *akte.Proceedings[0].PrimaryParty != "defendant" {
|
||||
t.Errorf("primary_party = %v, want defendant", akte.Proceedings[0].PrimaryParty)
|
||||
}
|
||||
|
||||
// Toggle with_ccr=true via PatchProceeding. Dual-write should land
|
||||
// the same key on projects.scenario_flags.
|
||||
if _, err := svc.PatchProceeding(ctx, owner, akte.ID, procID, PatchProceedingInput{
|
||||
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchProceeding (Akte): %v", err)
|
||||
}
|
||||
var rawProjFlags []byte
|
||||
if err := pool.GetContext(ctx, &rawProjFlags,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
|
||||
t.Fatalf("read project scenario_flags: %v", err)
|
||||
}
|
||||
var projFlags map[string]any
|
||||
if err := json.Unmarshal(rawProjFlags, &projFlags); err != nil {
|
||||
t.Fatalf("decode project scenario_flags: %v", err)
|
||||
}
|
||||
if v, ok := projFlags["with_ccr"].(bool); !ok || !v {
|
||||
t.Errorf("after Akte PatchProceeding, projects.scenario_flags.with_ccr = %v, want true", projFlags["with_ccr"])
|
||||
}
|
||||
|
||||
// Add an event card backed by a real sequencing rule, then PATCH
|
||||
// state='filed' with actual_date. Dual-write should insert a
|
||||
// paliad.deadlines row (status='completed', completed_at=actual_date).
|
||||
ev, err := svc.AddEvent(ctx, owner, akte.ID, procID, AddEventInput{
|
||||
SequencingRuleID: &ruleID,
|
||||
State: ptrString("planned"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddEvent (Akte): %v", err)
|
||||
}
|
||||
filedDate := mustDate(t, "2026-04-15")
|
||||
if _, err := svc.PatchEvent(ctx, owner, akte.ID, ev.ID, PatchEventInput{
|
||||
State: ptrString("filed"),
|
||||
ActualDate: &filedDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchEvent filed (Akte): %v", err)
|
||||
}
|
||||
var deadlineCount int
|
||||
if err := pool.GetContext(ctx, &deadlineCount,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines
|
||||
WHERE project_id = $1 AND sequencing_rule_id = $2
|
||||
AND status = 'completed'`,
|
||||
projectID, ruleID); err != nil {
|
||||
t.Fatalf("count deadlines: %v", err)
|
||||
}
|
||||
if deadlineCount != 1 {
|
||||
t.Errorf("after Akte PatchEvent filed, deadlines rows = %d, want 1", deadlineCount)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Phase B — kontextfrei scenario does NOT touch project surfaces.
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Capture project scenario_flags + deadline count before the
|
||||
// kontextfrei mutations so we can assert no change.
|
||||
var beforeFlagsRaw []byte
|
||||
_ = pool.GetContext(ctx, &beforeFlagsRaw,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID)
|
||||
var beforeDeadlines int
|
||||
_ = pool.GetContext(ctx, &beforeDeadlines,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID)
|
||||
|
||||
kf, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenario (kontextfrei): %v", err)
|
||||
}
|
||||
if kf.OriginProjectID != nil {
|
||||
t.Fatalf("kontextfrei origin_project_id = %v, want nil", kf.OriginProjectID)
|
||||
}
|
||||
kfProc, err := svc.AddProceeding(ctx, owner, kf.ID, AddProceedingInput{
|
||||
ProceedingTypeID: ptID,
|
||||
PrimaryParty: ptrString("claimant"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddProceeding (kontextfrei): %v", err)
|
||||
}
|
||||
// Flag toggle on a kontextfrei scenario MUST NOT mutate the
|
||||
// project's scenario_flags.
|
||||
if _, err := svc.PatchProceeding(ctx, owner, kf.ID, kfProc.ID, PatchProceedingInput{
|
||||
ScenarioFlags: json.RawMessage(`{"with_amend": true}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchProceeding (kontextfrei): %v", err)
|
||||
}
|
||||
var afterFlagsRaw []byte
|
||||
if err := pool.GetContext(ctx, &afterFlagsRaw,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
|
||||
t.Fatalf("re-read project scenario_flags: %v", err)
|
||||
}
|
||||
if string(beforeFlagsRaw) != string(afterFlagsRaw) {
|
||||
t.Errorf("kontextfrei PatchProceeding leaked into projects.scenario_flags: before=%s after=%s",
|
||||
beforeFlagsRaw, afterFlagsRaw)
|
||||
}
|
||||
|
||||
// Filed-state event on a kontextfrei scenario MUST NOT touch
|
||||
// paliad.deadlines.
|
||||
kfEv, err := svc.AddEvent(ctx, owner, kf.ID, kfProc.ID, AddEventInput{
|
||||
SequencingRuleID: &ruleID,
|
||||
State: ptrString("planned"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddEvent (kontextfrei): %v", err)
|
||||
}
|
||||
kfDate := mustDate(t, "2026-04-16")
|
||||
if _, err := svc.PatchEvent(ctx, owner, kf.ID, kfEv.ID, PatchEventInput{
|
||||
State: ptrString("filed"),
|
||||
ActualDate: &kfDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchEvent filed (kontextfrei): %v", err)
|
||||
}
|
||||
var afterDeadlines int
|
||||
if err := pool.GetContext(ctx, &afterDeadlines,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID); err != nil {
|
||||
t.Fatalf("re-count deadlines: %v", err)
|
||||
}
|
||||
if afterDeadlines != beforeDeadlines {
|
||||
t.Errorf("kontextfrei PatchEvent filed leaked into deadlines: before=%d after=%d",
|
||||
beforeDeadlines, afterDeadlines)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioBuilderPromote pins B5's load-bearing contract
|
||||
// (m/paliad#153 / t-paliad-350 / PRD §2.4 + §5.4 + §10): PromoteScenario
|
||||
// creates a paliad.projects 'case' row transactionally, cascades parties
|
||||
// + deadlines, flips the scenario to 'promoted' with a back-ref, and
|
||||
// makes the original scenario read-only afterwards.
|
||||
func TestScenarioBuilderPromote(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()
|
||||
var createdProjectID uuid.UUID
|
||||
cleanup := func() {
|
||||
if createdProjectID != uuid.Nil {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, createdProjectID)
|
||||
}
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, owner)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, owner)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'promote-owner-test@hlc.com')`, owner); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, lang)
|
||||
VALUES ($1, 'promote-owner-test@hlc.com', 'Promote Owner', 'munich', 'de')`, owner); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Two distinct rule ids: one filed, one planned (with an explicit
|
||||
// actual_date so the planned deadline lands even without a calc engine).
|
||||
var ruleIDs []uuid.UUID
|
||||
if err := pool.SelectContext(ctx, &ruleIDs,
|
||||
`SELECT id FROM paliad.sequencing_rules
|
||||
WHERE proceeding_type_id = $1 AND is_active = true AND lifecycle_state = 'published'
|
||||
ORDER BY sequence_order NULLS LAST, id LIMIT 2`, ptID); err != nil {
|
||||
t.Fatalf("look up sequencing_rules: %v", err)
|
||||
}
|
||||
if len(ruleIDs) < 2 {
|
||||
t.Skipf("need >=2 published rules for upc.inf; got %d", len(ruleIDs))
|
||||
}
|
||||
|
||||
userSvc := NewUserService(pool)
|
||||
projSvc := NewProjectService(pool, userSvc)
|
||||
flagsSvc := NewScenarioFlagsService(pool, projSvc)
|
||||
// fristenrechner nil — planned events carry an explicit actual_date in
|
||||
// this test, so the cascade doesn't need computed dates.
|
||||
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
|
||||
|
||||
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{Name: "Promote-Test"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenario: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
filedDate := mustDate(t, "2026-01-15")
|
||||
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
|
||||
SequencingRuleID: &ruleIDs[0], State: ptrString("filed"), ActualDate: &filedDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddEvent filed: %v", err)
|
||||
}
|
||||
plannedDate := mustDate(t, "2026-04-01")
|
||||
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
|
||||
SequencingRuleID: &ruleIDs[1], State: ptrString("planned"), ActualDate: &plannedDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddEvent planned: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{
|
||||
Title: "Becker ./. X — UPC",
|
||||
CaseNumber: ptrString("UPC_CFI_123/2026"),
|
||||
OurSide: ptrString("defendant"),
|
||||
Parties: []PromotePartyInput{
|
||||
{Name: "Becker GmbH", Role: ptrString("defendant")},
|
||||
{Name: "X Corp", Role: ptrString("claimant")},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PromoteScenario: %v", err)
|
||||
}
|
||||
createdProjectID = res.ProjectID
|
||||
if res.ProjectID == uuid.Nil {
|
||||
t.Fatal("PromoteScenario returned nil project id")
|
||||
}
|
||||
if res.PartiesCreated != 2 {
|
||||
t.Errorf("PartiesCreated = %d, want 2", res.PartiesCreated)
|
||||
}
|
||||
if res.DeadlinesCreated != 2 {
|
||||
t.Errorf("DeadlinesCreated = %d, want 2 (1 filed + 1 planned-with-date)", res.DeadlinesCreated)
|
||||
}
|
||||
|
||||
// Project row exists, is a 'case', carries origin_scenario_id + flags.
|
||||
var proj struct {
|
||||
Type string `db:"type"`
|
||||
OriginScenario *uuid.UUID `db:"origin_scenario_id"`
|
||||
ProceedingType *int `db:"proceeding_type_id"`
|
||||
OurSide *string `db:"our_side"`
|
||||
ScenarioFlags json.RawMessage `db:"scenario_flags"`
|
||||
CaseNumber *string `db:"case_number"`
|
||||
}
|
||||
if err := pool.GetContext(ctx, &proj,
|
||||
`SELECT type, origin_scenario_id, proceeding_type_id, our_side, scenario_flags, case_number
|
||||
FROM paliad.projects WHERE id = $1`, res.ProjectID); err != nil {
|
||||
t.Fatalf("load promoted project: %v", err)
|
||||
}
|
||||
if proj.Type != "case" {
|
||||
t.Errorf("project type = %q, want case", proj.Type)
|
||||
}
|
||||
if proj.OriginScenario == nil || *proj.OriginScenario != sc.ID {
|
||||
t.Errorf("origin_scenario_id = %v, want %v", proj.OriginScenario, sc.ID)
|
||||
}
|
||||
if proj.ProceedingType == nil || *proj.ProceedingType != ptID {
|
||||
t.Errorf("proceeding_type_id = %v, want %d", proj.ProceedingType, ptID)
|
||||
}
|
||||
if proj.OurSide == nil || *proj.OurSide != "defendant" {
|
||||
t.Errorf("our_side = %v, want defendant", proj.OurSide)
|
||||
}
|
||||
|
||||
// Scenario flipped to promoted with the back-ref.
|
||||
var after BuilderScenario
|
||||
if err := pool.GetContext(ctx, &after,
|
||||
`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`, sc.ID); err != nil {
|
||||
t.Fatalf("reload scenario: %v", err)
|
||||
}
|
||||
if after.Status != "promoted" {
|
||||
t.Errorf("scenario status = %q, want promoted", after.Status)
|
||||
}
|
||||
if after.PromotedProjectID == nil || *after.PromotedProjectID != res.ProjectID {
|
||||
t.Errorf("promoted_project_id = %v, want %v", after.PromotedProjectID, res.ProjectID)
|
||||
}
|
||||
|
||||
// Deadlines + parties physically present.
|
||||
var deadlineCount, partyCount int
|
||||
pool.GetContext(ctx, &deadlineCount,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, res.ProjectID)
|
||||
pool.GetContext(ctx, &partyCount,
|
||||
`SELECT COUNT(*) FROM paliad.parties WHERE project_id = $1`, res.ProjectID)
|
||||
if deadlineCount != 2 {
|
||||
t.Errorf("deadlines in DB = %d, want 2", deadlineCount)
|
||||
}
|
||||
if partyCount != 2 {
|
||||
t.Errorf("parties in DB = %d, want 2", partyCount)
|
||||
}
|
||||
|
||||
// Promoted scenario is now read-only: further PATCH is rejected.
|
||||
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
||||
Name: ptrString("rename-after-promote"),
|
||||
}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("PatchScenario after promote = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
// Re-promoting is rejected.
|
||||
if _, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{Title: "again"}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("re-promote = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustDate parses an ISO date or fails the test. Helper for the
|
||||
// dual-write test above.
|
||||
func mustDate(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
d, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse date %q: %v", s, err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// (Note: ptrString lives in rule_editor_service_test.go in this package
|
||||
// and is reused here. No second declaration needed.)
|
||||
375
internal/services/scenario_flags_service.go
Normal file
375
internal/services/scenario_flags_service.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// ScenarioFlagsService owns the per-project scenario state — the
|
||||
// single source of truth introduced in mig 154 (m/paliad#149 Phase 2 P0).
|
||||
//
|
||||
// The state lives in paliad.projects.scenario_flags (jsonb object) and
|
||||
// carries two key shapes:
|
||||
//
|
||||
// - **Named flags** — keys whose name appears in paliad.scenario_flag_catalog
|
||||
// (today: with_ccr / with_amend / with_cci). These drive condition_expr
|
||||
// evaluation in pkg/litigationplanner and the Verfahrensablauf
|
||||
// scenario-strip UI.
|
||||
//
|
||||
// - **Per-rule selection deviations** — keys of shape "rule:<uuid>".
|
||||
// They record an explicit deviation from the rule's priority-driven
|
||||
// default (mandatory always selected; recommended default-selected;
|
||||
// optional default-unselected). The UUID must resolve to an
|
||||
// active+published sequencing_rule on the project's proceeding type.
|
||||
//
|
||||
// Values are always JSON booleans. Missing keys take the priority-driven
|
||||
// default — the absence of an entry is the absence of a deviation.
|
||||
//
|
||||
// All writes go through Patch (PATCH semantics: keys not in the delta are
|
||||
// left untouched; passing `null` for a key deletes it from the map so the
|
||||
// default behaviour returns). Patch validates every key + every UUID
|
||||
// before persisting; a single bad key fails the whole patch.
|
||||
type ScenarioFlagsService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
}
|
||||
|
||||
func NewScenarioFlagsService(db *sqlx.DB, projects *ProjectService) *ScenarioFlagsService {
|
||||
return &ScenarioFlagsService{db: db, projects: projects}
|
||||
}
|
||||
|
||||
// ScenarioFlagCatalogEntry mirrors one row of paliad.scenario_flag_catalog.
|
||||
type ScenarioFlagCatalogEntry struct {
|
||||
FlagKey string `db:"flag_key" json:"flag_key"`
|
||||
LabelDE string `db:"label_de" json:"label_de"`
|
||||
LabelEN string `db:"label_en" json:"label_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
HiddenUnlessSet bool `db:"hidden_unless_set" json:"hidden_unless_set"`
|
||||
}
|
||||
|
||||
// ScenarioFlagsView is the GET response shape — the live flag map plus
|
||||
// the catalog the UI needs to render the scenario-flags strip.
|
||||
type ScenarioFlagsView struct {
|
||||
Flags map[string]bool `json:"flags"`
|
||||
Catalog []ScenarioFlagCatalogEntry `json:"catalog"`
|
||||
}
|
||||
|
||||
// rulePrefix is the prefix that distinguishes a per-rule selection
|
||||
// entry from a named flag. Kept lowercase to match the catalog's
|
||||
// CHECK constraint pattern.
|
||||
const rulePrefix = "rule:"
|
||||
|
||||
// ruleKeyRe parses "rule:<uuid>" into the UUID portion. Uses the
|
||||
// case-insensitive uuid regex so callers can paste either lower or
|
||||
// uppercase UUIDs.
|
||||
var ruleKeyRe = regexp.MustCompile(`^rule:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$`)
|
||||
|
||||
// Get returns the current scenario state for a project. Visibility-gated
|
||||
// via paliad.can_see_project (mirrors EventChoiceService.requireProjectVisible).
|
||||
//
|
||||
// The returned map is never nil; an empty object means "every rule takes
|
||||
// the priority-driven default". The catalog is always populated so the
|
||||
// UI can render the scenario-strip without a second round-trip.
|
||||
func (s *ScenarioFlagsService) Get(ctx context.Context, userID, projectID uuid.UUID) (*ScenarioFlagsView, error) {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw []byte
|
||||
err := s.db.GetContext(ctx, &raw,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read scenario_flags: %w", err)
|
||||
}
|
||||
|
||||
flags, err := decodeFlagMap(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode scenario_flags: %w", err)
|
||||
}
|
||||
|
||||
catalog, err := s.ListCatalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ScenarioFlagsView{Flags: flags, Catalog: catalog}, nil
|
||||
}
|
||||
|
||||
// ListCatalog returns every paliad.scenario_flag_catalog row, ordered by
|
||||
// added_at so the seeded with_ccr / with_amend / with_cci tier surfaces
|
||||
// first and later-added flags appear after.
|
||||
func (s *ScenarioFlagsService) ListCatalog(ctx context.Context) ([]ScenarioFlagCatalogEntry, error) {
|
||||
out := []ScenarioFlagCatalogEntry{}
|
||||
if err := s.db.SelectContext(ctx, &out,
|
||||
`SELECT flag_key, label_de, label_en, description, hidden_unless_set
|
||||
FROM paliad.scenario_flag_catalog
|
||||
ORDER BY added_at ASC, flag_key ASC`); err != nil {
|
||||
return nil, fmt.Errorf("list flag catalog: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Patch merges a partial delta into the project's scenario_flags. Per
|
||||
// the design (§2.3): keys not in the delta are left untouched; a key
|
||||
// set to `nil` (JSON null) is deleted from the map so the default
|
||||
// returns; bool values are stored verbatim.
|
||||
//
|
||||
// Every key in the delta is validated before any write happens:
|
||||
//
|
||||
// - keys matching "rule:<uuid>" must resolve to an active+published
|
||||
// sequencing_rule whose proceeding_type matches the project's
|
||||
// proceeding_type_id;
|
||||
// - all other keys must appear in paliad.scenario_flag_catalog.
|
||||
//
|
||||
// Bad keys raise ErrInvalidInput with a message that names the offending
|
||||
// key. The whole patch is rejected on the first bad key — no partial
|
||||
// writes.
|
||||
func (s *ScenarioFlagsService) Patch(
|
||||
ctx context.Context,
|
||||
userID, projectID uuid.UUID,
|
||||
delta map[string]*bool,
|
||||
) (*ScenarioFlagsView, error) {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(delta) == 0 {
|
||||
return s.Get(ctx, userID, projectID)
|
||||
}
|
||||
|
||||
if err := s.validateDelta(ctx, projectID, delta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := setAuditReasonTx(ctx, tx,
|
||||
fmt.Sprintf("scenario-flags PATCH by user %s on project %s", userID, projectID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw []byte
|
||||
if err := tx.GetContext(ctx, &raw,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1 FOR UPDATE`,
|
||||
projectID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
return nil, fmt.Errorf("lock project row: %w", err)
|
||||
}
|
||||
|
||||
current, err := decodeFlagMap(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode current scenario_flags: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range delta {
|
||||
if v == nil {
|
||||
delete(current, k)
|
||||
continue
|
||||
}
|
||||
current[k] = *v
|
||||
}
|
||||
|
||||
merged, err := json.Marshal(current)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode merged scenario_flags: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.projects
|
||||
SET scenario_flags = $1::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $2`, merged, projectID); err != nil {
|
||||
return nil, fmt.Errorf("write scenario_flags: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit scenario-flags patch: %w", err)
|
||||
}
|
||||
|
||||
catalog, err := s.ListCatalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScenarioFlagsView{Flags: current, Catalog: catalog}, nil
|
||||
}
|
||||
|
||||
// validateDelta runs every key in the delta through the appropriate
|
||||
// validator. Returns the first error it finds — callers receive
|
||||
// ErrInvalidInput wrapped with the offending key.
|
||||
func (s *ScenarioFlagsService) validateDelta(
|
||||
ctx context.Context,
|
||||
projectID uuid.UUID,
|
||||
delta map[string]*bool,
|
||||
) error {
|
||||
var (
|
||||
ruleUUIDs []uuid.UUID
|
||||
flagKeys []string
|
||||
ruleIDsKey = map[string]uuid.UUID{}
|
||||
)
|
||||
for k := range delta {
|
||||
if k == "" {
|
||||
return fmt.Errorf("%w: empty key in scenario_flags delta", ErrInvalidInput)
|
||||
}
|
||||
if m := ruleKeyRe.FindStringSubmatch(k); m != nil {
|
||||
u, err := uuid.Parse(m[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %q has malformed UUID", ErrInvalidInput, k)
|
||||
}
|
||||
ruleUUIDs = append(ruleUUIDs, u)
|
||||
ruleIDsKey[k] = u
|
||||
continue
|
||||
}
|
||||
flagKeys = append(flagKeys, k)
|
||||
}
|
||||
|
||||
if len(flagKeys) > 0 {
|
||||
known, err := s.knownFlagKeys(ctx, flagKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range flagKeys {
|
||||
if _, ok := known[k]; !ok {
|
||||
return fmt.Errorf("%w: scenario flag %q is not in scenario_flag_catalog", ErrInvalidInput, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ruleUUIDs) > 0 {
|
||||
if err := s.validateRuleUUIDs(ctx, projectID, ruleUUIDs, ruleIDsKey, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// knownFlagKeys returns the subset of `flagKeys` that exists in the
|
||||
// catalog. Used to reject writes that name unknown flags.
|
||||
func (s *ScenarioFlagsService) knownFlagKeys(ctx context.Context, flagKeys []string) (map[string]struct{}, error) {
|
||||
if len(flagKeys) == 0 {
|
||||
return map[string]struct{}{}, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT flag_key FROM paliad.scenario_flag_catalog WHERE flag_key = ANY($1)`,
|
||||
pq.Array(flagKeys))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup flag catalog: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// validateRuleUUIDs ensures every rule:<uuid> entry in the delta
|
||||
// references a sequencing_rule that:
|
||||
//
|
||||
// 1. exists, is active, and lifecycle_state='published'
|
||||
// 2. belongs to the project's current proceeding_type_id
|
||||
// 3. is NOT priority='mandatory' when the value is `false` (mandatory
|
||||
// rules cannot be deselected — that's a UX lie disguised as data)
|
||||
func (s *ScenarioFlagsService) validateRuleUUIDs(
|
||||
ctx context.Context,
|
||||
projectID uuid.UUID,
|
||||
ids []uuid.UUID,
|
||||
keyByUUID map[string]uuid.UUID,
|
||||
delta map[string]*bool,
|
||||
) error {
|
||||
var ptID sql.NullInt64
|
||||
if err := s.db.GetContext(ctx, &ptID,
|
||||
`SELECT proceeding_type_id FROM paliad.projects WHERE id = $1`,
|
||||
projectID); err != nil {
|
||||
return fmt.Errorf("load project proceeding_type_id: %w", err)
|
||||
}
|
||||
if !ptID.Valid {
|
||||
return fmt.Errorf("%w: project %s has no proceeding_type_id — per-rule selection entries require one", ErrInvalidInput, projectID)
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Priority string `db:"priority"`
|
||||
}
|
||||
rows := []row{}
|
||||
idStrs := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
idStrs[i] = id.String()
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT id, priority
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND proceeding_type_id = $2
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'`,
|
||||
pq.Array(idStrs), ptID.Int64); err != nil {
|
||||
return fmt.Errorf("validate rule UUIDs: %w", err)
|
||||
}
|
||||
priorityByID := make(map[uuid.UUID]string, len(rows))
|
||||
for _, r := range rows {
|
||||
priorityByID[r.ID] = r.Priority
|
||||
}
|
||||
for key, id := range keyByUUID {
|
||||
prio, ok := priorityByID[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: rule %s is not an active+published rule on the project's proceeding type", ErrInvalidInput, id)
|
||||
}
|
||||
val := delta[key]
|
||||
if val != nil && !*val && prio == "mandatory" {
|
||||
return fmt.Errorf("%w: rule %s is mandatory and cannot be deselected", ErrInvalidInput, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScenarioFlagsService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
|
||||
visible, err := s.projects.CanSee(ctx, userID, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visible {
|
||||
return ErrNotVisible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeFlagMap returns a (key → bool) map from the raw jsonb bytes.
|
||||
// Stored values that aren't bool are silently dropped — they should
|
||||
// never occur (the service rejects them on write) but defensive read
|
||||
// avoids crashing the API if a hand-written row sneaks through.
|
||||
func decodeFlagMap(raw []byte) (map[string]bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
var anyMap map[string]any
|
||||
if err := json.Unmarshal(raw, &anyMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]bool, len(anyMap))
|
||||
for k, v := range anyMap {
|
||||
if b, ok := v.(bool); ok {
|
||||
out[k] = b
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
178
internal/services/submission_autoname.go
Normal file
178
internal/services/submission_autoname.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package services
|
||||
|
||||
// Auto-naming for freshly-created submission drafts (t-paliad-352 /
|
||||
// m/paliad#155). A new project-bound draft gets a sortable, legal-
|
||||
// convention default title instead of the bare "Entwurf N" counter:
|
||||
//
|
||||
// <YYYY-MM-DD> <ClientName> ./. <ForumShort> ./. <OpponentName>
|
||||
//
|
||||
// The date leads so drafts sort chronologically; " ./. " is the German
|
||||
// legal shorthand for "gegen". The three identity segments are the
|
||||
// client we act for, the forum the proceeding runs in, and the opposing
|
||||
// party — exactly the trio m named ("CLIENTNAME / UPC / OPPONENTNAME").
|
||||
//
|
||||
// Missing-segment rule: any segment that resolves empty is dropped
|
||||
// together with its leading separator, so a project without an opponent
|
||||
// yet renders "2026-05-31 Bayer AG ./. UPC" (no trailing separator) and
|
||||
// a project-less draft never reaches this path at all (it keeps the
|
||||
// "Entwurf N" counter — see SubmissionDraftService.Create).
|
||||
//
|
||||
// v1.1 customization hook: the template is hardcoded here in v1. When m
|
||||
// promotes naming to a per-user / per-firm / per-base setting (issue
|
||||
// #155 Q4), the override string lands as an extra parameter on
|
||||
// AutoSubmissionTitle (or a small template struct) and the segment
|
||||
// resolvers below stay as the value source. Nothing else needs to move.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// submissionTitleSep is the separator between identity segments —
|
||||
// " ./. " is the German legal convention for "gegen" / "versus".
|
||||
const submissionTitleSep = " ./. "
|
||||
|
||||
// AutoSubmissionTitle assembles the auto-generated draft title from the
|
||||
// resolved identity pieces. Pure and table-testable — every DB hop
|
||||
// happens in the caller (SubmissionDraftService.autoNameForProject).
|
||||
//
|
||||
// clientName is passed separately because the client we act for is the
|
||||
// root ancestor of the project tree, not a field on the draft's own
|
||||
// project node; the caller walks the path to resolve it. ourSide and
|
||||
// the proceeding type both come off the draft's project node, the
|
||||
// parties hang directly off it.
|
||||
//
|
||||
// The date is always present (formatted in Europe/Berlin to match the
|
||||
// today.* render vars); the three identity segments are appended only
|
||||
// when non-empty.
|
||||
func AutoSubmissionTitle(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) string {
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
if loc != nil {
|
||||
now = now.In(loc)
|
||||
}
|
||||
date := now.Format("2006-01-02")
|
||||
|
||||
segments := make([]string, 0, 3)
|
||||
if c := strings.TrimSpace(clientName); c != "" {
|
||||
segments = append(segments, c)
|
||||
}
|
||||
if f := submissionForumShort(pt); f != "" {
|
||||
segments = append(segments, f)
|
||||
}
|
||||
ourSide := ""
|
||||
if project != nil {
|
||||
ourSide = derefString(project.OurSide)
|
||||
}
|
||||
if o := submissionOpponentName(parties, ourSide); o != "" {
|
||||
segments = append(segments, o)
|
||||
}
|
||||
|
||||
if len(segments) == 0 {
|
||||
return date
|
||||
}
|
||||
return date + " " + strings.Join(segments, submissionTitleSep)
|
||||
}
|
||||
|
||||
// submissionForumShort maps a proceeding type to the short forum label
|
||||
// used in the auto-name. The jurisdiction is the forum for the
|
||||
// supranational / office tracks (UPC, EPA, DPMA); German court
|
||||
// proceedings disambiguate by the court that hears them (LG / OLG /
|
||||
// BGH / BPatG), which is the tail segment of the proceeding code
|
||||
// (de.inf.lg → LG, de.null.bpatg → BPatG). nil / unknown → "".
|
||||
func submissionForumShort(pt *models.ProceedingType) string {
|
||||
if pt == nil {
|
||||
return ""
|
||||
}
|
||||
switch j := strings.ToUpper(strings.TrimSpace(derefString(pt.Jurisdiction))); j {
|
||||
case "":
|
||||
return ""
|
||||
case "DE":
|
||||
return germanCourtShort(pt.Code)
|
||||
default:
|
||||
// UPC / EPA / DPMA and any future jurisdiction are their own
|
||||
// forum label.
|
||||
return j
|
||||
}
|
||||
}
|
||||
|
||||
// germanCourtShort returns the court abbreviation from the tail segment
|
||||
// of a German proceeding code (the part after the last "."). Known
|
||||
// courts get their canonical casing; anything else falls back to the
|
||||
// uppercased tail so a new German proceeding still yields a label.
|
||||
func germanCourtShort(code string) string {
|
||||
parts := strings.Split(code, ".")
|
||||
tail := strings.ToLower(strings.TrimSpace(parts[len(parts)-1]))
|
||||
switch tail {
|
||||
case "":
|
||||
return ""
|
||||
case "lg":
|
||||
return "LG"
|
||||
case "olg":
|
||||
return "OLG"
|
||||
case "bgh":
|
||||
return "BGH"
|
||||
case "bpatg":
|
||||
return "BPatG"
|
||||
default:
|
||||
return strings.ToUpper(tail)
|
||||
}
|
||||
}
|
||||
|
||||
// submissionOpponentName picks the name of the primary opposing party
|
||||
// given the side we act for. We act actively (claimant / applicant /
|
||||
// appellant) → the opponent is on the defendant bucket; we act
|
||||
// reactively (defendant / respondent) → the opponent is the claimant.
|
||||
// An unknown / unset side (third_party, other, NULL) can't fix a
|
||||
// posture, so no opponent is derived (the segment is omitted). The
|
||||
// first party of the opposing bucket wins — PartyService.ListForProject
|
||||
// orders by name, so the pick is deterministic for a given project.
|
||||
func submissionOpponentName(parties []models.Party, ourSide string) string {
|
||||
var want string
|
||||
switch sidePosture(ourSide) {
|
||||
case "active":
|
||||
want = "defendant"
|
||||
case "reactive":
|
||||
want = "claimant"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
for i := range parties {
|
||||
if partyRoleBucket(parties[i].Role) == want {
|
||||
if n := strings.TrimSpace(parties[i].Name); n != "" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sidePosture folds the our_side sub-role vocabulary (t-paliad-222)
|
||||
// down to the active / reactive axis. Returns "" for sides that have no
|
||||
// clear posture (third_party, other) or an unset value.
|
||||
func sidePosture(ourSide string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(ourSide)) {
|
||||
case "claimant", "applicant", "appellant":
|
||||
return "active"
|
||||
case "defendant", "respondent":
|
||||
return "reactive"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// partyRoleBucket folds a party's free-text role into the
|
||||
// claimant / defendant / other buckets. German and English spellings
|
||||
// both fold in; everything else (Streithelfer, Patentinhaberin, …) is
|
||||
// "other". Shared with addPartyVars so the two paths can't drift.
|
||||
func partyRoleBucket(role *string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(derefString(role))) {
|
||||
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
|
||||
return "claimant"
|
||||
case "defendant", "beklagter", "beklagte":
|
||||
return "defendant"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
224
internal/services/submission_autoname_test.go
Normal file
224
internal/services/submission_autoname_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func party(name, role string) models.Party {
|
||||
return models.Party{Name: name, Role: strPtr(role)}
|
||||
}
|
||||
|
||||
func proceeding(jurisdiction, code string) *models.ProceedingType {
|
||||
return &models.ProceedingType{Jurisdiction: strPtr(jurisdiction), Code: code}
|
||||
}
|
||||
|
||||
func projectSide(side string) *models.Project {
|
||||
if side == "" {
|
||||
return &models.Project{}
|
||||
}
|
||||
return &models.Project{OurSide: strPtr(side)}
|
||||
}
|
||||
|
||||
// noon UTC on 2026-05-31 → 14:00 Europe/Berlin (CEST), same calendar day.
|
||||
var fixedNow = time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestAutoSubmissionTitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
clientName string
|
||||
project *models.Project
|
||||
parties []models.Party
|
||||
pt *models.ProceedingType
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "full data — UPC, we are claimant",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("claimant"),
|
||||
parties: []models.Party{party("Novartis Pharma", "Beklagte")},
|
||||
pt: proceeding("UPC", "upc.inf.cfi"),
|
||||
want: "2026-05-31 Bayer AG ./. UPC ./. Novartis Pharma",
|
||||
},
|
||||
{
|
||||
name: "full data — German court, we are respondent",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("respondent"),
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: proceeding("DE", "de.null.bpatg"),
|
||||
want: "2026-05-31 Bayer AG ./. BPatG ./. Acme Generics",
|
||||
},
|
||||
{
|
||||
name: "no opponent — opposing bucket empty",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("claimant"),
|
||||
parties: []models.Party{party("Bayer AG", "Klägerin")}, // only our own side
|
||||
pt: proceeding("UPC", "upc.inf.cfi"),
|
||||
want: "2026-05-31 Bayer AG ./. UPC",
|
||||
},
|
||||
{
|
||||
name: "no forum — proceeding type missing",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("respondent"),
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: nil,
|
||||
want: "2026-05-31 Bayer AG ./. Acme Generics",
|
||||
},
|
||||
{
|
||||
name: "no client — client segment omitted",
|
||||
clientName: "",
|
||||
project: projectSide("claimant"),
|
||||
parties: []models.Party{party("Novartis Pharma", "Beklagte")},
|
||||
pt: proceeding("UPC", "upc.inf.cfi"),
|
||||
want: "2026-05-31 UPC ./. Novartis Pharma",
|
||||
},
|
||||
{
|
||||
name: "all identity segments missing — date only",
|
||||
clientName: "",
|
||||
project: projectSide(""), // no our_side → no opponent posture
|
||||
parties: nil,
|
||||
pt: nil,
|
||||
want: "2026-05-31",
|
||||
},
|
||||
{
|
||||
name: "unknown side — opponent omitted even with parties",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("third_party"),
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: proceeding("EPA", "epa.opp.opd"),
|
||||
want: "2026-05-31 Bayer AG ./. EPA",
|
||||
},
|
||||
{
|
||||
name: "nil project — opponent omitted, client + forum stand",
|
||||
clientName: "Bayer AG",
|
||||
project: nil,
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: proceeding("DPMA", "dpma.opp.dpma"),
|
||||
want: "2026-05-31 Bayer AG ./. DPMA",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := AutoSubmissionTitle(fixedNow, c.clientName, c.project, c.parties, c.pt)
|
||||
if got != c.want {
|
||||
t.Errorf("AutoSubmissionTitle = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoSubmissionTitleBerlinDate locks the Europe/Berlin localisation:
|
||||
// 22:30 UTC on 2026-05-31 is already 00:30 on 2026-06-01 in CEST, so the
|
||||
// date segment must roll over.
|
||||
func TestAutoSubmissionTitleBerlinDate(t *testing.T) {
|
||||
lateUTC := time.Date(2026, 5, 31, 22, 30, 0, 0, time.UTC)
|
||||
got := AutoSubmissionTitle(lateUTC, "Bayer AG", projectSide("claimant"),
|
||||
[]models.Party{party("Novartis", "Beklagte")}, proceeding("UPC", "upc.inf.cfi"))
|
||||
want := "2026-06-01 Bayer AG ./. UPC ./. Novartis"
|
||||
if got != want {
|
||||
t.Errorf("AutoSubmissionTitle (late UTC) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionForumShort(t *testing.T) {
|
||||
cases := []struct {
|
||||
pt *models.ProceedingType
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{proceeding("UPC", "upc.inf.cfi"), "UPC"},
|
||||
{proceeding("EPA", "epa.opp.opd"), "EPA"},
|
||||
{proceeding("DPMA", "dpma.opp.dpma"), "DPMA"},
|
||||
{proceeding("DE", "de.inf.lg"), "LG"},
|
||||
{proceeding("DE", "de.inf.olg"), "OLG"},
|
||||
{proceeding("DE", "de.inf.bgh"), "BGH"},
|
||||
{proceeding("DE", "de.null.bpatg"), "BPatG"},
|
||||
{proceeding("DE", "de.null.bgh"), "BGH"},
|
||||
{proceeding("DE", "de.foo.amtsgericht"), "AMTSGERICHT"}, // unknown court → uppercased tail
|
||||
{proceeding("de", "de.inf.lg"), "LG"}, // lowercase jurisdiction folds
|
||||
{proceeding("", ""), ""}, // no jurisdiction
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := submissionForumShort(c.pt); got != c.want {
|
||||
t.Errorf("submissionForumShort(%+v) = %q, want %q", c.pt, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionOpponentName(t *testing.T) {
|
||||
claimantA := party("Acme", "Klägerin")
|
||||
defendantB := party("Novartis", "Beklagte")
|
||||
other := party("Streithelfer X", "Streithelfer")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
parties []models.Party
|
||||
ourSide string
|
||||
want string
|
||||
}{
|
||||
{"active → first defendant", []models.Party{claimantA, defendantB}, "claimant", "Novartis"},
|
||||
{"reactive → first claimant", []models.Party{claimantA, defendantB}, "respondent", "Acme"},
|
||||
{"applicant (active) → defendant", []models.Party{defendantB}, "applicant", "Novartis"},
|
||||
{"appellant (active) → defendant", []models.Party{defendantB}, "appellant", "Novartis"},
|
||||
{"defendant (reactive) → claimant", []models.Party{claimantA}, "defendant", "Acme"},
|
||||
{"unknown side → none", []models.Party{claimantA, defendantB}, "third_party", ""},
|
||||
{"empty side → none", []models.Party{claimantA, defendantB}, "", ""},
|
||||
{"no opposing party → none", []models.Party{claimantA, other}, "claimant", ""},
|
||||
{"opposing bucket only 'other' → none", []models.Party{other}, "respondent", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := submissionOpponentName(c.parties, c.ourSide); got != c.want {
|
||||
t.Errorf("submissionOpponentName = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueDraftName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
base string
|
||||
existing []string
|
||||
want string
|
||||
}{
|
||||
{"free", "2026-05-31 Bayer AG ./. UPC", nil, "2026-05-31 Bayer AG ./. UPC"},
|
||||
{"first clash → (2)", "2026-05-31 Bayer AG ./. UPC",
|
||||
[]string{"2026-05-31 Bayer AG ./. UPC"}, "2026-05-31 Bayer AG ./. UPC (2)"},
|
||||
{"two clash → (3)", "2026-05-31 Bayer AG ./. UPC",
|
||||
[]string{"2026-05-31 Bayer AG ./. UPC", "2026-05-31 Bayer AG ./. UPC (2)"},
|
||||
"2026-05-31 Bayer AG ./. UPC (3)"},
|
||||
{"gap reused → (2)", "X",
|
||||
[]string{"X", "X (3)"}, "X (2)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := uniqueDraftName(c.base, c.existing); got != c.want {
|
||||
t.Errorf("uniqueDraftName = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextDraftName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
existing []string
|
||||
lang string
|
||||
want string
|
||||
}{
|
||||
{"empty de", nil, "de", "Entwurf 1"},
|
||||
{"empty en", nil, "en", "Draft 1"},
|
||||
{"highest+1", []string{"Entwurf 1", "Entwurf 3"}, "de", "Entwurf 4"},
|
||||
{"ignores foreign names", []string{"2026-05-31 Bayer AG"}, "de", "Entwurf 1"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := nextDraftName(c.existing, c.lang); got != c.want {
|
||||
t.Errorf("nextDraftName = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,93 +1,73 @@
|
||||
package services
|
||||
|
||||
// Composer render pipeline — t-paliad-313 Slice B (design doc §9.1 +
|
||||
// §9.2). Assembles a base .docx and a draft's section rows into a
|
||||
// merged .docx ready for export.
|
||||
// Composer wrapper — bridges paliad's submission draft model
|
||||
// (SubmissionSection + SubmissionBase) to the format-neutral docforge
|
||||
// .docx composer (pkg/docforge/docx), extracted in slice 2 of the
|
||||
// docforge train (t-paliad-349 / m/paliad#157).
|
||||
//
|
||||
// Pipeline (high-level):
|
||||
// The full splice/assembly pipeline now lives in pkg/docforge/docx
|
||||
// (compose.go): macro pre-pass, anchor-pair splicing, append-before-sectPr,
|
||||
// hyperlink-rels patching, zip repack, and the final placeholder pass. This
|
||||
// wrapper does the one thing the engine must not know about — mapping
|
||||
// paliad's DB row types onto the neutral docx.Section / docx.Carrier
|
||||
// inputs. Behaviour is byte-identical to the pre-extraction composer; the
|
||||
// in-package compose_test still drives this wrapper end-to-end.
|
||||
//
|
||||
// 1. ConvertDotmToDocx pre-pass on the base bytes (idempotent on .docx).
|
||||
// 2. Locate `word/document.xml` inside the zip; pull the body XML.
|
||||
// 3. For each section in the draft (order_index ASC, included=true):
|
||||
// render content_md_<lang> → OOXML via RenderMarkdownToOOXML using
|
||||
// base.section_spec.stylemap.paragraph.
|
||||
// 4. Splice the rendered OOXML into the base body. Two splice modes:
|
||||
// - Anchor mode: when the body carries `{{#section:KEY}}` /
|
||||
// `{{/section:KEY}}` marker pairs, replace the slot's content
|
||||
// (including the anchor paragraphs themselves) with the rendered
|
||||
// section.
|
||||
// - Append mode: when no anchor pair is found for a section, the
|
||||
// rendered OOXML appends at the end of the body, just before any
|
||||
// `<w:sectPr>` element. Sections with `included=false` are
|
||||
// dropped silently.
|
||||
// 5. Strip any leftover unmatched anchor paragraphs.
|
||||
// 6. Re-pack the document.xml into the zip, leaving every other part
|
||||
// untouched.
|
||||
// 7. Run the v1 SubmissionRenderer placeholder pass over the assembly
|
||||
// so `{{path}}` placeholders inside section content (and inside
|
||||
// the base's untouched chrome) get substituted by the merged bag.
|
||||
// Cross-run merge in pass 2 handles autocorrect-fragmented
|
||||
// placeholders the same as v1.
|
||||
//
|
||||
// Result: a fully-merged .docx. No new third-party Go dep — reuses
|
||||
// archive/zip + the existing SubmissionRenderer.
|
||||
// Slice note: the paragraph-level neutral document model (Document / Block
|
||||
// / Slot) the PRD §3.2 sketches lands in slice 6, where the authoring
|
||||
// importer and the format exporters actually consume it. Building it now,
|
||||
// ahead of any consumer, would be speculative and would put the
|
||||
// byte-identical guarantee at risk for no gain (PRD §4 B3 principle:
|
||||
// extractions earn their keep this cycle).
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// SubmissionComposer assembles base + sections into a final .docx.
|
||||
// Stateless; safe for concurrent use.
|
||||
// SubmissionComposer assembles a base + a draft's sections into a final
|
||||
// .docx. Stateless; safe for concurrent use.
|
||||
type SubmissionComposer struct {
|
||||
renderer *SubmissionRenderer
|
||||
inner *docx.Composer
|
||||
}
|
||||
|
||||
// NewSubmissionComposer wires the composer. The renderer is required —
|
||||
// a nil renderer is a programmer error and the composer panics at
|
||||
// NewSubmissionComposer wires the composer. The renderer is required — a
|
||||
// nil renderer is a programmer error and the composer panics at
|
||||
// construction.
|
||||
func NewSubmissionComposer(renderer *SubmissionRenderer) *SubmissionComposer {
|
||||
if renderer == nil {
|
||||
panic("submission composer: renderer required")
|
||||
}
|
||||
return &SubmissionComposer{renderer: renderer}
|
||||
return &SubmissionComposer{inner: docx.NewComposer(renderer)}
|
||||
}
|
||||
|
||||
// ComposeOptions carries the per-call composition inputs.
|
||||
// ComposeOptions carries the per-call composition inputs in paliad's own
|
||||
// terms (SubmissionSection rows + the SubmissionBase chrome).
|
||||
type ComposeOptions struct {
|
||||
// Sections are the draft's section rows in display order. The
|
||||
// composer renders included sections; excluded rows are dropped.
|
||||
// Caller is responsible for visibility — by the time the composer
|
||||
// runs, the section rows have already been gated through
|
||||
// SubmissionDraftService.Get + can_see_project.
|
||||
// Sections are the draft's section rows in display order. Included
|
||||
// sections render; excluded rows are dropped. The caller is
|
||||
// responsible for visibility — by the time the composer runs the rows
|
||||
// have already been gated through SubmissionDraftService.Get +
|
||||
// can_see_project.
|
||||
Sections []SubmissionSection
|
||||
|
||||
// Base supplies the document chrome (.docx body host) plus the
|
||||
// stylemap for the MD walker. Must not be nil.
|
||||
// Base supplies the document chrome plus the stylemap for the MD
|
||||
// walker. Must not be nil.
|
||||
Base *SubmissionBase
|
||||
|
||||
// BaseBytes is the raw .docx bytes for the base. Typically fetched
|
||||
// BaseBytes is the raw .docx bytes for the base, typically fetched
|
||||
// from Gitea via the existing template cache.
|
||||
BaseBytes []byte
|
||||
|
||||
// Lang ('de' or 'en') selects which content_md_* column the
|
||||
// composer reads per section. Defaults to 'de' if empty.
|
||||
// Lang ('de' or 'en') selects which content_md_* column the composer
|
||||
// reads per section. Defaults to 'de' if empty.
|
||||
Lang string
|
||||
|
||||
// Vars is the merged placeholder bag the v1 renderer pass
|
||||
// substitutes after the composer assembly. Passed straight through
|
||||
// to SubmissionRenderer.Render.
|
||||
// Vars is the merged placeholder bag the renderer pass substitutes
|
||||
// after assembly.
|
||||
Vars PlaceholderMap
|
||||
|
||||
// Missing translates an unbound placeholder key into the marker
|
||||
// the lawyer sees in Word. Passed straight to the renderer.
|
||||
// Missing translates an unbound placeholder key into the marker the
|
||||
// lawyer sees in Word.
|
||||
Missing MissingPlaceholderFn
|
||||
}
|
||||
|
||||
@@ -96,512 +76,24 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
|
||||
if opts.Base == nil {
|
||||
return nil, fmt.Errorf("submission compose: base required")
|
||||
}
|
||||
_ = ctx // reserved for cancellation propagation in later slices
|
||||
sections := opts.Sections
|
||||
|
||||
// Pre-pass: strip macros so the base reads as a plain .docx zip.
|
||||
cleanBytes, err := ConvertDotmToDocx(opts.BaseBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: convert base: %w", err)
|
||||
}
|
||||
|
||||
// Locate + extract word/document.xml so we can splice in-place.
|
||||
documentXML, otherParts, err := splitBaseZip(cleanBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Per-compose hyperlink allocator. Each unique URL gets a fresh
|
||||
// rId outside the base's existing namespace. The post-pass
|
||||
// (patchDocumentXMLRels) writes the matching Relationship rows
|
||||
// before the zip is repacked. Slice D adds inline `[label](url)`
|
||||
// hyperlink support.
|
||||
linkAlloc := newComposerLinkAllocator()
|
||||
|
||||
// Build the rendered-section map: section_key → OOXML span.
|
||||
stylemap := opts.Base.SectionSpec.Stylemap
|
||||
rendered := make(map[string]string, len(sections))
|
||||
keptSections := make([]SubmissionSection, 0, len(sections))
|
||||
for _, sec := range sections {
|
||||
if !sec.Included {
|
||||
continue
|
||||
secs := make([]docx.Section, len(opts.Sections))
|
||||
for i, s := range opts.Sections {
|
||||
secs[i] = docx.Section{
|
||||
Key: s.SectionKey,
|
||||
OrderIndex: s.OrderIndex,
|
||||
Included: s.Included,
|
||||
ContentMDDE: s.ContentMDDE,
|
||||
ContentMDEN: s.ContentMDEN,
|
||||
}
|
||||
md := sec.ContentMDDE
|
||||
if strings.EqualFold(opts.Lang, "en") {
|
||||
md = sec.ContentMDEN
|
||||
}
|
||||
rendered[sec.SectionKey] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
|
||||
keptSections = append(keptSections, sec)
|
||||
}
|
||||
// Stable order — already sorted ascending by ListForDraft, but
|
||||
// belt-and-braces in case the caller swaps the ordering policy
|
||||
// later.
|
||||
sort.SliceStable(keptSections, func(i, j int) bool {
|
||||
return keptSections[i].OrderIndex < keptSections[j].OrderIndex
|
||||
return c.inner.Compose(ctx, docx.ComposeOptions{
|
||||
Sections: secs,
|
||||
Carrier: docx.Carrier{
|
||||
Bytes: opts.BaseBytes,
|
||||
Stylemap: opts.Base.SectionSpec.Stylemap,
|
||||
},
|
||||
Lang: opts.Lang,
|
||||
Vars: opts.Vars,
|
||||
Missing: opts.Missing,
|
||||
})
|
||||
|
||||
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
|
||||
|
||||
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
|
||||
// for inline `[label](url)` links, the base's
|
||||
// word/_rels/document.xml.rels needs matching <Relationship>
|
||||
// entries so Word can resolve the rIds. Mutates one zip part in
|
||||
// otherParts (or appends if missing).
|
||||
if linkAlloc.HasLinks() {
|
||||
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
otherParts = updatedParts
|
||||
}
|
||||
|
||||
// Re-pack into a zip with the assembled document.xml. All other
|
||||
// parts (styles, fonts, headers, footers, theme, settings) pass
|
||||
// through bit-for-bit at their original mtime + compression.
|
||||
repacked, err := repackBaseZip(otherParts, assembledBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Final pass: substitute placeholders against the merged bag. The
|
||||
// existing renderer handles cross-run fragmentation, the `{{rule.X}}`
|
||||
// alias contract, and the missing-marker emission. Reusing it
|
||||
// guarantees v1's placeholder grammar stays intact inside section
|
||||
// content + base chrome.
|
||||
merged, err := c.renderer.Render(repacked, opts.Vars, opts.Missing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: placeholder pass: %w", err)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Section splicing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Anchor markers as they appear inside a <w:t> text node. We don't
|
||||
// need a full XML parse — finding the marker text inside the body is
|
||||
// sufficient because:
|
||||
// - {{ and }} are never legitimate document content (placeholders
|
||||
// follow the same convention everywhere else in paliad).
|
||||
// - The anchor key grammar [A-Za-z0-9_]+ rules out any HTML/XML
|
||||
// special characters.
|
||||
// - Each anchor lives in exactly one <w:t>...<w:t>, which lives in
|
||||
// exactly one <w:r>...</w:r>, which lives in exactly one
|
||||
// <w:p>...</w:p>. We expand from the marker outward to find the
|
||||
// enclosing <w:p> span and drop the entire paragraph as part of
|
||||
// the splice.
|
||||
//
|
||||
// RE2 has no lookahead, so the "find enclosing <w:p>" logic is
|
||||
// implemented as manual byte-index search around the marker hit
|
||||
// (anchorParagraphSpan below) rather than a single regex pattern.
|
||||
|
||||
const (
|
||||
anchorOpenPrefix = "{{#section:"
|
||||
anchorClosePrefix = "{{/section:"
|
||||
anchorSuffix = "}}"
|
||||
)
|
||||
|
||||
// anchorKeyRegex validates that the captured anchor key is a clean
|
||||
// identifier. Keys that include other characters (which can't actually
|
||||
// appear in our authored .docx) are treated as no match.
|
||||
var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
||||
|
||||
// anchorPair records the byte span of one matched anchor pair inside
|
||||
// the body — from the start of the opening anchor's <w:p> element
|
||||
// through the end of the closing anchor's </w:p>.
|
||||
type anchorPair struct {
|
||||
key string
|
||||
openStart int // start of <w:p> for the opening anchor
|
||||
closeEnd int // index just past </w:p> for the closing anchor
|
||||
}
|
||||
|
||||
// findAllAnchorPairs scans the body for matched open/close anchor
|
||||
// pairs. Unbalanced markers (open without close, or vice versa) are
|
||||
// dropped from the result. Returns pairs in body-order; each pair's
|
||||
// span is non-overlapping.
|
||||
func findAllAnchorPairs(body string) []anchorPair {
|
||||
type marker struct {
|
||||
key string
|
||||
paraStart int
|
||||
paraEnd int
|
||||
isOpen bool
|
||||
}
|
||||
var markers []marker
|
||||
|
||||
collect := func(prefix string, isOpen bool) {
|
||||
offset := 0
|
||||
for {
|
||||
idx := strings.Index(body[offset:], prefix)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
start := offset + idx
|
||||
suffixIdx := strings.Index(body[start+len(prefix):], anchorSuffix)
|
||||
if suffixIdx < 0 {
|
||||
return
|
||||
}
|
||||
key := body[start+len(prefix) : start+len(prefix)+suffixIdx]
|
||||
if !anchorKeyRegex.MatchString(key) {
|
||||
offset = start + len(prefix)
|
||||
continue
|
||||
}
|
||||
markerEnd := start + len(prefix) + suffixIdx + len(anchorSuffix)
|
||||
pStart, pEnd, ok := paragraphSpanAround(body, start, markerEnd)
|
||||
if !ok {
|
||||
offset = markerEnd
|
||||
continue
|
||||
}
|
||||
markers = append(markers, marker{key: key, paraStart: pStart, paraEnd: pEnd, isOpen: isOpen})
|
||||
offset = pEnd
|
||||
}
|
||||
}
|
||||
collect(anchorOpenPrefix, true)
|
||||
collect(anchorClosePrefix, false)
|
||||
|
||||
// Walk markers in body-order, matching each open with the next
|
||||
// close that carries the same key.
|
||||
sort.SliceStable(markers, func(i, j int) bool {
|
||||
return markers[i].paraStart < markers[j].paraStart
|
||||
})
|
||||
var pairs []anchorPair
|
||||
openStack := map[string]marker{}
|
||||
for _, m := range markers {
|
||||
if m.isOpen {
|
||||
openStack[m.key] = m
|
||||
continue
|
||||
}
|
||||
o, ok := openStack[m.key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, anchorPair{
|
||||
key: m.key,
|
||||
openStart: o.paraStart,
|
||||
closeEnd: m.paraEnd,
|
||||
})
|
||||
delete(openStack, m.key)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// paragraphSpanAround returns the byte span of the smallest `<w:p>...</w:p>`
|
||||
// element that fully contains the byte range [markerStart, markerEnd).
|
||||
// Returns false when the byte range doesn't sit inside a single
|
||||
// paragraph (which would mean the marker survived a cross-paragraph
|
||||
// edit — defensive guard, shouldn't happen in well-formed input).
|
||||
func paragraphSpanAround(body string, markerStart, markerEnd int) (int, int, bool) {
|
||||
// Walk backwards to find the nearest unclosed <w:p ... > opening.
|
||||
// Since <w:p> doesn't nest, the nearest <w:p before markerStart is
|
||||
// the enclosing paragraph's opening tag.
|
||||
pStart := -1
|
||||
cursor := markerStart
|
||||
for cursor > 0 {
|
||||
idx := strings.LastIndex(body[:cursor], "<w:p")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
// Confirm this is a paragraph open, not a different
|
||||
// w:p-prefixed tag (e.g. <w:pPr>).
|
||||
if idx+4 <= len(body) {
|
||||
after := body[idx+4]
|
||||
if after == ' ' || after == '>' || after == '/' {
|
||||
// <w:p ...> or <w:p>; not <w:pPr>.
|
||||
close := strings.Index(body[idx:], ">")
|
||||
if close < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pStart = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
cursor = idx
|
||||
}
|
||||
if pStart < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Walk forward to find the matching </w:p>. <w:p> doesn't nest so
|
||||
// the next </w:p> after the marker is the close.
|
||||
pEndIdx := strings.Index(body[markerEnd:], "</w:p>")
|
||||
if pEndIdx < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pEnd := markerEnd + pEndIdx + len("</w:p>")
|
||||
return pStart, pEnd, true
|
||||
}
|
||||
|
||||
// spliceSections replaces anchor slots with rendered sections and
|
||||
// appends any unanchored sections before sectPr. Returns the assembled
|
||||
// document.xml body.
|
||||
func spliceSections(documentXML []byte, rendered map[string]string, kept []SubmissionSection, all []SubmissionSection) []byte {
|
||||
body := string(documentXML)
|
||||
pairs := findAllAnchorPairs(body)
|
||||
|
||||
// Build a lookup of kept section keys for quick membership tests.
|
||||
keptByKey := map[string]int{}
|
||||
for i, sec := range kept {
|
||||
keptByKey[sec.SectionKey] = i
|
||||
}
|
||||
allByKey := map[string]int{}
|
||||
for i, sec := range all {
|
||||
allByKey[sec.SectionKey] = i
|
||||
}
|
||||
|
||||
matchedKeys := map[string]bool{}
|
||||
|
||||
// Walk pairs in REVERSE body-order so slice mutations don't shift
|
||||
// later offsets.
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
return pairs[i].openStart > pairs[j].openStart
|
||||
})
|
||||
for _, p := range pairs {
|
||||
replacement := ""
|
||||
if idx, ok := keptByKey[p.key]; ok {
|
||||
replacement = rendered[p.key]
|
||||
matchedKeys[p.key] = true
|
||||
_ = idx
|
||||
} else if _, isOnDraft := allByKey[p.key]; isOnDraft {
|
||||
// Anchor matches an excluded section on the draft — drop
|
||||
// the entire slot.
|
||||
replacement = ""
|
||||
} else {
|
||||
// Anchor doesn't match any section on this draft — drop
|
||||
// to leave the base's chrome unbroken.
|
||||
replacement = ""
|
||||
}
|
||||
body = body[:p.openStart] + replacement + body[p.closeEnd:]
|
||||
}
|
||||
|
||||
// Append unanchored sections before sectPr in order_index ASC.
|
||||
var unanchored strings.Builder
|
||||
for _, sec := range kept {
|
||||
if matchedKeys[sec.SectionKey] {
|
||||
continue
|
||||
}
|
||||
unanchored.WriteString(rendered[sec.SectionKey])
|
||||
}
|
||||
if unanchored.Len() > 0 {
|
||||
body = appendBeforeSectPr(body, unanchored.String())
|
||||
}
|
||||
|
||||
return []byte(body)
|
||||
}
|
||||
|
||||
// appendBeforeSectPr inserts content immediately before the first
|
||||
// `<w:sectPr` element in the body, or at the end of the body if there
|
||||
// is none. Word documents conventionally close the body with a sectPr
|
||||
// describing page setup; we want to land sections before that element
|
||||
// so they show up on the actual pages.
|
||||
var sectPrRegex = regexp.MustCompile(`<w:sectPr\b`)
|
||||
|
||||
func appendBeforeSectPr(body, content string) string {
|
||||
loc := sectPrRegex.FindStringIndex(body)
|
||||
if loc == nil {
|
||||
// No sectPr → append before `</w:body>` if present, else at
|
||||
// the very end.
|
||||
idx := strings.LastIndex(body, "</w:body>")
|
||||
if idx < 0 {
|
||||
return body + content
|
||||
}
|
||||
return body[:idx] + content + body[idx:]
|
||||
}
|
||||
return body[:loc[0]] + content + body[loc[0]:]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Zip plumbing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// baseZipPart captures one zip entry we kept aside while extracting
|
||||
// document.xml.
|
||||
type baseZipPart struct {
|
||||
name string
|
||||
method uint16
|
||||
modTime int64 // wall seconds; converted back to time.Time on repack
|
||||
body []byte
|
||||
}
|
||||
|
||||
// splitBaseZip extracts document.xml and returns it alongside every
|
||||
// other zip entry, ready for repacking.
|
||||
func splitBaseZip(cleanBytes []byte) ([]byte, []baseZipPart, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: open base zip: %w", err)
|
||||
}
|
||||
var documentXML []byte
|
||||
parts := make([]baseZipPart, 0, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
body, err := readZipEntry(f)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: read %s: %w", f.Name, err)
|
||||
}
|
||||
if f.Name == "word/document.xml" {
|
||||
documentXML = body
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: nil})
|
||||
continue
|
||||
}
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: body})
|
||||
}
|
||||
if documentXML == nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: base zip missing word/document.xml")
|
||||
}
|
||||
return documentXML, parts, nil
|
||||
}
|
||||
|
||||
// repackBaseZip rebuilds the zip, swapping document.xml for the
|
||||
// assembled body and leaving every other part untouched.
|
||||
func repackBaseZip(parts []baseZipPart, assembledBody []byte) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, p := range parts {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: p.name,
|
||||
Method: p.method,
|
||||
}
|
||||
if p.modTime > 0 {
|
||||
hdr.Modified = time.Unix(p.modTime, 0)
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write header %s: %w", p.name, err)
|
||||
}
|
||||
body := p.body
|
||||
if p.name == "word/document.xml" {
|
||||
body = assembledBody
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write body %s: %w", p.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice D — hyperlink wiring
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// composerLinkAllocator hands out fresh rIds for inline hyperlink
|
||||
// targets discovered by the MD walker. Each unique URL gets one rId
|
||||
// (deduped — repeated links to the same URL share one Relationship).
|
||||
// Allocations land outside the base's rId namespace by prefixing with
|
||||
// "rIdComposer" so they can't collide with existing relationships.
|
||||
type composerLinkAllocator struct {
|
||||
next int
|
||||
byURL map[string]string
|
||||
order []string // URLs in allocation order
|
||||
}
|
||||
|
||||
func newComposerLinkAllocator() *composerLinkAllocator {
|
||||
return &composerLinkAllocator{byURL: map[string]string{}}
|
||||
}
|
||||
|
||||
// Alloc returns the rId for url, allocating one on first sight.
|
||||
func (a *composerLinkAllocator) Alloc(url string) string {
|
||||
if rid, ok := a.byURL[url]; ok {
|
||||
return rid
|
||||
}
|
||||
a.next++
|
||||
rid := fmt.Sprintf("rIdComposer%d", a.next)
|
||||
a.byURL[url] = rid
|
||||
a.order = append(a.order, url)
|
||||
return rid
|
||||
}
|
||||
|
||||
// HasLinks reports whether any links were allocated during this compose.
|
||||
func (a *composerLinkAllocator) HasLinks() bool {
|
||||
return len(a.order) > 0
|
||||
}
|
||||
|
||||
// Pairs returns the (rId, URL) pairs in allocation order. The
|
||||
// document.xml.rels patcher consumes this to emit <Relationship>
|
||||
// elements.
|
||||
func (a *composerLinkAllocator) Pairs() [][2]string {
|
||||
pairs := make([][2]string, 0, len(a.order))
|
||||
for _, url := range a.order {
|
||||
pairs = append(pairs, [2]string{a.byURL[url], url})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
|
||||
// in `parts` to append the given (rId, URL) pairs as hyperlink
|
||||
// relationships. If the rels part doesn't exist (some bases omit it
|
||||
// when the body has no relationships), this function appends a fresh
|
||||
// part with the minimal Relationships wrapper.
|
||||
//
|
||||
// Idempotent on (rId, URL) pairs already present (e.g. when a base
|
||||
// already references the URL for some other reason).
|
||||
//
|
||||
// Returns the (possibly extended) parts slice — callers must overwrite
|
||||
// their reference because the append in the no-rels-yet case grows the
|
||||
// backing array.
|
||||
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
|
||||
const path = "word/_rels/document.xml.rels"
|
||||
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
||||
|
||||
existingIdx := -1
|
||||
for i := range parts {
|
||||
if parts[i].name == path {
|
||||
existingIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var body string
|
||||
if existingIdx >= 0 {
|
||||
body = string(parts[existingIdx].body)
|
||||
} else {
|
||||
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
|
||||
}
|
||||
|
||||
var inserts strings.Builder
|
||||
for _, p := range pairs {
|
||||
rid := p[0]
|
||||
url := p[1]
|
||||
if strings.Contains(body, `Id="`+rid+`"`) {
|
||||
continue
|
||||
}
|
||||
inserts.WriteString(`<Relationship Id="`)
|
||||
inserts.WriteString(xmlAttrEscape(rid))
|
||||
inserts.WriteString(`" Type="`)
|
||||
inserts.WriteString(hyperlinkType)
|
||||
inserts.WriteString(`" Target="`)
|
||||
inserts.WriteString(xmlAttrEscape(url))
|
||||
inserts.WriteString(`" TargetMode="External"/>`)
|
||||
}
|
||||
|
||||
if inserts.Len() == 0 {
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
closeIdx := strings.LastIndex(body, "</Relationships>")
|
||||
if closeIdx < 0 {
|
||||
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
|
||||
}
|
||||
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
|
||||
|
||||
if existingIdx >= 0 {
|
||||
parts[existingIdx].body = []byte(patched)
|
||||
return parts, nil
|
||||
}
|
||||
parts = append(parts, baseZipPart{
|
||||
name: path,
|
||||
method: zip.Deflate,
|
||||
modTime: time.Now().Unix(),
|
||||
body: []byte(patched),
|
||||
})
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
129
internal/services/submission_draft_autoname_live_test.go
Normal file
129
internal/services/submission_draft_autoname_live_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package services
|
||||
|
||||
// Live-DB test for the submission-draft auto-naming scheme
|
||||
// (t-paliad-352 / m/paliad#155). Skipped without TEST_DATABASE_URL.
|
||||
//
|
||||
// Verifies the shipped Create flow end-to-end against real Postgres:
|
||||
// a project-bound draft is auto-named "<date> <client> ./. <forum> ./.
|
||||
// <opponent>" rather than "Entwurf N", the segments resolve from the
|
||||
// real project tree (client = root ancestor, forum = proceeding-type
|
||||
// jurisdiction, opponent = opposing party by our_side), and a second
|
||||
// draft on the same slot de-duplicates with a " (2)" suffix.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestSubmissionDraft_AutoName_Live(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()
|
||||
|
||||
userID := uuid.New()
|
||||
email := "autoname-" + userID.String()[:8] + "@hlc.com"
|
||||
var clientID, caseID uuid.UUID
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.parties WHERE project_id = $1`, caseID)
|
||||
// Children first (FK), then root.
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, caseID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, clientID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
defer cleanup()
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, $2, 'Auto Name', 'munich', 'standard', 'de')`, userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
|
||||
// Client root → case child. The case carries the proceeding type
|
||||
// (UPC) and our_side (claimant), the party is the opponent.
|
||||
client, err := projects.Create(ctx, userID, CreateProjectInput{
|
||||
Type: "client", Title: "Bayer AG",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create client project: %v", err)
|
||||
}
|
||||
clientID = client.ID
|
||||
|
||||
ptID := 8 // upc.inf.cfi → jurisdiction UPC
|
||||
side := "claimant"
|
||||
caseProj, err := projects.Create(ctx, userID, CreateProjectInput{
|
||||
Type: "case", Title: "Streitsache", ParentID: &client.ID,
|
||||
ProceedingTypeID: &ptID, OurSide: &side,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create case project: %v", err)
|
||||
}
|
||||
caseID = caseProj.ID
|
||||
|
||||
beklagte := "Beklagte"
|
||||
if _, err := parties.Create(ctx, userID, caseProj.ID, CreatePartyInput{
|
||||
Name: "Novartis Pharma", Role: &beklagte,
|
||||
}); err != nil {
|
||||
t.Fatalf("create party: %v", err)
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
today := time.Now().In(loc).Format("2006-01-02")
|
||||
wantBase := today + " Bayer AG ./. UPC ./. Novartis Pharma"
|
||||
|
||||
d1, err := drafts.Create(ctx, userID, &caseProj.ID, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft 1: %v", err)
|
||||
}
|
||||
if d1.Name != wantBase {
|
||||
t.Fatalf("draft 1 name = %q, want %q", d1.Name, wantBase)
|
||||
}
|
||||
|
||||
// Second draft on the same (project, code) slot must de-duplicate.
|
||||
d2, err := drafts.Create(ctx, userID, &caseProj.ID, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft 2: %v", err)
|
||||
}
|
||||
want2 := wantBase + " (2)"
|
||||
if d2.Name != want2 {
|
||||
t.Fatalf("draft 2 name = %q, want %q", d2.Name, want2)
|
||||
}
|
||||
|
||||
// A project-less draft keeps the legacy Entwurf-N counter.
|
||||
dless, err := drafts.Create(ctx, userID, nil, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create project-less draft: %v", err)
|
||||
}
|
||||
if dless.Name != "Entwurf 1" {
|
||||
t.Fatalf("project-less draft name = %q, want %q", dless.Name, "Entwurf 1")
|
||||
}
|
||||
}
|
||||
111
internal/services/submission_draft_keyword_live_test.go
Normal file
111
internal/services/submission_draft_keyword_live_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package services
|
||||
|
||||
// Live-DB test for the user-replaceable filename keyword
|
||||
// (t-paliad-354). Skipped without TEST_DATABASE_URL.
|
||||
//
|
||||
// Exercises the real Update → Get code path against Postgres: setting the
|
||||
// override merges into composer_meta.filename_keyword without clobbering
|
||||
// other composer keys, clearing it removes only that key, and the value
|
||||
// reads back through the same jsonb decode the export handler relies on.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestSubmissionDraft_FilenameKeyword_Live(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()
|
||||
|
||||
userID := uuid.New()
|
||||
email := "kw-" + userID.String()[:8] + "@hlc.com"
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
defer cleanup()
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, $2, 'Keyword Tester', 'munich', 'standard', 'de')`, userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
|
||||
// A project-less draft is the simplest fixture — no project tree
|
||||
// needed to exercise composer_meta persistence.
|
||||
d, err := drafts.Create(ctx, userID, nil, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed an unrelated composer_meta key to prove the merge/delete
|
||||
// only touches filename_keyword.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.submission_drafts SET composer_meta = '{"other":"keep-me"}'::jsonb WHERE id = $1`,
|
||||
d.ID); err != nil {
|
||||
t.Fatalf("seed composer_meta: %v", err)
|
||||
}
|
||||
|
||||
// Set the override.
|
||||
kw := "Replik Hauptantrag"
|
||||
got, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &kw})
|
||||
if err != nil {
|
||||
t.Fatalf("update set keyword: %v", err)
|
||||
}
|
||||
if v, _ := got.ComposerMeta["filename_keyword"].(string); v != kw {
|
||||
t.Fatalf("after set: filename_keyword = %q, want %q", v, kw)
|
||||
}
|
||||
if v, _ := got.ComposerMeta["other"].(string); v != "keep-me" {
|
||||
t.Fatalf("after set: unrelated key 'other' = %q, want %q (merge clobbered it)", v, "keep-me")
|
||||
}
|
||||
|
||||
// Read back through Get (the path the export handler uses).
|
||||
reload, err := drafts.Get(ctx, userID, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after set: %v", err)
|
||||
}
|
||||
if v, _ := reload.ComposerMeta["filename_keyword"].(string); v != kw {
|
||||
t.Fatalf("reload: filename_keyword = %q, want %q", v, kw)
|
||||
}
|
||||
|
||||
// Clear the override (empty string) — only filename_keyword should go.
|
||||
empty := ""
|
||||
cleared, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &empty})
|
||||
if err != nil {
|
||||
t.Fatalf("update clear keyword: %v", err)
|
||||
}
|
||||
if _, present := cleared.ComposerMeta["filename_keyword"]; present {
|
||||
t.Fatalf("after clear: filename_keyword still present: %v", cleared.ComposerMeta)
|
||||
}
|
||||
if v, _ := cleared.ComposerMeta["other"].(string); v != "keep-me" {
|
||||
t.Fatalf("after clear: unrelated key 'other' = %q, want %q (delete removed too much)", v, "keep-me")
|
||||
}
|
||||
}
|
||||
@@ -63,12 +63,17 @@ type SubmissionDraft struct {
|
||||
// ON DELETE SET NULL keeps a draft renderable if its base is
|
||||
// removed; the lawyer picks a new one via the sidebar.
|
||||
BaseID *uuid.UUID `db:"base_id" json:"base_id,omitempty"`
|
||||
// TemplateVersionID pins an uploaded docforge template version
|
||||
// (t-paliad-349 slice 7). NULL = render via base_id Composer path or
|
||||
// the v1 fallback; non-NULL = render the pinned version's carrier.
|
||||
// The export/preview path checks this first. ON DELETE SET NULL.
|
||||
TemplateVersionID *uuid.UUID `db:"template_version_id" json:"template_version_id,omitempty"`
|
||||
// ComposerMetaRaw / ComposerMeta — Composer-side metadata jsonb.
|
||||
// Slice A: empty default. Future slices populate section_order,
|
||||
// hidden_sections, etc.
|
||||
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// Variables is the decoded overrides map; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
@@ -170,6 +175,22 @@ type DraftPatch struct {
|
||||
// content is unaffected — the base swap is render-side only.
|
||||
// t-paliad-313.
|
||||
BaseID **uuid.UUID
|
||||
|
||||
// TemplateVersionID pins (or clears) an uploaded docforge template
|
||||
// version. Same three-state two-level pointer as BaseID:
|
||||
// nil → no change
|
||||
// *p == nil → clear (back to base_id / v1)
|
||||
// **p → pin the version (validated via TemplateStore.GetVersion)
|
||||
// t-paliad-349 slice 7.
|
||||
TemplateVersionID **uuid.UUID
|
||||
|
||||
// FilenameKeyword sets (or clears) the user override that leads the
|
||||
// exported document name "<date> <keyword> (<case>)" (t-paliad-354).
|
||||
// Stored under composer_meta.filename_keyword — no dedicated column:
|
||||
// nil → no change
|
||||
// *p == "" → clear the key (back to the auto-derived rule name)
|
||||
// *p == "x" → set the override
|
||||
FilenameKeyword *string
|
||||
}
|
||||
|
||||
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
|
||||
@@ -186,7 +207,7 @@ const draftColumns = `id, project_id, submission_code, user_id, name, language,
|
||||
variables, selected_parties,
|
||||
last_exported_at, last_exported_sha,
|
||||
last_imported_at,
|
||||
base_id, composer_meta,
|
||||
base_id, template_version_id, composer_meta,
|
||||
created_at, updated_at`
|
||||
|
||||
// List returns every draft for (project, submission_code, user)
|
||||
@@ -239,7 +260,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
|
||||
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
|
||||
d.variables, d.selected_parties,
|
||||
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
|
||||
d.base_id, d.composer_meta,
|
||||
d.base_id, d.template_version_id, d.composer_meta,
|
||||
d.created_at, d.updated_at,
|
||||
p.title AS project_title,
|
||||
p.reference AS project_reference
|
||||
@@ -343,12 +364,15 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
|
||||
// creates with base_id=NULL — Composer is additive, the v1 fallback
|
||||
// path remains valid.
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
var project *models.Project
|
||||
if projectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
|
||||
p, err := s.projects.GetByID(ctx, userID, *projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
project = p
|
||||
}
|
||||
name, err := s.nextDraftName(ctx, projectID, submissionCode, userID, lang)
|
||||
name, err := s.newDraftName(ctx, userID, project, projectID, submissionCode, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -418,20 +442,94 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
|
||||
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
|
||||
// suffix if two callers race; the unique constraint on the table is
|
||||
// the final guard.
|
||||
// newDraftName picks the title for a freshly-created draft. Project-
|
||||
// bound drafts get the auto-name scheme (t-paliad-352 / m/paliad#155) —
|
||||
// "<date> <client> ./. <forum> ./. <opponent>", de-duplicated against
|
||||
// the user's existing drafts for the same (project, submission_code).
|
||||
// Project-less drafts (and any project-bound draft whose auto-name
|
||||
// resolves to nothing) fall back to the "Entwurf N" / "Draft N"
|
||||
// counter.
|
||||
//
|
||||
// A nil projectID scopes the search to the user's project-less drafts
|
||||
// for this submission_code — matches the row-uniqueness contract on
|
||||
// the DB side (project_id, submission_code, user_id, name) where
|
||||
// project_id IS NULL is its own equivalence class.
|
||||
func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID, lang string) (string, error) {
|
||||
prefix := "Entwurf"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "Draft"
|
||||
// Only Create calls this — existing drafts are never renamed (the
|
||||
// scheme is create-time only, per #155). A lawyer's later manual rename
|
||||
// flows through Update and is left untouched.
|
||||
func (s *SubmissionDraftService) newDraftName(ctx context.Context, userID uuid.UUID, project *models.Project, projectID *uuid.UUID, submissionCode, lang string) (string, error) {
|
||||
existing, err := s.existingDraftNames(ctx, projectID, submissionCode, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if project != nil {
|
||||
auto, err := s.autoNameForProject(ctx, time.Now(), project)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(auto) != "" {
|
||||
return uniqueDraftName(auto, existing), nil
|
||||
}
|
||||
}
|
||||
return nextDraftName(existing, lang), nil
|
||||
}
|
||||
|
||||
// autoNameForProject resolves the three identity segments for a
|
||||
// project-bound draft and hands them to the pure AutoSubmissionTitle
|
||||
// assembler. The client is the root ancestor of the project tree (the
|
||||
// 'client' node), the proceeding type and our_side come off the draft's
|
||||
// own project node, and the parties hang directly off it.
|
||||
//
|
||||
// A failure to resolve the client / proceeding type is not fatal —
|
||||
// AutoSubmissionTitle just omits the empty segment — so the only errors
|
||||
// returned here are genuine DB faults.
|
||||
func (s *SubmissionDraftService) autoNameForProject(ctx context.Context, now time.Time, project *models.Project) (string, error) {
|
||||
clientName, err := s.clientNameForProject(ctx, project.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
pt, err := s.vars.loadProceedingType(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var parties []models.Party
|
||||
if err := s.db.SelectContext(ctx, &parties,
|
||||
`SELECT id, project_id, name, role, representative, contact_info,
|
||||
created_at, updated_at
|
||||
FROM paliad.parties
|
||||
WHERE project_id = $1
|
||||
ORDER BY name`, project.ID); err != nil {
|
||||
return "", fmt.Errorf("auto-name: load parties: %w", err)
|
||||
}
|
||||
|
||||
return AutoSubmissionTitle(now, clientName, project, parties, pt), nil
|
||||
}
|
||||
|
||||
// clientNameForProject returns the title of the 'client' ancestor in
|
||||
// the project's path (the firm's mandant). Empty string when the tree
|
||||
// has no client node — the auto-name then omits the client segment.
|
||||
func (s *SubmissionDraftService) clientNameForProject(ctx context.Context, projectID uuid.UUID) (string, error) {
|
||||
var title string
|
||||
err := s.db.GetContext(ctx, &title,
|
||||
`SELECT p.title
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.projects p
|
||||
ON p.id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
WHERE target.id = $1 AND p.type = 'client'
|
||||
LIMIT 1`, projectID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auto-name: resolve client name: %w", err)
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
// existingDraftNames returns the names already in use for the
|
||||
// (project, submission_code, user) slot. A nil projectID scopes to the
|
||||
// user's project-less drafts for this submission_code — matching the
|
||||
// DB unique contract (project_id, submission_code, user_id, name) where
|
||||
// project_id IS NULL is its own equivalence class.
|
||||
func (s *SubmissionDraftService) existingDraftNames(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID) ([]string, error) {
|
||||
var names []string
|
||||
var err error
|
||||
if projectID == nil {
|
||||
@@ -446,16 +544,48 @@ func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *u
|
||||
*projectID, submissionCode, userID)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("scan existing draft names: %w", err)
|
||||
return nil, fmt.Errorf("scan existing draft names: %w", err)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
|
||||
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
|
||||
// suffix if two callers race; the unique constraint on the table is
|
||||
// the final guard. Pure over the supplied name list.
|
||||
func nextDraftName(existing []string, lang string) string {
|
||||
prefix := "Entwurf"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "Draft"
|
||||
}
|
||||
highest := 0
|
||||
for _, n := range names {
|
||||
for _, n := range existing {
|
||||
var idx int
|
||||
if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
|
||||
highest = idx
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s %d", prefix, highest+1), nil
|
||||
return fmt.Sprintf("%s %d", prefix, highest+1)
|
||||
}
|
||||
|
||||
// uniqueDraftName returns base unchanged when it's free, otherwise
|
||||
// appends " (N)" with the lowest N≥2 that isn't taken. Mirrors the
|
||||
// "race → unique constraint is the final guard" contract of
|
||||
// nextDraftName; pure over the supplied name list.
|
||||
func uniqueDraftName(base string, existing []string) string {
|
||||
taken := make(map[string]struct{}, len(existing))
|
||||
for _, n := range existing {
|
||||
taken[n] = struct{}{}
|
||||
}
|
||||
if _, clash := taken[base]; !clash {
|
||||
return base
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
cand := fmt.Sprintf("%s (%d)", base, i)
|
||||
if _, clash := taken[cand]; !clash {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update patches the draft. Variables is replace-semantics — pass the
|
||||
@@ -567,6 +697,30 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.TemplateVersionID != nil {
|
||||
newTV := *patch.TemplateVersionID // *uuid.UUID — nil means clear
|
||||
// Existence is enforced by the FK + validated at the handler via
|
||||
// TemplateStore.GetVersion (clean 404); here we just set it.
|
||||
setParts = append(setParts, fmt.Sprintf("template_version_id = $%d", idx))
|
||||
args = append(args, newTV)
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.FilenameKeyword != nil {
|
||||
// Targeted jsonb merge so other composer_meta keys survive. An
|
||||
// empty override removes the key entirely, restoring the
|
||||
// auto-derived rule name as the filename keyword (t-paliad-354).
|
||||
kw := strings.TrimSpace(*patch.FilenameKeyword)
|
||||
if kw == "" {
|
||||
setParts = append(setParts, "composer_meta = composer_meta - 'filename_keyword'")
|
||||
} else {
|
||||
setParts = append(setParts,
|
||||
fmt.Sprintf("composer_meta = composer_meta || jsonb_build_object('filename_keyword', $%d::text)", idx))
|
||||
args = append(args, kw)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return existing, nil
|
||||
}
|
||||
@@ -878,7 +1032,6 @@ func normalizeDraftLanguage(lang string) string {
|
||||
return "de"
|
||||
}
|
||||
|
||||
|
||||
// Compile-time guard: ensure the *models.User reference in the import
|
||||
// graph doesn't get optimised away by linters. The service doesn't
|
||||
// dereference User directly — that happens in SubmissionVarsService —
|
||||
|
||||
184
internal/services/submission_draft_template_live_test.go
Normal file
184
internal/services/submission_draft_template_live_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package services
|
||||
|
||||
// Live-DB test for generation-on-uploaded-templates (t-paliad-349 slice 7).
|
||||
// Skipped without TEST_DATABASE_URL. Verifies the shipped draft-service
|
||||
// change end-to-end against real Postgres:
|
||||
// 1. submission_drafts.template_version_id round-trips through
|
||||
// Update → Get (the column-sync + patch path), and clears to NULL.
|
||||
// 2. An uploaded template's carrier renders via the v1 Export path:
|
||||
// {{firm.name}} in the carrier substitutes to the branding name.
|
||||
//
|
||||
// This is the verification the head greenlit (option C) before the
|
||||
// shipped-code change is committed.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
func TestSubmissionDraft_TemplateVersionPin(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()
|
||||
|
||||
userID := uuid.New()
|
||||
email := "tplpin-" + userID.String()[:8] + "@hlc.com"
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.templates WHERE created_by = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, $2, 'Tpl Pin', 'munich', 'standard', 'de')`, userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
store := NewPgTemplateStore(pool)
|
||||
|
||||
// Uploaded template whose carrier carries a {{firm.name}} slot.
|
||||
carrier := minimalDocxWithBody(t, `<w:p><w:r><w:t>Von {{firm.name}}</w:t></w:r></w:p>`)
|
||||
tmpl, err := store.Create(ctx,
|
||||
docforge.TemplateMetaInput{NameDE: "Pin-Test", NameEN: "Pin test", CreatedBy: userID.String()},
|
||||
docforge.TemplateVersionInput{CarrierBytes: carrier, CreatedBy: userID.String()})
|
||||
if err != nil {
|
||||
t.Fatalf("store.Create: %v", err)
|
||||
}
|
||||
if tmpl.VersionID == "" {
|
||||
t.Fatalf("template VersionID empty — generation can't pin it")
|
||||
}
|
||||
versionID := uuid.MustParse(tmpl.VersionID)
|
||||
|
||||
// Project-less draft on a code that has a published rule (so Build
|
||||
// resolves). No composer attached → plain draft.
|
||||
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("drafts.Create: %v", err)
|
||||
}
|
||||
if d.TemplateVersionID != nil {
|
||||
t.Errorf("fresh draft has a template pin: %v", d.TemplateVersionID)
|
||||
}
|
||||
|
||||
// --- Pin the version via Update, read it back via Get.
|
||||
pin := &versionID
|
||||
if _, err := drafts.Update(ctx, userID, d.ID, DraftPatch{TemplateVersionID: &pin}); err != nil {
|
||||
t.Fatalf("Update(pin): %v", err)
|
||||
}
|
||||
got, err := drafts.Get(ctx, userID, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after pin: %v", err)
|
||||
}
|
||||
if got.TemplateVersionID == nil || *got.TemplateVersionID != versionID {
|
||||
t.Fatalf("pinned template_version_id = %v; want %s", got.TemplateVersionID, versionID)
|
||||
}
|
||||
|
||||
// --- The uploaded carrier renders via Export: {{firm.name}} → "HLC".
|
||||
out, _, err := drafts.Export(ctx, got, carrier)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
doc := unzipDocumentXML(t, out)
|
||||
if strings.Contains(doc, "{{firm.name}}") {
|
||||
t.Errorf("placeholder not substituted; doc=%s", doc)
|
||||
}
|
||||
if !strings.Contains(doc, "HLC") {
|
||||
t.Errorf("firm.name did not resolve to HLC; doc=%s", doc)
|
||||
}
|
||||
|
||||
// --- Clearing the pin sets it back to NULL.
|
||||
var nilPin *uuid.UUID
|
||||
if _, err := drafts.Update(ctx, userID, d.ID, DraftPatch{TemplateVersionID: &nilPin}); err != nil {
|
||||
t.Fatalf("Update(clear): %v", err)
|
||||
}
|
||||
cleared, err := drafts.Get(ctx, userID, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after clear: %v", err)
|
||||
}
|
||||
if cleared.TemplateVersionID != nil {
|
||||
t.Errorf("template_version_id = %v after clear; want nil", cleared.TemplateVersionID)
|
||||
}
|
||||
}
|
||||
|
||||
// minimalDocxWithBody builds a tiny valid .docx (zip) whose document.xml
|
||||
// body is the given inner XML.
|
||||
func minimalDocxWithBody(t *testing.T, inner string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
add := func(name, body string) {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", name, err)
|
||||
}
|
||||
if _, err := io.WriteString(w, body); err != nil {
|
||||
t.Fatalf("zip write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
add("[Content_Types].xml",
|
||||
`<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
||||
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`)
|
||||
add("word/document.xml",
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`+
|
||||
`<w:body>`+inner+`</w:body></w:document>`)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func unzipDocumentXML(t *testing.T, b []byte) string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
t.Fatalf("open zip: %v", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open document.xml: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
data, _ := io.ReadAll(rc)
|
||||
return string(data)
|
||||
}
|
||||
t.Fatal("document.xml not found in output")
|
||||
return ""
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user