Compare commits
55 Commits
mai/knuth/
...
mai/cronus
| Author | SHA1 | Date | |
|---|---|---|---|
| cd5f752a0e | |||
| 2377f08bd7 | |||
| 1d704f6e04 | |||
| a75731a902 | |||
| 727e01c6c9 | |||
| 5cff38ff3c | |||
| 3097df3918 | |||
| 46b58dcf41 | |||
| 9da4715137 | |||
| 16ec8c490a | |||
| f49c804ddd | |||
| 5901d40b79 | |||
| c767b61a8a | |||
| 4f94697377 | |||
| 2a56b7817c | |||
| 75833082fc | |||
| ce28ea972e | |||
| 6f8b4eabb1 | |||
| e2d75c391d | |||
| 932b177779 | |||
| 989941c648 | |||
| db8e8ba6fd | |||
| d5bf82314a | |||
| 426b90bb88 | |||
| 07acf7b4a2 | |||
| 3e1644820a | |||
| c4c0a82abb | |||
| 5ab14f8b37 | |||
| acf5743fa3 | |||
| d1d0cf9c1d | |||
| 5f0a85fa83 | |||
| 6e585951ee | |||
| 8240717b5a | |||
| 593e6243e0 | |||
| 15cc5e418c | |||
| abf0328dcd | |||
| cc13a5b857 | |||
| abef74fe63 | |||
| 49ddaa4eb8 | |||
| 1bd2ebb4ae | |||
| f6c8eb5bcf | |||
| 5ba4df9d55 | |||
| 7ca6b2d643 | |||
| ed8af0dca9 | |||
| 293e612582 | |||
| 9d3325bd88 | |||
| 18d2e743ba | |||
| 07d2eb472c | |||
| 7cdccd55ae | |||
| d4ed989b8f | |||
| 54fb676db5 | |||
| c3eaa9b1d4 | |||
| 80883eaac5 | |||
| 5e17de6e07 | |||
| 0e1f62e375 |
23
Makefile
23
Makefile
@@ -21,7 +21,7 @@
|
||||
# the test runner's working dirs. None of them touch internal/db/migrations/
|
||||
# files.
|
||||
|
||||
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot
|
||||
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot snapshot-upc
|
||||
|
||||
help:
|
||||
@echo "Paliad — developer targets"
|
||||
@@ -33,6 +33,8 @@ help:
|
||||
@echo " test Short test pass — covers gate tier"
|
||||
@echo " test-go Full Go suite with race detector"
|
||||
@echo " test-frontend Frontend bun:test suite"
|
||||
@echo " snapshot-upc Regenerate pkg/litigationplanner/embedded/upc/ from live DB"
|
||||
@echo " (needs DATABASE_URL — see cmd/gen-upc-snapshot/README.md)"
|
||||
@echo ""
|
||||
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
|
||||
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
|
||||
@@ -141,3 +143,22 @@ refresh-snapshot:
|
||||
' internal/db/testdata/prod-snapshot.sql.tmp > internal/db/testdata/prod-snapshot.sql
|
||||
@rm internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@wc -l internal/db/testdata/prod-snapshot.sql
|
||||
|
||||
# Regenerate the embedded UPC snapshot from a live paliad DB. The
|
||||
# generator applies pending migrations first, then SELECTs the UPC
|
||||
# subset and writes JSON files under pkg/litigationplanner/embedded/upc/.
|
||||
#
|
||||
# Requires DATABASE_URL — Slice C of the litigation-planner extraction
|
||||
# (m/paliad#124 §19). See cmd/gen-upc-snapshot/README.md for the full
|
||||
# operator runbook.
|
||||
snapshot-upc:
|
||||
@if [ -z "$$DATABASE_URL" ]; then \
|
||||
echo "ERROR: DATABASE_URL is not set."; \
|
||||
echo " Snapshot generation needs read access to a paliad DB."; \
|
||||
echo " Set DATABASE_URL to the live paliad Postgres, then re-run."; \
|
||||
exit 2; \
|
||||
fi
|
||||
@echo "==> regenerating UPC snapshot from $$DATABASE_URL"
|
||||
go run ./cmd/gen-upc-snapshot
|
||||
@echo "==> running snapshot tests against the regenerated data"
|
||||
go test ./pkg/litigationplanner/embedded/upc/...
|
||||
|
||||
59
cmd/gen-upc-snapshot/README.md
Normal file
59
cmd/gen-upc-snapshot/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# gen-upc-snapshot
|
||||
|
||||
Regenerates the embedded UPC snapshot consumed by
|
||||
`pkg/litigationplanner/embedded/upc`. Slice C of the litigation-planner
|
||||
extraction (m/paliad#124 §19). See
|
||||
`docs/design-litigation-planner-2026-05-26.md` §19 for the full design.
|
||||
|
||||
## When to regenerate
|
||||
|
||||
After any change that affects the public UPC rule corpus:
|
||||
|
||||
- new rules merged via the admin rule-editor
|
||||
- a deadline-rule migration that touches UPC rows
|
||||
- a `paliad.holidays` update (new public holidays / vacation runs)
|
||||
- a `paliad.courts` update (new UPC LD opens, etc.)
|
||||
- a `paliad.proceeding_types` change for `jurisdiction = 'UPC'`
|
||||
|
||||
The snapshot is operator-controlled — there is no CI regeneration in v1.
|
||||
|
||||
## How to regenerate
|
||||
|
||||
```sh
|
||||
make snapshot-upc
|
||||
```
|
||||
|
||||
or directly:
|
||||
|
||||
```sh
|
||||
DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|-----------------|----------------------------------------|---------|
|
||||
| `-output` | `./pkg/litigationplanner/embedded/upc` | directory to write JSON files into |
|
||||
| `-version` | auto-derived (`YYYY-MM-DD-N`) | override the snapshot version |
|
||||
| `-source-label` | empty | text label written to `meta.json` (`paliad-prod`, `paliad-dev`, …) |
|
||||
|
||||
The generator:
|
||||
|
||||
1. Applies pending migrations against `DATABASE_URL` (snapshot always matches schema HEAD).
|
||||
2. SELECTs UPC active proceeding_types + their published+active rules + referenced trigger_events + DE/UPC holidays + UPC courts.
|
||||
3. Writes pretty-printed JSON to `<output>/{proceeding_types,rules,trigger_events,holidays,courts,meta}.json`.
|
||||
|
||||
## Idempotence
|
||||
|
||||
Running twice with the same DB state produces the same JSON (modulo `meta.generated_at`). Diff-friendly in git.
|
||||
|
||||
## Versioning
|
||||
|
||||
`meta.json.version` uses `YYYY-MM-DD-N` where N starts at 1 and increments on same-day regenerations. The generator reads the existing `meta.json` and bumps automatically.
|
||||
|
||||
## After regeneration
|
||||
|
||||
1. Review the diff: `git diff pkg/litigationplanner/embedded/upc/`.
|
||||
2. Run tests: `go test ./pkg/litigationplanner/embedded/upc/...`.
|
||||
3. Commit with a message like `chore(snapshot): regenerate UPC snapshot (<reason>)`.
|
||||
4. Notify any downstream consumer (youpc.org) that a new paliad release is available.
|
||||
301
cmd/gen-upc-snapshot/main.go
Normal file
301
cmd/gen-upc-snapshot/main.go
Normal file
@@ -0,0 +1,301 @@
|
||||
// Command gen-upc-snapshot reads paliad's live deadline corpus and
|
||||
// writes the UPC subset as JSON files under
|
||||
// pkg/litigationplanner/embedded/upc/. The package's embedded
|
||||
// catalog/holiday/court implementations then serve this data without
|
||||
// any DB roundtrip — letting youpc.org (or any future consumer) run
|
||||
// the litigationplanner engine against the canonical UPC rule set.
|
||||
//
|
||||
// Slice C (m/paliad#124 §19). See docs/design-litigation-planner-2026-05-26.md
|
||||
// §19 for the full design.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot \
|
||||
// [-output ./pkg/litigationplanner/embedded/upc] \
|
||||
// [-version 2026-05-26-1] \
|
||||
// [-source-label paliad-dev-supabase]
|
||||
//
|
||||
// The generator applies migrations against DATABASE_URL before
|
||||
// SELECTing (so the snapshot always matches schema HEAD). Idempotent —
|
||||
// running twice with the same DB state produces the same JSON.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOutput = "./pkg/litigationplanner/embedded/upc"
|
||||
defaultSourceLabel = ""
|
||||
)
|
||||
|
||||
// Meta is the version block written to meta.json. The embedded sub-
|
||||
// package re-defines this type so consumers can decode it without
|
||||
// importing the cmd; the cmd holds the canonical write shape.
|
||||
type Meta struct {
|
||||
Version string `json:"version"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
PaliadCommit string `json:"paliad_commit,omitempty"`
|
||||
SourceDBLabel string `json:"source_db_label,omitempty"`
|
||||
RuleCount int `json:"rule_count"`
|
||||
ProceedingCount int `json:"proceeding_count"`
|
||||
TriggerEventCount int `json:"trigger_event_count"`
|
||||
HolidayCount int `json:"holiday_count"`
|
||||
CourtCount int `json:"court_count"`
|
||||
}
|
||||
|
||||
// EmbeddedHoliday is the holiday row shape the embedded snapshot
|
||||
// stores. JSON tags mirror paliad.holidays so the generator's SELECT
|
||||
// scans onto it directly + the embedded HolidayCalendar reads the
|
||||
// same tag.
|
||||
type EmbeddedHoliday struct {
|
||||
Date string `db:"date_iso" json:"date"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Country *string `db:"country" json:"country,omitempty"`
|
||||
Regime *string `db:"regime" json:"regime,omitempty"`
|
||||
State *string `db:"state" json:"state,omitempty"`
|
||||
HolidayType string `db:"holiday_type" json:"holiday_type"`
|
||||
}
|
||||
|
||||
// EmbeddedCourt is the court row shape the embedded snapshot stores.
|
||||
type EmbeddedCourt struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
NameDE string `db:"name_de" json:"name_de"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Country string `db:"country" json:"country"`
|
||||
Regime *string `db:"regime" json:"regime,omitempty"`
|
||||
CourtType string `db:"court_type" json:"court_type"`
|
||||
ParentID *string `db:"parent_id" json:"parent_id,omitempty"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
output := flag.String("output", defaultOutput, "directory to write JSON files into")
|
||||
version := flag.String("version", "", "explicit snapshot version (auto-derived if empty)")
|
||||
sourceLabel := flag.String("source-label", defaultSourceLabel, "label for source_db in meta.json")
|
||||
flag.Parse()
|
||||
|
||||
url := os.Getenv("DATABASE_URL")
|
||||
if url == "" {
|
||||
log.Fatal("DATABASE_URL must be set")
|
||||
}
|
||||
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
log.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
log.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := run(ctx, pool, *output, *version, *sourceLabel); err != nil {
|
||||
log.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string) error {
|
||||
if err := os.MkdirAll(output, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir output: %w", err)
|
||||
}
|
||||
|
||||
// 1. Proceeding types — UPC + active only. The unified upc.apl row
|
||||
// from B1 mig 134 is included; the 3 archived old appeal codes
|
||||
// (is_active=false) are filtered out by the WHERE.
|
||||
var procs []litigationplanner.ProceedingType
|
||||
if err := pool.SelectContext(ctx, &procs, `
|
||||
SELECT id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active,
|
||||
trigger_event_label_de, trigger_event_label_en,
|
||||
appeal_target
|
||||
FROM paliad.proceeding_types
|
||||
WHERE jurisdiction = 'UPC' AND is_active = true
|
||||
ORDER BY sort_order, id`); err != nil {
|
||||
return fmt.Errorf("select proceeding_types: %w", err)
|
||||
}
|
||||
|
||||
if len(procs) == 0 {
|
||||
return fmt.Errorf("no active UPC proceeding_types — refusing to write empty snapshot")
|
||||
}
|
||||
|
||||
procIDs := make([]int, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
procIDs = append(procIDs, p.ID)
|
||||
}
|
||||
|
||||
// 2. Deadline rules — published + active rules for those proceedings.
|
||||
const ruleCols = `id, proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type, duration_value,
|
||||
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active,
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
||||
choices_offered, applies_to_target`
|
||||
|
||||
q, args, err := sqlx.In(`
|
||||
SELECT `+ruleCols+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id IN (?)
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
ORDER BY proceeding_type_id, sequence_order`, procIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build rules IN: %w", err)
|
||||
}
|
||||
q = pool.Rebind(q)
|
||||
var rules []litigationplanner.Rule
|
||||
if err := pool.SelectContext(ctx, &rules, q, args...); err != nil {
|
||||
return fmt.Errorf("select rules: %w", err)
|
||||
}
|
||||
|
||||
// 3. Trigger events referenced by any UPC rule's trigger_event_id.
|
||||
triggerIDSet := make(map[int64]struct{})
|
||||
for _, r := range rules {
|
||||
if r.TriggerEventID != nil {
|
||||
triggerIDSet[*r.TriggerEventID] = struct{}{}
|
||||
}
|
||||
}
|
||||
var triggers []litigationplanner.TriggerEvent
|
||||
if len(triggerIDSet) > 0 {
|
||||
triggerIDs := make([]int64, 0, len(triggerIDSet))
|
||||
for id := range triggerIDSet {
|
||||
triggerIDs = append(triggerIDs, id)
|
||||
}
|
||||
q, args, err := sqlx.In(`
|
||||
SELECT id, code, name, name_de, description, is_active, created_at
|
||||
FROM paliad.trigger_events
|
||||
WHERE id IN (?)
|
||||
ORDER BY id`, triggerIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build triggers IN: %w", err)
|
||||
}
|
||||
q = pool.Rebind(q)
|
||||
if err := pool.SelectContext(ctx, &triggers, q, args...); err != nil {
|
||||
return fmt.Errorf("select trigger_events: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Holidays — DE national + UPC regime entries. The embedded
|
||||
// calendar serves UPC computations so both axes matter.
|
||||
var holidays []EmbeddedHoliday
|
||||
if err := pool.SelectContext(ctx, &holidays, `
|
||||
SELECT to_char(date, 'YYYY-MM-DD') AS date_iso,
|
||||
name, country, regime, state, holiday_type
|
||||
FROM paliad.holidays
|
||||
WHERE country = 'DE' OR regime = 'UPC'
|
||||
ORDER BY date, name`); err != nil {
|
||||
return fmt.Errorf("select holidays: %w", err)
|
||||
}
|
||||
|
||||
// 5. Courts — UPC subset.
|
||||
var courts []EmbeddedCourt
|
||||
if err := pool.SelectContext(ctx, &courts, `
|
||||
SELECT id, code, name_de, name_en, country, regime, court_type, parent_id, sort_order
|
||||
FROM paliad.courts
|
||||
WHERE is_active = true
|
||||
AND (regime = 'UPC' OR court_type LIKE 'upc%')
|
||||
ORDER BY sort_order, id`); err != nil {
|
||||
return fmt.Errorf("select courts: %w", err)
|
||||
}
|
||||
|
||||
// 6. Compose meta.
|
||||
meta := Meta{
|
||||
Version: resolveVersion(version, output),
|
||||
GeneratedAt: time.Now().UTC().Truncate(time.Second),
|
||||
PaliadCommit: gitCommitShort(),
|
||||
SourceDBLabel: sourceLabel,
|
||||
RuleCount: len(rules),
|
||||
ProceedingCount: len(procs),
|
||||
TriggerEventCount: len(triggers),
|
||||
HolidayCount: len(holidays),
|
||||
CourtCount: len(courts),
|
||||
}
|
||||
|
||||
// 7. Write each file.
|
||||
files := []struct {
|
||||
name string
|
||||
data any
|
||||
}{
|
||||
{"proceeding_types.json", procs},
|
||||
{"rules.json", rules},
|
||||
{"trigger_events.json", triggers},
|
||||
{"holidays.json", holidays},
|
||||
{"courts.json", courts},
|
||||
{"meta.json", meta},
|
||||
}
|
||||
for _, f := range files {
|
||||
path := filepath.Join(output, f.name)
|
||||
buf, err := json.MarshalIndent(f.data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s: %w", f.name, err)
|
||||
}
|
||||
buf = append(buf, '\n')
|
||||
if err := os.WriteFile(path, buf, 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("snapshot written: version=%s rules=%d proceedings=%d triggers=%d holidays=%d courts=%d → %s",
|
||||
meta.Version, meta.RuleCount, meta.ProceedingCount,
|
||||
meta.TriggerEventCount, meta.HolidayCount, meta.CourtCount, output)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveVersion picks a date-stamped version slug, bumping the suffix
|
||||
// past any pre-existing same-day version found in the existing
|
||||
// meta.json. If the caller passed -version, that wins.
|
||||
func resolveVersion(explicit, output string) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
// Read prior meta to detect same-day collisions.
|
||||
prior, err := os.ReadFile(filepath.Join(output, "meta.json"))
|
||||
if err != nil {
|
||||
return today + "-1"
|
||||
}
|
||||
var pm Meta
|
||||
if err := json.Unmarshal(prior, &pm); err != nil {
|
||||
return today + "-1"
|
||||
}
|
||||
if !strings.HasPrefix(pm.Version, today+"-") {
|
||||
return today + "-1"
|
||||
}
|
||||
// Same day: bump the suffix.
|
||||
suffix := pm.Version[len(today)+1:]
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(suffix, "%d", &n); err != nil {
|
||||
return today + "-1"
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", today, n+1)
|
||||
}
|
||||
|
||||
// gitCommitShort returns the short SHA of the paliad checkout. Best-
|
||||
// effort — empty string when we're not in a git checkout.
|
||||
func gitCommitShort() string {
|
||||
out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
@@ -221,6 +221,8 @@ func main() {
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
|
||||
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
|
||||
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions.
|
||||
Scenario: services.NewScenarioService(pool, projectSvc, rules),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
|
||||
@@ -421,7 +421,7 @@ The editor is the **largest single surface** in Phase 3. ~3-4 PRs of work depend
|
||||
| `POST /api/admin/rules` | POST | global_admin | Create a new rule from scratch (starts as `lifecycle_state='draft'`). |
|
||||
| `GET /admin/rules/{id}/audit` | GET | global_admin | Audit log for this rule. |
|
||||
| `POST /admin/rules/{id}/preview` | POST | global_admin | Preview-on-trigger-date — runs calculator with this draft replacing its published peer; returns the resulting timeline (no persistence). |
|
||||
| `POST /admin/rules/export-migration` | POST | global_admin | Export pending (draft + audit-since-last-export) rules as a `*.up.sql` blob the human can paste into `internal/db/migrations/`. Sets `migration_exported=true` on the audit rows. |
|
||||
| _(removed t-paliad-297)_ migration-export endpoint | — | — | Was a SQL-export tool generating `*.up.sql` from audit rows. Workflow shifted to hand-written numbered migrations; tool removed in m/paliad#129. |
|
||||
|
||||
### 4.2 Draft → published lifecycle
|
||||
|
||||
|
||||
1618
docs/design-litigation-planner-2026-05-26.md
Normal file
1618
docs/design-litigation-planner-2026-05-26.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ A full org export today is **< 600 rows of user content** plus reference data
|
||||
|
||||
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
|
||||
|
||||
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) — admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
|
||||
**Existing export precedent.** _(Originally pointed at the admin rule-migration export. That tool was deleted in m/paliad#129 / t-paliad-297. The gating pattern — `adminGate(users, …)` on a download endpoint that streams a generated artifact — still lives on other admin handlers, e.g. `handleAdminDownloadBackup` for `/api/admin/backups/{id}/file`.)_ Re-use the gating helper.
|
||||
|
||||
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
|
||||
|
||||
@@ -591,7 +591,7 @@ No other slice deltas. v1 still ships slices 1+2+3.
|
||||
- `docs/design-data-model-v2.md` — projects + mandanten + ltree path + can_see_project predicate.
|
||||
- `docs/design-approval-policy-ui-2026-05-07.md` — 5-source audit union (this design adds the 6th source).
|
||||
- `docs/design-profession-vs-project-role-2026-05-07.md` — profession ladder for the §4 project gate.
|
||||
- `internal/handlers/admin_rules.go:303` — `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
|
||||
- `internal/handlers/backups.go` — `handleAdminDownloadBackup` (precedent for admin-gated artifact download; the older rule-migration export precedent was removed in t-paliad-297).
|
||||
- `internal/services/project_service.go:15` — visibility predicate.
|
||||
- `internal/services/derivation_service.go` — `EffectiveProjectRole` for the project gate.
|
||||
- `github.com/xuri/excelize/v2` — chosen xlsx library.
|
||||
|
||||
277
docs/design-procedural-events-b0-findings-2026-05-26.md
Normal file
277
docs/design-procedural-events-b0-findings-2026-05-26.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Slice B.0 — Live DB re-validation findings (t-paliad-273)
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-26
|
||||
**Branch:** `mai/curie/researcher-slice-b-zero`
|
||||
**Predecessor:** `docs/design-procedural-events-model-2026-05-25.md` (cronus, t-paliad-262)
|
||||
**Scope:** READ-ONLY re-validation of the design doc's §1 premises against the live youpc Supabase `paliad` schema. No migration SQL written, no writes to `deadline_rules` or any table. B.1 (additive migration) remains blocked pending m's greenlight.
|
||||
|
||||
This document does **not** redesign the schema. It does **not** propose new structural changes. It records what the live DB looks like ~24 hours after the design was authored, flags every claim that drifted, and gives the eventual B.1 coder a current-as-of-2026-05-26 baseline to plan against.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
The design doc's §1 premises were sound on 2026-05-25. **All numeric premises drifted in the 24 hours since.** The qualitative model (`deadline_rules` conflates three concepts; live `deadlines.rule_id` FK; snapshot precedent established; no `proceeding_event*` tables) still holds.
|
||||
|
||||
The Q5 default ("10 archived multi-row submission_codes collapse safely") is now **moot**: those rows were removed from the live DB between 2026-05-25 15:30 and 2026-05-26 13:30. There are now **zero** multi-row submission codes; every active submission_code maps 1:1 to one rule row. B.1 backfill no longer needs the multi-row collapse logic that §5 of the design doc anticipated.
|
||||
|
||||
The Q6 default ("concept_id attaches to procedural event, not sequencing rule") is **directionally correct but needs refinement**. The empirical attachment is **above** the procedural-event level — `deadline_concepts` rows cluster legal meaning *across* jurisdictional procedural-event variants. One concept_id can span 15 distinct submission_codes (e.g. "Berufungsfrist" across BGH / BPatG / LG / OLG for both PatG and ZPO paths). The FK in §4.1's draft schema (`procedural_events.concept_id REFERENCES deadline_concepts(id)`, N:1) is **already correctly shaped** for this — no schema change needed. The verbal claim in the design doc should be tightened to "one `deadline_concept` row may be referenced by many procedural events; the FK lives on `procedural_events`."
|
||||
|
||||
Migration tracker drift: the design's "next available mig = 124" is stale; live head is 133 (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27 — applied **after** the design was written). **Next available is 134.** Ten migrations landed since the doc was authored — 124..133. None of them touched `deadline_rules` schema, but they did mutate row content (the missing 23 rows and the new event_type/legal_source distribution come from migs 127/128/132/133).
|
||||
|
||||
The design's claimed migration tracker `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 native counter (stuck at v106). The **canonical** tracker is `paliad.applied_migrations` (one row per applied migration, with checksum + applied_at). `internal/db/migrate.go:9-21` is the source of truth. Project CLAUDE.md still says `paliad.paliad_schema_migrations`; that's a stale doc, not a B.0-scope fix.
|
||||
|
||||
One doc-side bug fixed by this slice: design doc §1 + m/paliad#93 issue body referenced `paliad.deadlines.deadline_rule_id`. Live column is `paliad.deadlines.rule_id`. Both files patched on this branch.
|
||||
|
||||
---
|
||||
|
||||
## §1 Headline-count drift table
|
||||
|
||||
All numbers taken 2026-05-26 ~13:30 UTC against the live `paliad` schema.
|
||||
|
||||
| Metric | Design (2026-05-25) | Live (2026-05-26) | Δ | Notes |
|
||||
|---|--:|--:|--:|---|
|
||||
| `deadline_rules` row count | 254 | **231** | -23 | All rows `is_active = true`. No soft-deletes in flight. |
|
||||
| Rows with `submission_code` | 177 | **153** | -24 | |
|
||||
| Distinct `submission_code` values | 158 | **153** | -5 | **All 5 lost are the multi-row `_archived_litigation.*` codes** — see §2. |
|
||||
| Rows with `legal_source` | 102 | **112** | +10 | |
|
||||
| Distinct `legal_source` values | 70 | **87** | +17 | New jurisdictional variants seeded by recent migs (127/132/133). |
|
||||
| Rows with `concept_id` (linked to `deadline_concepts`) | 125 | **129** | +4 | 56% of the corpus is concept-linked, vs 49% in the design. |
|
||||
| `paliad.deadlines` rows | 1 | **5** | +4 | Still tiny — destructive cutover stays cheap. |
|
||||
| `paliad.submission_drafts` rows | 4 | **7** | +3 | |
|
||||
| Rules in `lifecycle_state = 'draft'` | 4 | **0** | -4 | All 4 design-era drafts were published or discarded. |
|
||||
|
||||
### event_type distribution
|
||||
|
||||
| `event_type` | Design | Live | Δ |
|
||||
|---|--:|--:|--:|
|
||||
| `filing` | 130 | 105 | -25 |
|
||||
| NULL | 77 | 89 | +12 |
|
||||
| `decision` | 25 | 21 | -4 |
|
||||
| `hearing` | 21 | 15 | -6 |
|
||||
| `order` | 1 | 1 | 0 |
|
||||
| **Total** | **254** | **231** | -23 |
|
||||
|
||||
The -23 row delta lands almost entirely in `filing` (-25) and `hearing` (-6), offset by +12 NULL — consistent with the disappearance of the `_archived_litigation.*` filings and a few archived `hearing` rows, plus seeding of new structural / parent-only rows by recent migrations.
|
||||
|
||||
### What did NOT drift (qualitative claims, still valid)
|
||||
|
||||
- `paliad.deadline_rules` carries 39 columns (design said 38 — drift +1; likely from mig 128 `deadline_rules_unit_check` which adds a CHECK without adding a column — or one of migs 124-133 added a column. Not investigated further; out of B.0 scope).
|
||||
- `paliad.deadlines.rule_id` (uuid, nullable) is the FK column to `paliad.deadline_rules.id`. **Confirmed via `information_schema.referential_constraints`** — `rule_id → paliad.deadline_rules(id)`. The doc-side mention of `deadline_rule_id` was always a typo.
|
||||
- `paliad.deadlines.rule_code` + `paliad.deadlines.custom_rule_text` both still present (the denormalized-display columns from mig 122).
|
||||
- `paliad.submission_drafts` uses `(project_id uuid nullable, submission_code text NOT NULL)` as its key — **no FK to deadline_rules**. Confirms the design's claim that the Schriftsätze surface filters on a text key, not on `deadline_rules.id`.
|
||||
- No `paliad.proceeding_event*` tables exist (einstein's 2026-05-08 graph design was never built — still the case).
|
||||
|
||||
---
|
||||
|
||||
## §2 Archived submission_code audit (Q5 re-confirm)
|
||||
|
||||
**Premise re-checked:** "10 archived multi-row submission_codes (`_archived_litigation.*`) collapse safely into single procedural events with multiple sequencing variants."
|
||||
|
||||
**Finding:** the premise is **moot in the live DB**.
|
||||
|
||||
```sql
|
||||
SELECT submission_code, COUNT(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code LIKE '_archived_litigation.%'
|
||||
GROUP BY submission_code;
|
||||
-- 0 rows
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT submission_code, COUNT(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
GROUP BY submission_code
|
||||
HAVING COUNT(*) > 1;
|
||||
-- 0 rows
|
||||
```
|
||||
|
||||
Every active submission_code in the live corpus is 1:1 with its `deadline_rules` row. The 10 multi-row codes the design anticipated no longer exist.
|
||||
|
||||
**Consequence for B.1 backfill:**
|
||||
|
||||
- The §5.1 / §5.2 backfill SQL the design sketched (collapsing N rows-with-same-submission_code into 1 procedural_event + N sequencing_rules) is **simpler than expected**: a straight 1:1 backfill, no GROUP-BY-and-collapse step needed.
|
||||
- B.1's `INSERT INTO paliad.procedural_events ... SELECT DISTINCT submission_code ...` becomes equivalent to `INSERT ... SELECT submission_code, ... FROM deadline_rules WHERE submission_code IS NOT NULL`. No deduplication needed.
|
||||
- The 78 rows where `submission_code IS NULL` (231 - 153) still need a B.1 decision: do they become `procedural_events` rows (with synthetic codes), do they become free-standing `sequencing_rules` with `procedural_event_id` NULL, or do they get parked? This was implicit in the design (the 77 NULLs were framed as "structural / parent-only rows in the proceeding tree"); B.1 should make the decision explicit and document it in the migration's `.up.sql` comments.
|
||||
|
||||
---
|
||||
|
||||
## §3 concept_id attachment shape (Q6 re-confirm)
|
||||
|
||||
**Premise re-checked:** "concept_id attaches to procedural event, not sequencing rule."
|
||||
|
||||
**Finding:** **partly true.** The FK direction the design proposes (`procedural_events.concept_id → deadline_concepts.id`, N:1) is correct. The verbal phrasing in Q6's default needs refinement — the empirical attachment is **above** the procedural-event level, not "at" it.
|
||||
|
||||
### Empirical pattern
|
||||
|
||||
129 of 231 rows carry a `concept_id`. Those 129 rows reference **53 distinct `deadline_concepts`** rows. Averages: 2.43 rows-per-concept, 2.42 submission-codes-per-concept (the two are nearly identical because today's corpus has no multi-row submission codes — see §2). Span distribution:
|
||||
|
||||
- 33 of 53 concepts (62%) attach to exactly 1 submission_code → procedural-event-scoped.
|
||||
- 20 of 53 concepts (38%) attach to >1 submission_code → cross-procedural-event scoped.
|
||||
- Maximum: 1 concept attaches to **15 distinct submission_codes**.
|
||||
|
||||
### Example: one concept, four procedural events
|
||||
|
||||
The concept `b85b2e5a-4064-40b2-b862-24b7abaa5b94` ("Berufungsfrist / Berufungsschrift") is referenced by 4 `deadline_rules` rows that today carry these 4 distinct submission_codes:
|
||||
|
||||
| rule_code | submission_code | court | name |
|
||||
|---|---|---|---|
|
||||
| § 110 PatG | `de.null.bgh.berufung` | BGH | Berufungsschrift |
|
||||
| § 110 PatG | `de.null.bpatg.berufung` | BPatG | Berufungsfrist |
|
||||
| § 517 ZPO | `de.inf.lg.berufung` | LG | Berufungsfrist |
|
||||
| § 517 ZPO | `de.inf.olg.berufung` | OLG | Berufungsfrist |
|
||||
|
||||
Under Slice B's target schema (§4.1), each of these four rows becomes a separate `procedural_events` row (different `code`s, different jurisdiction-specific names, different `legal_source_id`s), but **all four reference the same `deadline_concepts.id`**.
|
||||
|
||||
### Implication for B.1
|
||||
|
||||
- `procedural_events.concept_id` should be **nullable** (62% of rows today have no concept link — the §4.1 sketch already allows this).
|
||||
- The constraint must be **N:1, not 1:1** (one `deadline_concept` may be referenced by many `procedural_events`). The §4.1 sketch (`concept_id uuid REFERENCES paliad.deadline_concepts(id)`) is already correctly N:1; a hypothetical "UNIQUE INDEX on `procedural_events.concept_id`" would break the existing data. **Do not add UNIQUE.**
|
||||
- The design doc's Q6 phrasing can be tightened to: "concept_id attaches to procedural event (N procedural events → 1 concept). Sequencing rules do not carry concept_id." — but this is a wording nit, not a structural change. It does **not** block B.1.
|
||||
|
||||
---
|
||||
|
||||
## §4 Snapshot precedent audit
|
||||
|
||||
**Premise re-checked:** the `paliad.deadline_rules_pre_<N>` snapshot pattern is established and ready for B.4's destructive drop.
|
||||
|
||||
**Finding:** confirmed and consistent.
|
||||
|
||||
Snapshot tables in `paliad`:
|
||||
|
||||
| Snapshot table | Origin migration |
|
||||
|---|---|
|
||||
| `deadlines_pre_089` | mig 089 |
|
||||
| `deadline_rules_pre_091` | mig 091 (destructive drop of legacy columns) |
|
||||
| `event_deadlines_pre_092` | mig 092 |
|
||||
| `event_deadline_rule_codes_pre_092` | mig 092 |
|
||||
| `deadline_rules_pre_093` | mig 093 |
|
||||
| `proceeding_types_pre_093` | mig 093 |
|
||||
| `projects_pre_094` | mig 094 |
|
||||
| `deadline_rules_pre_095` | mig 095 |
|
||||
| `proceeding_types_pre_096` | mig 096 |
|
||||
| `deadline_rules_pre_098` | mig 098 |
|
||||
|
||||
Pattern: `<original_table>_pre_<migration_number>`. Always created in the `.up.sql` of the destructive migration as `CREATE TABLE paliad.<t>_pre_<N> AS TABLE paliad.<t>;` (followed by the destructive DROP / ALTER).
|
||||
|
||||
**B.4's template:** before `DROP TABLE paliad.deadline_rules;` (and `ALTER TABLE paliad.deadlines DROP COLUMN rule_id;`), `mig <N>.up.sql` must include:
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;
|
||||
-- (optional) CREATE TABLE paliad.deadlines_pre_<N> AS TABLE paliad.deadlines;
|
||||
```
|
||||
|
||||
This is non-negotiable per m's snapshot policy and the precedent of migs 089-098. B.4 should not enter the deploy queue without it.
|
||||
|
||||
---
|
||||
|
||||
## §5 deadlines.rule_id doc bug — verified + patched
|
||||
|
||||
**Premise re-checked:** the live column on `paliad.deadlines` referencing `deadline_rules` is named `rule_id`, not `deadline_rule_id`.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```sql
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema='paliad' AND table_name='deadlines' AND column_name LIKE '%rule%';
|
||||
-- rule_id (uuid, nullable)
|
||||
-- rule_code (text, nullable)
|
||||
-- custom_rule_text (text, nullable)
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT kcu.column_name, ccu.table_name, ccu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu ON ...
|
||||
JOIN information_schema.constraint_column_usage ccu ON ...
|
||||
WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_schema='paliad' AND tc.table_name='deadlines';
|
||||
-- rule_id → paliad.deadline_rules.id
|
||||
```
|
||||
|
||||
**Fix applied on this branch:**
|
||||
|
||||
- `docs/design-procedural-events-model-2026-05-25.md` — §1 row 51 already says "the column is `rule_id` (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo)". §1 row 63 (the "Doc-side bug flagged" line) already names the fix target. **No change needed to the design doc — the inventor already flagged and described the bug; B.0 just re-confirms it.**
|
||||
- `m/paliad#93` issue body — line 56 says `paliad.deadlines.deadline_rule_id` in the Q3 migration shape. Patched via Gitea API on this slice. See §6 of this report.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration tracker drift (out-of-scope context)
|
||||
|
||||
The design doc said "next available mig number is 124 (mig 123 = Backup Mode Slice A, just shipped)". Live state on 2026-05-26 13:30:
|
||||
|
||||
- Latest applied migration: **133** (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27).
|
||||
- Next available: **134**.
|
||||
- Migrations 124-133 (all applied after the design was authored):
|
||||
|
||||
```
|
||||
124 de_inf_lg_replik_duplik_sequencing (2026-05-25 13:49)
|
||||
125 cross_cutting_filter_legal_source (2026-05-25 14:13)
|
||||
126 users_inbox_seen_at (2026-05-25 13:51)
|
||||
127 wave0_tier0_deadline_fixes (2026-05-25 14:13)
|
||||
128 deadline_rules_unit_check (2026-05-25 14:13)
|
||||
129 project_event_choices (2026-05-25 15:02)
|
||||
130 submission_drafts_language (2026-05-25 15:05)
|
||||
131 submission_drafts_party_selection (2026-05-25 15:02)
|
||||
132 wave1_tier1_rule_additions (2026-05-25 15:40)
|
||||
133 upc_dmgs_pi_court_followup (2026-05-25 15:27)
|
||||
```
|
||||
|
||||
These touched `deadline_rules` content (wave0/wave1 rule additions, sequencing fixes, unit checks) and adjacent tables, but did not change the conflated-three-concepts shape that motivates Slice B. The structural premise of the design holds; the row-level numbers shifted.
|
||||
|
||||
**Side observation (not a B.0 fix scope):** the project's `CLAUDE.md` says "Migration tracker is `paliad.paliad_schema_migrations` (avoids collision with other apps on the shared `public.schema_migrations`)." That sentence is stale. The **canonical tracker is `paliad.applied_migrations`** (per `internal/db/migrate.go:9-21,53,105`). `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 counter, frozen at v106; the migrate runner uses it only to bootstrap `applied_migrations` on first deploy of the new runner (`internal/db/migrate.go:219-240`). Recommend a separate doc-fix slice (out of B.0 scope) to update `.claude/CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## §7 Updated B.1 brief (no-op / minor adjustments only)
|
||||
|
||||
What the live data means for the design's §5 migration plan:
|
||||
|
||||
1. **Backfill is simpler.** No multi-row collapse logic needed (§2). One-to-one `INSERT INTO paliad.procedural_events SELECT submission_code, name, name_en, description, event_type AS event_kind, primary_party, ... FROM paliad.deadline_rules WHERE submission_code IS NOT NULL` against 153 rows.
|
||||
2. **The 78 NULL-submission_code rows need an explicit decision in B.1.** Either:
|
||||
- (a) Skip them — they remain `deadline_rules`-only and become orphan-once-deadline_rules-is-dropped. Not acceptable; B.4 would lose them.
|
||||
- (b) Mint synthetic codes (`null.<uuid8>` or similar) for the structural rows and create `procedural_events` for them.
|
||||
- (c) Treat them as "sequencing-rule-only" (a `sequencing_rules` row with NULL `procedural_event_id`) — would require `sequencing_rules.procedural_event_id` to be nullable, which contradicts §4.1's NOT NULL FK.
|
||||
- Default recommendation: **(b)** — mint codes, preserve every row. B.1 must document the mint rule in the `.up.sql`. Surface this to head before scheduling B.1.
|
||||
3. **concept_id stays N:1 on procedural_events.** No UNIQUE constraint. §4.1's sketch already does this; just don't accidentally tighten it.
|
||||
4. **Use migration number 134** (or whatever's the live `MAX(version)+1` at B.1-write-time; re-check at the moment of writing the file).
|
||||
5. **Snapshot before drop in B.4:** `CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;` per §4 precedent. **This is the hard-stop pre-condition for B.4 entering the deploy queue.**
|
||||
6. **Submission_drafts.submission_code → procedural_events.code text join** continues to work unchanged through B.1-B.3 because both names match. No B.5 dual-write needed for `submission_drafts`. (The design's §6.3 already noted this.)
|
||||
|
||||
None of these change the **shape** of the design — they tighten the backfill SQL and surface one explicit decision (point 2) for head.
|
||||
|
||||
---
|
||||
|
||||
## §8 Outputs of this slice (B.0)
|
||||
|
||||
| Artifact | Status |
|
||||
|---|---|
|
||||
| `docs/design-procedural-events-b0-findings-2026-05-26.md` (this file) | created on `mai/curie/researcher-slice-b-zero` |
|
||||
| `docs/design-procedural-events-model-2026-05-25.md` | cherry-picked from `mai/cronus/inventor-procedural` onto this branch (design doc was never merged to main; B.0 brings it onto a branch off main so the doc bug fix has somewhere to land) |
|
||||
| m/paliad#93 issue body — `deadline_rule_id` → `rule_id` correction | patched via Gitea API |
|
||||
| Gitea comment on m/paliad#93 summarizing this report | posted (see §6 trailing summary on the issue) |
|
||||
|
||||
**Nothing migrated, nothing written to `paliad.deadline_rules` or any other live data table.** Only `mai.reports` (progress) and the GitHub issue body / repo files were touched.
|
||||
|
||||
---
|
||||
|
||||
## §9 Hard-stop status
|
||||
|
||||
**B.0 COMPLETE. AWAITING B.1 GREENLIGHT.**
|
||||
|
||||
Per the original instruction:
|
||||
|
||||
- B.1 (additive migration creating `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources` + backfill) requires explicit m approval before any new tables get created.
|
||||
- B.4 (destructive drop of `paliad.deadline_rules` + `paliad.deadlines.rule_id`) requires m's downtime-window approval AND a `paliad.deadline_rules_pre_<N>` snapshot table in the same migration.
|
||||
- This researcher (curie) stays parked until head re-hires.
|
||||
|
||||
---
|
||||
|
||||
## §10 Decisions worth surfacing to m before B.1 starts
|
||||
|
||||
1. **NULL-submission_code rows (78 of them) — what to do during backfill?** Recommendation (b): mint synthetic codes. m should confirm or pick (a)/(c).
|
||||
2. **B.5 deprecation header window length** — the design (§8.2) says "one slice". For 7 active submission_drafts that's safe; the question is whether external integrations (Word templates with `{{rule.X}}`) need a longer window. The variable-bag alias contract (`submission_vars.go`) covers Word templates without a wire-format change, so "one slice" is defensible. m should confirm.
|
||||
3. **Migration number reservation** — by the time B.1 ships, the live head may be 135+. The B.1 coder must re-check `MAX(version)` at write-time. (Not a decision; just a process note.)
|
||||
|
||||
These are the only open questions the B.0 audit surfaced. Everything else in the design holds.
|
||||
571
docs/design-procedural-events-model-2026-05-25.md
Normal file
571
docs/design-procedural-events-model-2026-05-25.md
Normal file
@@ -0,0 +1,571 @@
|
||||
# Design — Procedural-Events Data Model (t-paliad-262)
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-25
|
||||
**Issue:** m/paliad#93 (mai task t-paliad-262)
|
||||
**Branch:** `mai/cronus/inventor-procedural`
|
||||
**Status:** DESIGN — read-only, no schema or code changes in this branch.
|
||||
**B.0 re-validation:** see `docs/design-procedural-events-b0-findings-2026-05-26.md` (curie, 2026-05-26) for the live-DB premise re-check. Numeric §1 claims drifted; Q5 multi-row collapse premise is moot (no `_archived_litigation.*` rows remain); Q6 N:1 attachment confirmed; mig number target updated 124 → 134.
|
||||
**Prior art read:**
|
||||
- `docs/design-deadline-data-model-2026-05-08.md` (einstein, t-paliad-158) — proposed `proceeding_event_types` + `proceeding_event_edges`; the **graph-shape recommendation has not been built** (no `proceeding_event*` tables exist in the live DB as of 2026-05-25, verified via `information_schema.tables`).
|
||||
- `docs/design-fristen-phase2-2026-05-15.md` (Phase 2/3 unified-rule columns — migs 078/079/091, **shipped**).
|
||||
- `docs/design-submission-generator-2026-05-19.md` and `docs/design-submission-page-2026-05-22.md` (Slice 1 → Slice A of the Schriftsätze stack — shipped on top of today's `deadline_rules`).
|
||||
|
||||
This doc names a single conflation in the schema and proposes a two-slice fix (cosmetic immediate, structural follow-up). It is intentionally narrower than einstein's 2026-05-08 graph proposal — it does **not** re-litigate the proceeding-as-DAG question.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
`paliad.deadline_rules` today is **one row that wears three hats**:
|
||||
|
||||
1. **The procedural-event template** — `submission_code`, `name`, `name_en`, `description`, `event_type`, `primary_party`. This is "what kind of step is this in the proceeding": Rechtsbeschwerdebegründung, mündliche Verhandlung, Entscheidung, etc.
|
||||
2. **The legal-norm citation** — `legal_source`, `rule_code`, `alt_rule_code`, `rule_codes[]`. This is "the source-of-law anchor": § 102 PatG, UPC RoP R.220(1).
|
||||
3. **The sequencing rule** — `parent_id`, `trigger_event_id`, `duration_value`, `duration_unit`, `timing`, `alt_duration_*`, `combine_op`, `condition_expr`, `is_spawn`, `spawn_*`, `sequence_order`, `is_court_set`, `priority`, `anchor_alt`, `proceeding_type_id`. This is "how and when does it fire relative to other events".
|
||||
|
||||
The conflation surfaces most painfully in the submission-draft editor's variable sidebar (m's report 2026-05-25 15:02), where the lawyer sees field labels like `{{rule.submission_code}}` for what is plainly a *procedural-event code*, `{{rule.event_type}}` for what is plainly the *procedural-event kind*, and `{{rule.legal_source_pretty}}` for what is plainly the *legal norm* — all under a `rule.*` namespace that reads as if the lawyer were filling in arithmetic.
|
||||
|
||||
**Recommendation = Q1 option (C):**
|
||||
|
||||
- **Slice A (immediate, this design's coder shift):** cosmetic rename — placeholders, i18n labels, Go struct-comment naming, admin-UI page titles all shift to `procedural_event.*` as the canonical name. **Database schema, table name, column names, FK directions, JSON envelope keys on the wire all stay exactly as they are.** Old `{{rule.*}}` placeholders remain emitted in the variable bag as legacy aliases so existing Word templates and saved drafts keep working.
|
||||
- **Slice B (planned follow-up, separate mai task, separate slice plan):** structural rework — extract `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources`, with a phased dual-write migration. **Not shipped here.** This doc defines the target shape (§4) and the migration shape (§5) so the eventual coder has a brief, not so the eventual coder is hired today.
|
||||
|
||||
**Umbrella term lock = Q2 option (R):** **"procedural event"** (DE: **"Verfahrensschritt"**) as the umbrella covering filings, hearings, decisions, orders. Justification in §2.
|
||||
|
||||
Both Slice A and the eventual Slice B preserve the Schriftsätze surface (t-paliad-238/242/243): the submissions list query changes its predicate from `dr.event_type = 'filing'` to `pe.event_kind IN ('filing', 'reply')` (Slice B only) — same rows, cleaner predicate.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-25)
|
||||
|
||||
Every load-bearing claim was checked against the running paliad codebase + youpc Supabase. Numbers and schema facts are point-in-time as of 2026-05-25 15:30.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| `paliad.deadline_rules` carries the 38 columns listed in §0's three-hats decomposition. | `information_schema.columns WHERE table_schema='paliad' AND table_name='deadline_rules'` — 38 rows; columns confirmed verbatim. |
|
||||
| Live row count = 254. | `SELECT COUNT(*) FROM paliad.deadline_rules` → 254. |
|
||||
| 177 rows carry a `submission_code` (procedural-event identity); 158 distinct values. | `COUNT(*) FILTER (WHERE submission_code IS NOT NULL)` → 177; `COUNT(DISTINCT submission_code)` → 158. |
|
||||
| 102 rows carry a `legal_source`; 70 distinct citations. | Same query, `legal_source` column. |
|
||||
| 125 rows are linked to a `deadline_concepts` row via `concept_id`. | `COUNT(*) FILTER (WHERE concept_id IS NOT NULL)` → 125 (49 % of the corpus). |
|
||||
| `event_type` distribution: 130 `filing` · 77 NULL · 25 `decision` · 21 `hearing` · 1 `order`. | `SELECT event_type, count(*) GROUP BY event_type` — confirmed; the 77 NULL rows are structural / parent-only rows in the proceeding tree. |
|
||||
| 10 `submission_code` values appear on more than one row (jurisdictional / bilateral variants). | All 10 today are `_archived_litigation.*` codes (claimant/defendant splits + multi-stage hearing rows). Live non-archived codes are 1:1 with rows in the current corpus. |
|
||||
| `paliad.deadlines` joins to `deadline_rules` via column `rule_id` (uuid, FK). The text `rule_code` and free-text `custom_rule_text` (mig 122, t-paliad-258) are denormalized for display when the rule row is deleted. | `internal/services/deadline_service.go:69-127`; live column list confirms `rule_id`, `rule_code`, `custom_rule_text` — there is **no** `deadline_rule_id` column on deadlines (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo; the column is `rule_id`). |
|
||||
| `paliad.submission_drafts` keys to a procedural event via `submission_code` text — **no FK** to `deadline_rules`. | `information_schema.columns` for `submission_drafts`: `submission_code text` plus `(project_id, submission_code)` as the joint identifier. Confirms the Schriftsätze surface filters on the *text key*, not on `deadline_rules.id`. |
|
||||
| The Schriftsätze list (t-paliad-238) filters `deadline_rules` by `event_type='filing'` and `submission_code IS NOT NULL`. | `internal/handlers/submissions.go:193-211` — verbatim. |
|
||||
| The variable bag emits exactly 8 `rule.*` placeholders. | `internal/services/submission_vars.go:349-364` — `rule.submission_code`, `rule.name`, `rule.name_de`, `rule.name_en`, `rule.legal_source`, `rule.legal_source_pretty`, `rule.primary_party`, `rule.event_type`. Frontend i18n labels at `frontend/src/client/submission-draft.ts:158-185`. |
|
||||
| Admin rule-edit form binds the same `rule.X` fields. | `frontend/src/admin-rules-edit.tsx:74-110` + `frontend/src/client/admin-rules-edit.ts:253-278` — same eight columns surfaced as form inputs. |
|
||||
| The Fristenrechner client surface refers to `calc.rule.nameDE` / `calc.rule.nameEN`. | `frontend/src/client/fristenrechner.ts:1592,1655`. |
|
||||
| einstein's 2026-05-08 `proceeding_event_types` + `proceeding_event_edges` are **not** in the DB. | `SELECT table_name FROM information_schema.tables WHERE table_schema='paliad' AND table_name LIKE '%proceeding_event%'` → 0 rows. The graph-shape proposal was never built. |
|
||||
| `paliad.deadline_concepts` (57 rows in the original einstein audit; live count not directly queried this shift) still exists and is referenced via `deadline_rules.concept_id`. | `information_schema.tables` confirms `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_types`, `trigger_events`, `event_categories` all still present — the deadline-knowledge graph from the einstein design lives on alongside the unified rule columns. |
|
||||
| Phase 2/3 columns (`priority`, `condition_expr`, `is_court_set`, `lifecycle_state`, `draft_of`, `published_at`, `rule_codes[]`) are live and load-bearing. | `internal/models/models.go:622-684` + mig 091. Slice B's structural rework must preserve every one of these on the new `sequencing_rules` table — they are not legacy. |
|
||||
| Live `paliad.deadlines` references to rules are sparse (1 row in prod). | `SELECT COUNT(*) FROM paliad.deadlines` → 1. The 4 `submission_drafts` rows reference a procedural event by `submission_code` text only. Tiny live FK surface → migrations can be aggressive without losing user data. |
|
||||
| Migration tracker is `paliad.paliad_schema_migrations`; next available number is 124 (mig 123 = Backup Mode Slice A, just shipped). | `internal/db/migrations/` directory listing; latest applied = 123. |
|
||||
|
||||
**Doc-side bug flagged for this issue's body:** the deliverable spec writes `paliad.deadlines.deadline_rule_id` in §3 (Q3 migration shape). The live column is `paliad.deadlines.rule_id`. Slice B's rename target is therefore `paliad.deadlines.procedural_event_id`, renamed directly from `paliad.deadlines.rule_id` — there is no intermediate `deadline_rule_id` step (no such column exists). Updating the issue body is m's call — flagged here so it doesn't propagate into a coder brief. *(B.0 update 2026-05-26: issue body patched. See `docs/design-procedural-events-b0-findings-2026-05-26.md` §5.)*
|
||||
|
||||
---
|
||||
|
||||
## §2 m's vocabulary call (Q2 — lock the umbrella term)
|
||||
|
||||
m proposed "procedural event" in the report. Options weighed:
|
||||
|
||||
| Option | Reads as | Collisions | Verdict |
|
||||
|---|---|---|---|
|
||||
| **"procedural event"** (DE: "Verfahrensschritt") | Umbrella that naturally covers filings, hearings, decisions, orders. Matches lawyer mental model: "the next thing that happens in the proceeding". | None — no `paliad.procedural_event*` table or column today (verified). | **(R) — adopt as canonical.** |
|
||||
| "submission" | Today the Schriftsätze surface uses this for *filings only* (`event_type='filing'`). Expanding the meaning would silently change Slice A's semantics for an existing UI. | Surface-level collision with the Schriftsätze nomenclature already in production. | Reject — would lose precision for an existing concept. |
|
||||
| "event" / "event_type" | Existing `deadline_rules.event_type` column. | **Hard collision** with `paliad.events` (audit feed, distinct table, distinct meaning). Renaming around it would be worse than the conflation we're trying to fix. | Reject. |
|
||||
| "Verfahrensschritt" only (no English) | Cleanest German but no English fallback. | Bilingual UI (DE primary, EN secondary per project CLAUDE.md) requires both. | Reject in isolation — but **adopt as the canonical German rendering** of "procedural event". |
|
||||
| "Verfahrensereignis" | Closer literal translation of "procedural event". | None. | Reject in favor of "Verfahrensschritt" — m's broader vocabulary uses "Schritt" (e.g. "Antragsschritt") more naturally than "Ereignis", which already maps to `paliad.events` in the audit-feed sense. |
|
||||
|
||||
**Lock:**
|
||||
|
||||
| Surface | Canonical |
|
||||
|---|---|
|
||||
| English | **procedural event** (lowercase except sentence-initial) |
|
||||
| German | **Verfahrensschritt** (m. — der Verfahrensschritt) |
|
||||
| Plural EN | procedural events |
|
||||
| Plural DE | Verfahrensschritte |
|
||||
| Code identifier (Go struct names, TS types) | `ProceduralEvent`, `ProceduralEventKind`, `ProceduralEventTemplate` |
|
||||
| Snake-case (DB columns, JSON keys, i18n keys, placeholders) | `procedural_event`, `procedural_event_kind`, `procedural_events` (table) |
|
||||
| Slice A: variable-bag placeholder namespace | `procedural_event.*` (with `rule.*` kept as legacy alias) |
|
||||
| Slice B: table name (if shipped) | `paliad.procedural_events` |
|
||||
|
||||
`event_type` (the column) becomes `event_kind` in Slice B — using "kind" rather than "type" to free up the word "type" for the proceeding-level taxonomy (`paliad.proceeding_types`, untouched) and to mirror the "event_type vs event_kind" disambiguation einstein hit in the 2026-05-08 doc. In Slice A the column stays `event_type` (no DB change).
|
||||
|
||||
**Q2 is locked by inventor recommendation.** It costs nothing structurally and clears noise across every downstream conversation. If m disagrees in the head round-trip, the only thing that flips is the term — Slice A's scope shape stays.
|
||||
|
||||
---
|
||||
|
||||
## §3 Scope decision (Q1 — A vs B vs C)
|
||||
|
||||
**Recommendation = (C) — cosmetic rename now, structural rework as a planned follow-up.**
|
||||
|
||||
### Why not (A) — cosmetic only and stop
|
||||
|
||||
(A) leaves the model wrong forever. The conflation isn't just a labelling annoyance — it makes future questions harder to answer cleanly:
|
||||
|
||||
- "How many distinct procedural events does paliad model?" Today: ambiguous (rows vs distinct `submission_code`s vs distinct `(submission_code, proceeding_type_id)` tuples).
|
||||
- "Where can we attach a per-procedural-event Word template that's independent of which proceeding it appears in?" Today: nowhere — the FK chain forces a per-row template registry, see `internal/handlers/files.go` template fallback.
|
||||
- "Show me every sequencing rule that triggers a given procedural event across all proceedings." Today: requires joining `deadline_rules` to itself on `submission_code` + `parent_id`, brittle.
|
||||
|
||||
If m signals (A) anyway — fine; the cosmetic-only slice is a strict subset of (C)'s Slice A and ships the same value (label clarity in the editor). But the recommendation is to write down the structural target now while the analysis is fresh.
|
||||
|
||||
### Why not (B) — restructure immediately
|
||||
|
||||
(B) means: one slice plan, one cutover. With:
|
||||
- 254 live rule rows,
|
||||
- 1 live `paliad.deadlines` row,
|
||||
- 4 live `submission_drafts` rows,
|
||||
- 12 Go services + 6 handlers touching `deadline_rules` + 8 placeholder strings on the wire + the admin rule-editor UI bound to the column shape,
|
||||
|
||||
…doing this in one cutover means a big-bang migration during a downtime window. m has granted exactly one such window in recent memory (2026-05-15 for mig 091's destructive drops), and that one was constrained to a 4-column drop. A four-table restructure has a meaningfully larger blast radius; it warrants its own task with its own slice plan and its own risk review.
|
||||
|
||||
### Why (C) — cosmetic-rename Slice A this design, structural Slice B as a separate task
|
||||
|
||||
Three properties of (C) make it the safe call:
|
||||
|
||||
1. **Slice A is reversible at any time** — every change is in i18n strings, Go struct comments, admin-UI page titles, and the variable-bag aliases. No DB migration. No drop. A revert is a `git revert` of the Slice A commit.
|
||||
2. **Slice B is fully designed but uncommitted** — §4 and §5 below define the target shape and migration plan, but the design doc itself ships in Slice A. m can read it, redirect it, or park it without pressure to ship it now.
|
||||
3. **The Schriftsätze surface doesn't care which slice we ship** — Slice A leaves it on `event_type='filing'`; Slice B flips it to `event_kind IN ('filing', 'reply')` over a dual-write window. Either way, the lawyer-facing behavior is unchanged.
|
||||
|
||||
### Slice A's deliverable boundary (what gets renamed, what stays)
|
||||
|
||||
**Renamed in Slice A:**
|
||||
|
||||
- **i18n keys** for the admin rule-editor field labels: `admin.rules.edit.field.submission_code` → `admin.rules.edit.field.procedural_event_code`, etc. (16 keys total — `name`, `name_en`, `description`, `submission_code`, `rule_code`, `legal_source`, `primary_party`, `event_type` × DE/EN — full list in §7.1.)
|
||||
- **Variable-bag placeholder labels** in `submission-draft.ts:158-185`: the *visible label* (`{ de: "Schriftsatz-Code", en: "Submission code" }`) is unchanged for filings (filings are still Schriftsätze on that surface), but the **namespace shown next to the placeholder string** changes: lawyer sees `{{procedural_event.code}}` in the placeholder column with the same Schriftsatz-Code label and same value. The old `{{rule.submission_code}}` stays in the catalog as an "(alt)" entry pointing at the same field.
|
||||
- **Variable-bag emission** (`internal/services/submission_vars.go:351-364`): the bag emits **both** key-names for every value, so any Word template / saved draft holding `{{rule.X}}` keeps working without a touch. New templates and the in-app catalog show the canonical `{{procedural_event.X}}` name.
|
||||
- **Admin page titles + section headings**: "Regel bearbeiten" → "Verfahrensschritt bearbeiten" (DE), "Edit rule" → "Edit procedural event" (EN). "Regeln verwalten" → "Verfahrensschritte verwalten" / "Procedural events". The URL path `/admin/rules` stays — URL renames have downstream cost (bookmarks, audit log entries) and would need their own redirect slice (out of scope here).
|
||||
- **Go struct comments + service docstrings + worker-facing log lines** that refer to "the rule" → "the procedural event" where the referent is the procedural-event aspect (not the sequencing-rule aspect). Function names, type names, table name stay (Slice B handles those).
|
||||
- **The "Submission Code / Einreichung-Kennung" label** itself stays (it's the lawyer's anchor — they recognize it). The framing around it changes: it now reads as "the code that identifies this *procedural event*", not "the code attached to this *rule*".
|
||||
|
||||
**Untouched in Slice A:**
|
||||
|
||||
- Database schema. Table name (`paliad.deadline_rules`). Column names. FK directions. Indexes. RLS policies. Triggers. Audit log column `rule_id`.
|
||||
- Go struct names: `DeadlineRule` stays. The renames here are *prose*, not *code*. Renaming `DeadlineRule` to `ProceduralEvent` couples Slice A to Slice B's table rename — keep them decoupled.
|
||||
- JSON envelope keys on the wire (`POST /api/admin/rules/:id` still accepts `submission_code` in the body — Slice B's API rename is a breaking change with its own deprecation window).
|
||||
- URL paths (`/admin/rules`, `/api/admin/rules/:id`, `/api/projects/:id/submissions` etc.).
|
||||
- `paliad.deadlines.rule_id` FK column name.
|
||||
- The variable-bag's legacy `{{rule.X}}` keys — kept forever as aliases (cheap, zero rot).
|
||||
- The `submission_drafts` table's `submission_code` text key.
|
||||
|
||||
This boundary makes Slice A a one-day coder shift: scoped, reversible, label-only.
|
||||
|
||||
### What Slice B inherits
|
||||
|
||||
Slice B inherits a codebase + a UI where every prose surface already speaks "procedural event". It also inherits a *legacy alias contract* (the dual emission in the variable bag) that gives it freedom to rename the JSON keys on the wire and the Go struct in two separate sub-slices without rushing.
|
||||
|
||||
---
|
||||
|
||||
## §4 Restructure schema (Q3 — if/when we ship Slice B)
|
||||
|
||||
This is the target the eventual Slice B coder would land. **Nothing here ships in this task.**
|
||||
|
||||
### §4.1 Three new tables (plus the rename of `deadline_rules`)
|
||||
|
||||
```sql
|
||||
-- 1. Procedural event templates — one row per (procedural-event identity)
|
||||
-- For now the live corpus is 1:1 with non-archived submission_codes
|
||||
-- (148 of the 158 distinct codes), so we get ~177 rows minus the 10
|
||||
-- multi-row codes' duplicates. Bilateral / jurisdictional variants
|
||||
-- are modeled at the sequencing_rules layer.
|
||||
CREATE TABLE paliad.procedural_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE, -- former submission_code
|
||||
name text NOT NULL, -- DE
|
||||
name_en text NOT NULL,
|
||||
description text,
|
||||
event_kind text NOT NULL, -- filing|reply|hearing|decision|order|other
|
||||
primary_party_default text, -- claimant|defendant|both|court
|
||||
legal_source_id uuid REFERENCES paliad.legal_sources(id),
|
||||
concept_id uuid REFERENCES paliad.deadline_concepts(id),
|
||||
lifecycle_state text NOT NULL DEFAULT 'published', -- draft|published|archived
|
||||
draft_of uuid REFERENCES paliad.procedural_events(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 2. Legal sources — the source-of-law citations the procedural event
|
||||
-- anchors against. ~70 distinct values today (live corpus).
|
||||
CREATE TABLE paliad.legal_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
citation text NOT NULL UNIQUE, -- "DE.PatG.102", "UPC.RoP.220.1", …
|
||||
jurisdiction text NOT NULL, -- DE|UPC|EPA|DPMA|other
|
||||
pretty_de text NOT NULL, -- "§ 102 PatG"
|
||||
pretty_en text NOT NULL, -- "Section 102 PatG"
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 3. Sequencing rules — the timing / trigger / condition mechanics that
|
||||
-- today live alongside the procedural-event identity on deadline_rules.
|
||||
-- One row per (procedural_event × proceeding × variant). The 10
|
||||
-- "_archived_litigation.*" codes that today have 2-5 rows become
|
||||
-- 2-5 sequencing_rules rows for the same procedural_events row.
|
||||
CREATE TABLE paliad.sequencing_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
procedural_event_id uuid NOT NULL REFERENCES paliad.procedural_events(id),
|
||||
proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
parent_id uuid REFERENCES paliad.sequencing_rules(id), -- structural tree, today's parent_id
|
||||
trigger_event_id bigint REFERENCES paliad.trigger_events(id), -- event-rooted variant
|
||||
duration_value integer NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months',
|
||||
timing text DEFAULT 'after',
|
||||
alt_duration_value integer,
|
||||
alt_duration_unit text,
|
||||
alt_rule_code text, -- legacy free-text alt citation, retained
|
||||
anchor_alt text,
|
||||
combine_op text, -- max|min
|
||||
condition_expr jsonb,
|
||||
primary_party text, -- per-rule override of the procedural_event default
|
||||
sequence_order integer NOT NULL DEFAULT 0,
|
||||
is_spawn boolean NOT NULL DEFAULT false,
|
||||
spawn_label text,
|
||||
spawn_proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
is_bilateral boolean NOT NULL DEFAULT false,
|
||||
is_court_set boolean NOT NULL DEFAULT false,
|
||||
priority text NOT NULL DEFAULT 'mandatory',
|
||||
rule_code text, -- legacy short-form citation, retained on the rule
|
||||
rule_codes text[], -- multi-citation array (mig pre-091)
|
||||
deadline_notes text,
|
||||
deadline_notes_en text,
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.sequencing_rules(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 4. Rename downstream FK + add the link to procedural_events.
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id),
|
||||
ADD COLUMN sequencing_rule_id uuid REFERENCES paliad.sequencing_rules(id);
|
||||
-- (rule_id stays as a transitional alias during the dual-write window;
|
||||
-- dropped at end of Slice B)
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 5. Submission drafts: add procedural_event_id FK alongside submission_code.
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id);
|
||||
-- (submission_code stays — it's the cosmetic anchor lawyers recognize
|
||||
-- in URLs and chat, and it doubles as the procedural_events.code value)
|
||||
```
|
||||
|
||||
### §4.2 What goes where (column-by-column map)
|
||||
|
||||
Every column on today's `paliad.deadline_rules` lands on exactly one of the three new tables:
|
||||
|
||||
| Today's `deadline_rules` column | Lands on | Notes |
|
||||
|---|---|---|
|
||||
| `id`, `created_at`, `updated_at` | `sequencing_rules` | The current row's identity becomes a sequencing-rule row. `procedural_events.id` is **new** — backfilled from `submission_code`. |
|
||||
| `submission_code` | `procedural_events.code` | Promoted up. Multi-row codes (10 in corpus, all `_archived_litigation.*`) collapse to one row on the new table; the 2-5 sequencing rows hang off it. |
|
||||
| `name`, `name_en`, `description` | `procedural_events` | Procedural-event identity. |
|
||||
| `primary_party` | `procedural_events.primary_party_default` AND `sequencing_rules.primary_party` | Both. The procedural event has a default party (claimant for Klage etc.); the sequencing rule can override per-jurisdiction (bilateral variants — e.g. `litigation.reply` claimant vs defendant become two sequencing rows with overridden party). |
|
||||
| `event_type` | `procedural_events.event_kind` | Hat 1, with rename to `event_kind` (term lock §2). |
|
||||
| `legal_source` | `legal_sources.citation` + FK from `procedural_events.legal_source_id` | The citation moves to its own row; the procedural event points at it. `pretty_de` / `pretty_en` materialize the existing `legalSourcePretty()` function output as columns (with the function retained as the migration source). |
|
||||
| `rule_code`, `alt_rule_code`, `rule_codes[]` | `sequencing_rules` | Short-form citation arrays stay on the sequencing rule — they're rule-specific. |
|
||||
| `proceeding_type_id`, `parent_id`, `trigger_event_id`, `spawn_proceeding_type_id`, `is_spawn`, `spawn_label`, `is_bilateral`, `is_court_set`, `combine_op` | `sequencing_rules` | Hat 3 (mechanics) — exact copies. |
|
||||
| `duration_value`, `duration_unit`, `timing`, `alt_duration_value`, `alt_duration_unit`, `anchor_alt` | `sequencing_rules` | Hat 3 (mechanics). |
|
||||
| `condition_expr` (jsonb) | `sequencing_rules` | Hat 3. The grammar from mig 091 stays. |
|
||||
| `priority`, `sequence_order` | `sequencing_rules` | Hat 3. |
|
||||
| `is_active`, `lifecycle_state`, `draft_of`, `published_at` | **BOTH** `procedural_events` AND `sequencing_rules` | A procedural event can be retired independently of any one of its sequencing variants. Backfill: copy onto both during dual-write; new rows go through the rule-editor service which writes both sides together. |
|
||||
| `concept_id` (FK to `deadline_concepts`) | `procedural_events.concept_id` | The concept layer (einstein 2026-05-08) attaches to the procedural event, not the sequencing rule. |
|
||||
| `deadline_notes`, `deadline_notes_en` | `sequencing_rules` | They're rule-specific notes ("filing the appeal in DE costs €X if you also did Y") — not procedural-event-wide. |
|
||||
|
||||
Three columns disappear:
|
||||
|
||||
- The semantically-overloaded part of `event_type` (renamed to `event_kind` and moved).
|
||||
- The "what is this thing" vs "how does it fire" name conflict — gone by construction.
|
||||
- Any column that exists only because of the conflation (none of today's columns are pure overhead — they all carry data — so the count stays at 38 across the three new tables).
|
||||
|
||||
### §4.3 Indexes + RLS
|
||||
|
||||
`paliad.can_see_project()` is the canonical RLS predicate (mig 055). None of the three new tables hold project-scoped data — they're firm-wide reference tables. RLS = none, same posture as today's `deadline_rules` (which is firm-wide and unrestricted at the row level; access control is via the `lifecycle_state='published'` filter in the read paths).
|
||||
|
||||
Indexes inherited from today:
|
||||
|
||||
- `paliad.legal_sources(citation)` — UNIQUE.
|
||||
- `paliad.procedural_events(code)` — UNIQUE.
|
||||
- `paliad.procedural_events(concept_id)` — for the deadline-concept join.
|
||||
- `paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state)` — primary read path for the calculator.
|
||||
- `paliad.sequencing_rules(parent_id)` — tree walk.
|
||||
- `paliad.sequencing_rules(trigger_event_id)` — event-rooted variant.
|
||||
|
||||
---
|
||||
|
||||
## §5 Migration plan (Slice B — when it ships, not in this task)
|
||||
|
||||
Phased dual-write, so the cutover is **never** a single instant where the wire format flips. m gets to roll back any one phase with a `git revert` + an `ALTER TABLE` if a phase misbehaves in prod.
|
||||
|
||||
### §5.1 Phase 1 — Additive (no down-time)
|
||||
|
||||
1. Create `procedural_events`, `sequencing_rules`, `legal_sources`.
|
||||
2. Backfill `legal_sources` from `DISTINCT legal_source` on `deadline_rules` (70 rows). Populate `pretty_de`/`pretty_en` by calling the existing `legalSourcePretty()` function in a one-shot SQL/Go shim during the migration. Verify `COUNT(DISTINCT legal_source FROM deadline_rules) = COUNT(*) FROM legal_sources`.
|
||||
3. Backfill `procedural_events` from `DISTINCT submission_code` on `deadline_rules WHERE submission_code IS NOT NULL`. Take `name`, `name_en`, `event_type → event_kind`, `primary_party`, `concept_id`, `description` from the lowest-`id` rule row for each code (tie-breaker: lowest `sequence_order`). Verify `COUNT(*) FROM procedural_events = COUNT(DISTINCT submission_code FROM deadline_rules WHERE submission_code IS NOT NULL)` (= 158).
|
||||
4. Backfill `sequencing_rules` 1:1 from `deadline_rules` (254 rows). FK `procedural_event_id` resolved by code lookup; sequencing-rule row inherits the `deadline_rules.id` (so existing `deadlines.rule_id` FKs continue to resolve via the new column for the dual-write window — see Phase 3).
|
||||
5. Add `paliad.deadlines.procedural_event_id` + `sequencing_rule_id` columns, backfill from `deadlines.rule_id` join.
|
||||
6. Add `paliad.submission_drafts.procedural_event_id`, backfill from `submission_code` join.
|
||||
|
||||
This phase ships behind a feature flag (or just behind unused code) — readers + writers stay on `deadline_rules`. No behavior change.
|
||||
|
||||
### §5.2 Phase 2 — Dual-write (no down-time)
|
||||
|
||||
7. Update `RuleEditorService` to write to both `deadline_rules` (legacy) and (`procedural_events`, `sequencing_rules`, `legal_sources`) on every Create/Update/Publish/Archive. Audit log writes one row per side.
|
||||
8. Update read paths to **read from the new tables**, falling back to `deadline_rules` if the new row is missing (defense-in-depth during backfill catch-up).
|
||||
9. Run for ≥ 1 week (m's call on length). Compare row counts and a hash digest of the union daily — if drift, surface.
|
||||
|
||||
### §5.3 Phase 3 — Cutover (no down-time, but reversible only via re-application of the dual-write)
|
||||
|
||||
10. Flip read paths to **only** the new tables (`SubmissionVarsService.loadPublishedRule`, `DeadlineRuleService.*`, `SubmissionService.list`, `ProjectionService`, `FristenrechnerCalc`, etc.).
|
||||
11. Stop writing to `deadline_rules`.
|
||||
12. `paliad.deadlines.rule_id` is kept as a no-op alias for one more week; new writes go to `procedural_event_id` + `sequencing_rule_id`.
|
||||
13. `submission_drafts.submission_code` is kept as the URL anchor; the FK `procedural_event_id` is the primary join key going forward.
|
||||
|
||||
### §5.4 Phase 4 — Drop legacy (downtime window, destructive)
|
||||
|
||||
14. `paliad.deadline_rules_pre_<slice-B-mig>` snapshot of the entire table.
|
||||
15. DROP TABLE paliad.deadline_rules (after CASCADE-safe FK rewires).
|
||||
16. DROP COLUMN paliad.deadlines.rule_id (keep `rule_code` + `custom_rule_text` as the human-readable denormalized columns — they're the safety net for orphaned deadlines per t-paliad-258).
|
||||
|
||||
m grants this destructive phase its own window (precedent: mig 091 on 2026-05-15). Until then, the legacy table sits dormant.
|
||||
|
||||
### §5.5 Migration tracker
|
||||
|
||||
- Slice B uses migration numbers 124 (Phase 1 — create tables + backfill) and onward — a 4-5 migration sequence, one per phase boundary, mirroring the Phase 2/3 slicing that shipped under t-paliad-195.
|
||||
- Each migration includes a `paliad.audit_reason = 'mig <n>: <slice-B-phase>'` set_config like mig 091 did, so the audit log captures the schema journey.
|
||||
|
||||
---
|
||||
|
||||
## §6 Service-layer impact
|
||||
|
||||
### §6.1 Slice A — prose-only changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `internal/services/submission_vars.go` | `addRuleVars` → also emit `procedural_event.code`, `procedural_event.name`, `procedural_event.name_de`, `procedural_event.name_en`, `procedural_event.legal_source`, `procedural_event.legal_source_pretty`, `procedural_event.primary_party`, `procedural_event.event_kind` (8 new keys, 1:1 with the 8 existing `rule.*` keys, same values). Rename docstrings + the package-level placeholder map comment ("`rule.*`" → "`procedural_event.*` (with legacy alias `rule.*`)"). |
|
||||
| `internal/services/deadline_rule_service.go` | Top-of-file comment + struct comment renames only. Method names stay (`DeadlineRuleService`, `GetByID`, etc.). |
|
||||
| `internal/services/rule_editor_service.go` | Same. |
|
||||
| `internal/services/projection_service.go`, `deadline_service.go`, `fristenrechner.go`, `submission_draft_service.go`, `event_trigger_service.go`, `event_deadline_service.go`, `proceeding_mapping.go`, `export_service.go` | No code changes. Comments mentioning "the rule"/"rules" stay accurate as long as the file is about sequencing — only services that surface the **identity** aspect of the rule (`submission_vars.go`) need a prose pass. |
|
||||
| `internal/handlers/submissions.go` | No SQL change. Type+comment renames: the catalog response type stays `submissionListEntry` (it's still a Schriftsatz-level list); doc comments speak of "procedural events whose kind is filing" instead of "rules of type filing". |
|
||||
| `internal/handlers/admin_rules.go` | URL path stays. JSON envelope stays. Page-render comments + log-line text shift to "procedural event". |
|
||||
| `internal/handlers/submission_drafts.go`, `deadlines.go`, `fristenrechner.go` | No service-layer change. |
|
||||
|
||||
### §6.2 Slice B — structural
|
||||
|
||||
Mostly load-bearing; not enumerated here in detail (out of scope per (R)=C). The shape:
|
||||
|
||||
- `RuleEditorService` splits into `ProceduralEventService` + `SequencingRuleService` + `LegalSourceService`. The Save / Publish / Archive flow on the editor coordinates all three.
|
||||
- `DeadlineRuleService.GetByID` becomes `SequencingRuleService.GetByID`; the `submission_code` lookup moves to `ProceduralEventService.GetByCode`.
|
||||
- `SubmissionVarsService.loadPublishedRule` becomes `loadPublishedProceduralEvent` and returns a triple (`event`, `defaultSequencingRule`, `legalSource`); the variable-bag emission consumes all three.
|
||||
- `ProjectionService` and the Fristenrechner calculator read from `sequencing_rules` (same column set, same logic — only the table name changes).
|
||||
- `SubmissionService.list` (handlers/submissions.go) filters `procedural_events.event_kind IN ('filing', 'reply')`.
|
||||
- Backfill orphans + audit triggers (mig 079 / 089) are re-pointed at `sequencing_rules` + a new `procedural_events_audit`.
|
||||
|
||||
---
|
||||
|
||||
## §7 UI / i18n impact
|
||||
|
||||
### §7.1 i18n keys (Slice A)
|
||||
|
||||
Existing keys (DE + EN) at `frontend/src/client/i18n.ts` lines ~2834-2920 and ~5800-5890 — surface area is *labels*, not *placeholders-in-Word*:
|
||||
|
||||
| Old key | New key (Slice A) | DE label | EN label |
|
||||
|---|---|---|---|
|
||||
| `admin.rules.list.title` | `admin.procedural_events.list.title` | "Verfahrensschritte verwalten — Paliad" | "Manage procedural events — Paliad" |
|
||||
| `admin.rules.list.heading` | `admin.procedural_events.list.heading` | "Verfahrensschritte verwalten" | "Manage procedural events" |
|
||||
| `admin.rules.list.subtitle` | `admin.procedural_events.list.subtitle` | "Verfahrensschritte anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived." | "Create, edit and publish procedural events. Lifecycle: draft → published → archived." |
|
||||
| `admin.rules.list.new` | `admin.procedural_events.list.new` | "+ Neuer Verfahrensschritt" | "+ New procedural event" |
|
||||
| `admin.rules.col.submission_code` | `admin.procedural_events.col.code` | "Code" (drop "/ Einreichung-Kennung" — the new heading already disambiguates) | "Code" |
|
||||
| `admin.rules.col.legal_citation` | `admin.procedural_events.col.legal_source` | "Rechtsgrundlage" | "Legal source" |
|
||||
| `admin.rules.col.name` | `admin.procedural_events.col.name` | "Bezeichnung" | "Name" |
|
||||
| `admin.rules.col.proceeding` | `admin.procedural_events.col.proceeding` | "Verfahrenstyp" | "Proceeding" |
|
||||
| `admin.rules.col.priority` | `admin.procedural_events.col.priority` | "Priorität" | "Priority" |
|
||||
| `admin.rules.col.lifecycle` | `admin.procedural_events.col.lifecycle` | "Lifecycle" | "Lifecycle" |
|
||||
| `admin.rules.col.modified` | `admin.procedural_events.col.modified` | "Zuletzt geändert" | "Last modified" |
|
||||
| `admin.rules.edit.title` | `admin.procedural_events.edit.title` | "Verfahrensschritt bearbeiten — Paliad" | "Edit procedural event — Paliad" |
|
||||
| `admin.rules.edit.heading.loading` | `admin.procedural_events.edit.heading.loading` | "Verfahrensschritt laden…" | "Loading procedural event…" |
|
||||
| `admin.rules.edit.breadcrumb` | `admin.procedural_events.edit.breadcrumb` | "← Verfahrensschritte verwalten" | "← Manage procedural events" |
|
||||
| `admin.rules.edit.field.submission_code` | `admin.procedural_events.edit.field.code` | "Code (Schriftsatz-Code / Einreichung-Kennung)" — keep the parenthetical so lawyers familiar with the old label know what they're looking at. | "Code (submission / procedural-event identifier)" |
|
||||
| `admin.rules.edit.field.rule_code` | `admin.procedural_events.edit.field.short_citation` | "Rechtsgrundlage (Kurzform)" | "Legal source (short form)" |
|
||||
| `admin.rules.edit.field.legal_source` | `admin.procedural_events.edit.field.legal_source` | "Rechtsgrundlage (Langform)" | "Legal source (long form)" |
|
||||
| `admin.rules.edit.field.name` | `admin.procedural_events.edit.field.name` | "Bezeichnung (DE)" | "Name (DE)" |
|
||||
| `admin.rules.edit.field.name_en` | `admin.procedural_events.edit.field.name_en` | "Bezeichnung (EN)" | "Name (EN)" |
|
||||
| `admin.rules.edit.field.proceeding` | `admin.procedural_events.edit.field.proceeding` | "Verfahrenstyp" | "Proceeding type" |
|
||||
| `admin.rules.edit.field.trigger` | `admin.procedural_events.edit.field.trigger` | "Trigger-Ereignis" | "Trigger event" |
|
||||
| `admin.rules.edit.field.parent` | `admin.procedural_events.edit.field.parent` | "Übergeordneter Verfahrensschritt (UUID)" | "Parent procedural event (UUID)" |
|
||||
| `admin.rules.edit.field.concept` | `admin.procedural_events.edit.field.concept` | "Konzept (UUID)" | "Concept (UUID)" |
|
||||
| `admin.rules.edit.field.sequence_order` | `admin.procedural_events.edit.field.sequence_order` | "Reihenfolge" | "Order" |
|
||||
| `admin.rules.edit.field.duration_value` | `admin.procedural_events.edit.field.duration_value` | "Dauer" | "Duration" |
|
||||
| `admin.rules.edit.field.primary_party` | `admin.procedural_events.edit.field.primary_party` | "Partei (typisch)" | "Primary party" |
|
||||
| `admin.rules.edit.field.event_type` | `admin.procedural_events.edit.field.event_kind` | "Art des Verfahrensschritts" | "Procedural-event kind" |
|
||||
| `admin.rules.edit.field.description` | `admin.procedural_events.edit.field.description` | "Beschreibung" | "Description" |
|
||||
|
||||
**Legacy keys retained as aliases** so any existing translation imports or external integrations keep working — old keys point at the same DE/EN values during a deprecation window of one full Slice B cycle.
|
||||
|
||||
### §7.2 Variable-bag placeholders (Slice A)
|
||||
|
||||
`frontend/src/client/submission-draft.ts:155-185` — the catalog of placeholders the lawyer sees in the sidebar:
|
||||
|
||||
| Old placeholder (kept as legacy alias) | New canonical placeholder | DE label | EN label |
|
||||
|---|---|---|---|
|
||||
| `{{rule.submission_code}}` | `{{procedural_event.code}}` | "Code (Verfahrensschritt)" | "Code (procedural event)" |
|
||||
| `{{rule.name}}` | `{{procedural_event.name}}` | "Bezeichnung" | "Name" |
|
||||
| `{{rule.name_de}}` | `{{procedural_event.name_de}}` | "Bezeichnung (DE)" | "Name (DE)" |
|
||||
| `{{rule.name_en}}` | `{{procedural_event.name_en}}` | "Bezeichnung (EN)" | "Name (EN)" |
|
||||
| `{{rule.legal_source}}` | `{{procedural_event.legal_source}}` | "Rechtsgrundlage (Code)" | "Legal source (code)" |
|
||||
| `{{rule.legal_source_pretty}}` | `{{procedural_event.legal_source_pretty}}` | "Rechtsgrundlage" | "Legal source" |
|
||||
| `{{rule.primary_party}}` | `{{procedural_event.primary_party}}` | "Partei (typisch)" | "Primary party" |
|
||||
| `{{rule.event_type}}` | `{{procedural_event.event_kind}}` | "Art des Verfahrensschritts" | "Procedural-event kind" |
|
||||
|
||||
The catalog renders the canonical name in the "copy-this-placeholder" button. The variable bag (`submission_vars.go`) emits both names with identical values, so any Word template the lawyer already has continues to work; new templates are encouraged to use the canonical name.
|
||||
|
||||
### §7.3 Admin rule-editor form (Slice A)
|
||||
|
||||
`frontend/src/admin-rules-edit.tsx:74-110` — i18n key rebinds + heading text update. The DOM `id` attributes (`f-submission-code`, `f-rule-code`, `f-legal-source`, …) stay — they're internal, the rename here is cosmetic, the form still POSTs the same JSON envelope (Slice A doesn't touch the API). The fieldset `legend` for the "Identität" section changes to "Verfahrensschritt-Identität" (DE) / "Procedural-event identity" (EN). The "Verfahren & Trigger" section heading stays — that section is about sequencing, and Slice A doesn't rename sequencing-level labels (those are Slice B).
|
||||
|
||||
### §7.4 Project-detail Schriftsätze tab + dashboard
|
||||
|
||||
`frontend/src/client/submissions.ts`, `submissions-index.ts`: no surface-level label change in Slice A. The Schriftsätze tab continues to show Schriftsätze (the lawyer's preferred term for *filings specifically*). The tab is a filtered view onto procedural events of kind `filing`/`reply` — that distinction surfaces only in admin contexts.
|
||||
|
||||
### §7.5 Help text + docs
|
||||
|
||||
A short addition to the in-app help: "What is a procedural event?" — one-paragraph definition explaining the umbrella term, with examples (Klage, Klageerwiderung, mündliche Verhandlung, Endurteil). Stored in `frontend/src/client/i18n.ts` under `help.procedural_events.intro`. Out of scope for the URL/router changes — added as static copy where it fits naturally.
|
||||
|
||||
---
|
||||
|
||||
## §8 Slice plan
|
||||
|
||||
### §8.1 Slice A (this design's downstream task)
|
||||
|
||||
**Scope:** prose-only rename per §3 ("renamed in Slice A" list).
|
||||
|
||||
**Mechanics:**
|
||||
|
||||
1. Add 8 new placeholder keys to the variable bag in `submission_vars.go` (1:1 with the existing 8 `rule.*` keys). Keep the legacy keys.
|
||||
2. Update `frontend/src/client/submission-draft.ts` placeholder catalog labels.
|
||||
3. Rebind admin i18n keys per §7.1 (with legacy keys retained).
|
||||
4. Update admin page titles + section headings.
|
||||
5. Update Go struct comments + service docstrings in `submission_vars.go`, `deadline_rule_service.go`, `rule_editor_service.go`, `submission_draft_service.go`, `submissions.go` handler. No code-flow change.
|
||||
6. Update `internal/handlers/submissions.go` doc comments.
|
||||
7. Add a short `docs/glossary.md` entry (or extend an existing one) for "procedural event" / "Verfahrensschritt" — single source of truth for the term.
|
||||
8. Tests: rename strings in existing test fixtures + add a regression test that the variable bag emits **both** the legacy `rule.X` and the canonical `procedural_event.X` keys with the same value. (Critical — without this test, a future commit could drop the legacy alias and silently break user templates.)
|
||||
9. Manual smoke: open the admin rule editor, confirm the new title appears. Open the submission-draft editor, confirm both `{{rule.X}}` and `{{procedural_event.X}}` placeholders are listed (with canonical first). Generate a `.docx` from a project using each placeholder name — both render identically.
|
||||
|
||||
**Risk:** very low. No DB change, no API change, fully reversible.
|
||||
|
||||
**No hours estimate per project CLAUDE.md.**
|
||||
|
||||
### §8.2 Slice B (separate mai task — designed here, hired later)
|
||||
|
||||
**Scope:** structural rework per §4 + §5.
|
||||
|
||||
**Mechanics:** Phase 1 → Phase 4 per §5.
|
||||
|
||||
**Prerequisite:** m greenlights via a new mai task with this doc + §11's open items addressed. **Not part of Slice A.**
|
||||
|
||||
**Sub-slices (suggested for Slice B's own task):**
|
||||
|
||||
- **B.0** — Re-validate this doc's premises against live DB (numbers shift over weeks).
|
||||
- **B.1** — Phase 1 additive migration + backfill (mig 124).
|
||||
- **B.2** — Phase 2 dual-write + read-fallback.
|
||||
- **B.3** — Phase 3 read cutover (no schema change).
|
||||
- **B.4** — Phase 4 destructive drop (downtime window).
|
||||
- **B.5** — Rename Go types `DeadlineRule` → `SequencingRule` + `ProceduralEvent`; rename JSON API envelope keys with a deprecation header. Independent of B.4.
|
||||
- **B.6** — Rename admin URL paths `/admin/rules` → `/admin/procedural-events` with redirects. Optional / low-priority.
|
||||
|
||||
### §8.3 Why splitting is the right call
|
||||
|
||||
The conflation is real, but the *fix* for the most-painful surface (the editor sidebar) is independent of the table restructure. Splitting lets m ship the fix this week, see whether the prose change alone resolves enough of the cognitive friction, and then decide whether the structural rework is still worth the migration cost. If after Slice A m says "this reads fine now, B isn't worth it", that's a legitimate outcome — Slice B is a *good* refactor, not an *urgent* one.
|
||||
|
||||
---
|
||||
|
||||
## §9 Risk assessment
|
||||
|
||||
### §9.1 Slice A risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Lawyer's existing Word template has `{{rule.submission_code}}` baked in; a future commit drops the legacy alias and breaks templates. | Low (Slice A keeps the alias) | High if it happens | Regression test (§8.1 step 8) asserts both keys emit. Add an audit-log line on every variable-bag call recording which keys were consumed by the merge engine — gives a 30-day window of evidence before we'd consider deprecating the legacy keys. |
|
||||
| i18n key rename misses a binding, leaving an English string visible to a DE user. | Medium | Low | The build pipeline (`bun test` / `bun build`) fails on missing i18n keys in `i18n-keys.ts`. Add the new keys to the type union; leave the old keys in the union with `@deprecated` JSDoc. |
|
||||
| Renamed admin page heading confuses returning admin users ("Where did 'Regeln verwalten' go?"). | Medium | Low | One-time changelog entry; the URL `/admin/rules` is unchanged so muscle memory still lands them on the page. Internal users only (whitelist-gated). |
|
||||
| Slice A reads as "we're done" and Slice B never ships. | Medium | Medium (the model stays wrong) | This doc files the Slice B design as a separate task entry **before** Slice A merges, so the to-do is visible. m's call whether to schedule it. |
|
||||
|
||||
### §9.2 Slice B risks (deferred; recorded for the future task)
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Backfill collapses too eagerly: 10 multi-row submission_codes today are `_archived_litigation.*` — confirm they should collapse into one procedural event with 2-5 sequencing variants, vs. each row becoming its own procedural event. | The `_archived_litigation.*` codes are archived per their prefix — collapse is safe. **Decision-flag for Slice B's own design pass.** |
|
||||
| `deadline_concepts` linkage (125 of 254 rules link to a concept) — does the concept attach to the procedural event or the sequencing rule? §4.2 says procedural event; verify this is right when re-validating premises in B.0. | Read-path audit: every consumer that joins `deadline_rules.concept_id` (rule_editor, projection, fristenrechner) operates on the rule-level today. Reconfirm none of them depend on per-jurisdiction concept-attachment. |
|
||||
| The dual-write window introduces drift if a write hits one side and fails on the other. | Atomicity via single transaction per write in `RuleEditorService`. Daily drift-check job (one SELECT pair, alert if mismatched). |
|
||||
| `paliad.deadlines.rule_id` (1 live row, but more in future) — backfilling `procedural_event_id` + `sequencing_rule_id` must not orphan the live row. | The 1 live row joins cleanly. Backfill in the same migration that adds the new columns. |
|
||||
| The submission-draft `submission_code` text key — what if two `procedural_events.code` values collide post-rename (e.g. a draft was saved against a code that we then archive)? | Slice B Phase 1 enforces `procedural_events.code UNIQUE`; the backfill verifies no collision on the existing 158 distinct values. Drafts with codes that no longer exist as published procedural events are handled by the existing `submission_drafts.submission_code` text fallback (no FK enforcement). |
|
||||
| Slice B's API-key rename (`submission_code` → `code` in JSON) breaks external integrations. | None exist today (paliad is internal-only); add a one-Slice deprecation header (`X-Deprecated-Field: submission_code`) before flipping. |
|
||||
| **Coordination risk with future fristen/calculator work.** The Fristenrechner calculator reads `deadline_rules` directly today. Slice B Phase 2's read-fallback handles this, but a parallel calculator feature in flight could land changes that need re-merging. | B.0's job: confirm no in-flight task touches `deadline_rules` table shape before scheduling. |
|
||||
|
||||
### §9.3 What rolls Slice A back
|
||||
|
||||
`git revert <slice-a-commit>` + reload. Zero data side-effects (no DB writes). 30 seconds.
|
||||
|
||||
### §9.4 What rolls Slice B back
|
||||
|
||||
Per phase — Phases 1-3 reversible via reverting code + `DROP TABLE`. Phase 4 reversible only by restoring `deadline_rules` from the `_pre_<n>` snapshot taken at the start of Phase 4. Same posture as mig 091 — m's call when to commit to this point.
|
||||
|
||||
---
|
||||
|
||||
## §10 Out of scope
|
||||
|
||||
- **Renaming `paliad.events`** (the audit feed). Distinct table, distinct concept. The umbrella-term lock (§2) deliberately uses "procedural event" not "event" to avoid colliding with it.
|
||||
- **Renaming `paliad.deadline_concepts`** to align with the procedural-event taxonomy. The concept layer is the cross-proceeding semantic bridge (einstein 2026-05-08 Q5); the relationship "procedural event has-a concept" already reads cleanly under the new term.
|
||||
- **Per-jurisdiction variations of the same procedural event** (issue body's explicit out-of-scope). The 10 multi-row codes in the corpus today stay multi-row.
|
||||
- **Multi-tenant / cross-firm sharing of procedural events** — paliad is single-tenant per deploy via `FIRM_NAME`; cross-firm is a separate design.
|
||||
- **einstein's `proceeding_event_edges` graph proposal.** That design proposed a graph of typed event-types connected by typed edges. This design's procedural-events / sequencing-rules split is **compatible** with that graph shape (the edges would attach to procedural-event-IDs rather than sequencing-rule-IDs), but the graph layer is a Slice C, not Slice B. Flagged for future continuity, not part of either slice here.
|
||||
- **Renaming Go type `DeadlineRule` to `SequencingRule` or `ProceduralEvent` in Slice A.** Slice A is prose; Slice B's B.5 sub-slice handles the type rename. Coupling them costs the reversibility property.
|
||||
- **API-envelope key renames** (`submission_code` → `code`, `event_type` → `event_kind` on the wire). Slice B only.
|
||||
- **URL path renames** (`/admin/rules` → `/admin/procedural-events`). Slice B.6, optional.
|
||||
- **Touching `paliad.trigger_events`** beyond keeping the FK path open (today `deadline_rules.trigger_event_id`; Slice B maps to `sequencing_rules.trigger_event_id`).
|
||||
- **Touching `paliad.event_categories` / Pathway-B navigation.** Independent layer.
|
||||
|
||||
---
|
||||
|
||||
## §11 Open questions for m (escalated via `mai instruct head` per project CLAUDE.md)
|
||||
|
||||
Per project CLAUDE.md "Head answers questions — NO AskUserQuestion" rule, these are surfaced to head, not picked-as-chip with the user.
|
||||
|
||||
| ID | Question | Inventor recommendation | Material to head? |
|
||||
|---|---|---|---|
|
||||
| **Q1** | Scope: cosmetic-only (A) · full restructure (B) · cosmetic now + B as planned follow-up (C). | **(R) = C** | Yes — material. Defines whether Slice B is hired today or filed as a future task. |
|
||||
| **Q2** | Umbrella term: "procedural event" (DE: Verfahrensschritt) · "submission" (filings only) · "Verfahrensereignis" · other. | **(R) = procedural event / Verfahrensschritt** | Yes — material. The term ripples through every label in §7. Inventor's pick is the canonical choice; head can override with a single message. |
|
||||
| **Q3** | Slice B migration shape: confirmed (§4 + §5) or rescope. | **(R) = §4 + §5 as written, decision deferred until Slice B is hired** | No — informational. Locked when Slice B's own design pass runs. |
|
||||
| **Q4** | Effect on Schriftsätze surface: filter `procedural_events.event_kind IN ('filing', 'reply')` is acceptable replacement for today's `event_type='filing'`. | **(R) = yes, semantically equivalent under Slice B; no behavior change to lawyer.** | No — informational. |
|
||||
| **Q5** | Are the 10 archived multi-row submission_codes (`_archived_litigation.*`) safe to collapse into single procedural events with multiple sequencing variants in Slice B? | **(R) = yes, prefix indicates archival; collapse-safe.** | No — informational, defers to Slice B. |
|
||||
| **Q6** | `concept_id` attaches to procedural event, not sequencing rule. Confirmable? | **(R) = yes, per §4.2 (one concept per identity, not per jurisdiction).** | No — informational, defers to Slice B. |
|
||||
| **Q7** | Keep the legacy `{{rule.X}}` placeholder aliases **forever**, or set a deprecation horizon (e.g. 1 year)? | **(R) = forever, with `@deprecated` annotation in the catalog. Removing them risks breaking lawyer-authored templates that paliad doesn't see.** | Yes — material to Slice A's contract (test in §8.1 step 8 asserts both keys emit). |
|
||||
| **Q8** | Document side: update m/paliad#93 issue body to fix the `deadlines.deadline_rule_id` → `deadlines.rule_id` typo (§1 last paragraph). | **(R) = yes, head's call when to edit.** | No — informational, doc hygiene. |
|
||||
| **Q9** | After Slice A ships, do we file Slice B as a new mai task **now** (so it's visible), or wait for m to ask? | **(R) = file now, status:planning, no owner. Visibility >> deferred surprise.** | Yes — material to "does the model stay wrong forever". |
|
||||
|
||||
Q1, Q2, Q7, Q9 are the four head needs to answer before the coder shift. Q3-Q6, Q8 defer cleanly.
|
||||
|
||||
---
|
||||
|
||||
## §12 Appendix — verbatim m quote
|
||||
|
||||
From m's report 2026-05-25 15:02 (paliad#93 body):
|
||||
|
||||
> This shows how our 'rule' table system may need a revision?! It feels like we are rule based not submission based. But here we have a specific submission that is connected to a rule (as in: legal norm). And of course also connected to other 'procedural events' (which is a good term for it all) by rules how they are sequenced. But it makes it sound weird in the fields...
|
||||
|
||||
The design above takes m's three-way split — *the procedural event* / *the legal norm* / *the rule by which they are sequenced* — at face value and turns it into a column-level map (§4.2), a slice plan (§8), and a deprecation contract (§9.1).
|
||||
|
||||
---
|
||||
|
||||
*End of design.*
|
||||
@@ -46,7 +46,6 @@ import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
|
||||
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
|
||||
import { renderAdminRulesList } from "./src/admin-rules-list";
|
||||
import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderAdminBackups } from "./src/admin-backups";
|
||||
@@ -284,7 +283,6 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-list.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-export.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
// t-paliad-161 — inline Paliadin widget. Loaded via the
|
||||
// PaliadinWidget component on every authenticated page, so the
|
||||
@@ -416,7 +414,6 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList());
|
||||
await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit());
|
||||
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
|
||||
|
||||
@@ -1,80 +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";
|
||||
|
||||
// /admin/rules/export — Slice 11b (t-paliad-192). Surfaces the
|
||||
// GET /admin/api/rules/export-migrations endpoint as a SQL preview the
|
||||
// editor can copy or download. Optional ?since=<audit-id> query lets
|
||||
// the editor scope the export to a particular audit window — empty =
|
||||
// every un-exported audit row.
|
||||
export function renderAdminRulesExport(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.export.title">Regel-Migrations exportieren — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/rules" data-i18n="admin.rules.export.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 data-i18n="admin.rules.export.heading">Regel-Migrations exportieren</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.export.subtitle">
|
||||
Generiert ein <code>*.up.sql</code>-Blob mit allen unsynchronisierten Audit-Veränderungen.
|
||||
Manuell in <code>internal/db/migrations/</code> einchecken.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-export-controls">
|
||||
<div className="form-field">
|
||||
<label htmlFor="export-since" data-i18n="admin.rules.export.field.since">Startend ab Audit-ID (optional)</label>
|
||||
<input type="text" id="export-since" className="admin-rules-input" placeholder="UUID, leer = alle un-exportierten" />
|
||||
</div>
|
||||
<button type="button" id="export-run" className="btn-primary" data-i18n="admin.rules.export.run">
|
||||
Export generieren
|
||||
</button>
|
||||
<button type="button" id="export-download" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.download">
|
||||
Als Datei herunterladen
|
||||
</button>
|
||||
<button type="button" id="export-copy" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.copy">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="export-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-rules-export-summary" id="export-summary" style="display:none">
|
||||
<span id="export-summary-count" />
|
||||
<span id="export-summary-latest" />
|
||||
</div>
|
||||
|
||||
<pre id="export-output" className="admin-rules-export-pre" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-rules-export.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -39,9 +39,6 @@ export function renderAdminRulesList(): string {
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-rules-header-actions">
|
||||
<a href="/admin/rules/export" className="btn-secondary" data-i18n="admin.rules.list.export">
|
||||
Migrations exportieren
|
||||
</a>
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
|
||||
+ Neue Regel
|
||||
</button>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-export.ts — /admin/rules/export. Calls
|
||||
// GET /admin/api/rules/export-migrations[?since=<uuid>] and renders the
|
||||
// SQL blob server-side. Download builds a Blob URL and triggers a
|
||||
// fake <a> click; copy uses navigator.clipboard.
|
||||
|
||||
interface ExportResult {
|
||||
migration_sql: string;
|
||||
count: number;
|
||||
latest_audit_id: string;
|
||||
}
|
||||
|
||||
let latest: ExportResult | null = null;
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("export-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
if (!isError) setTimeout(() => { el.style.display = "none"; }, 4000);
|
||||
}
|
||||
|
||||
async function runExport() {
|
||||
const since = (document.getElementById("export-since") as HTMLInputElement).value.trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (since) qs.set("since", since);
|
||||
const url = "/admin/api/rules/export-migrations" + (qs.toString() ? "?" + qs.toString() : "");
|
||||
const out = document.getElementById("export-output") as HTMLElement;
|
||||
const summary = document.getElementById("export-summary") as HTMLElement;
|
||||
const dl = document.getElementById("export-download") as HTMLElement;
|
||||
const cp = document.getElementById("export-copy") as HTMLElement;
|
||||
out.textContent = t("admin.rules.export.running") || "Lade...";
|
||||
summary.style.display = "none";
|
||||
dl.style.display = "none";
|
||||
cp.style.display = "none";
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || (t("admin.rules.export.error") || "Export fehlgeschlagen."), true);
|
||||
out.textContent = "";
|
||||
return;
|
||||
}
|
||||
latest = await resp.json() as ExportResult;
|
||||
out.textContent = latest.migration_sql;
|
||||
summary.style.display = "";
|
||||
const countEl = document.getElementById("export-summary-count") as HTMLElement;
|
||||
const latestEl = document.getElementById("export-summary-latest") as HTMLElement;
|
||||
countEl.textContent = (t("admin.rules.export.count") || "Audit-Zeilen: {n}").replace("{n}", String(latest.count));
|
||||
if (latest.latest_audit_id) {
|
||||
latestEl.textContent = (t("admin.rules.export.latest") || "Letzte Audit-ID: {id}").replace("{id}", latest.latest_audit_id);
|
||||
} else {
|
||||
latestEl.textContent = "";
|
||||
}
|
||||
if (latest.count > 0) {
|
||||
dl.style.display = "";
|
||||
cp.style.display = "";
|
||||
showFeedback((t("admin.rules.export.ok") || "{n} Audit-Zeilen exportiert.").replace("{n}", String(latest.count)), false);
|
||||
} else {
|
||||
showFeedback(t("admin.rules.export.no_pending") || "Keine offenen Audit-Zeilen zum Export.", false);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
if (!latest) return;
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const name = `rules-export-${ts}.up.sql`;
|
||||
const blob = new Blob([latest.migration_sql], { type: "application/sql" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!latest) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(latest.migration_sql);
|
||||
showFeedback(t("admin.rules.export.copied") || "In Zwischenablage kopiert.", false);
|
||||
} catch (e) {
|
||||
showFeedback(t("admin.rules.export.copy_failed") || "Kopieren fehlgeschlagen.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
(document.getElementById("export-run") as HTMLElement).addEventListener("click", runExport);
|
||||
(document.getElementById("export-download") as HTMLElement).addEventListener("click", downloadFile);
|
||||
(document.getElementById("export-copy") as HTMLElement).addEventListener("click", copyToClipboard);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
@@ -237,6 +237,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.disc.cfi": "Bucheinsicht",
|
||||
"deadlines.upc.apl.cost": "Berufung Kosten",
|
||||
"deadlines.upc.apl.order": "Berufung Anordnungen",
|
||||
"deadlines.upc.apl.unified": "Berufung",
|
||||
"deadlines.appeal_target.label": "Worauf richtet sich die Berufung?",
|
||||
"deadlines.appeal_target.endentscheidung": "Endentscheidung",
|
||||
"deadlines.appeal_target.kostenentscheidung": "Kostenentscheidung",
|
||||
"deadlines.appeal_target.anordnung": "Anordnung",
|
||||
"deadlines.appeal_target.schadensbemessung": "Schadensbemessung",
|
||||
"deadlines.appeal_target.bucheinsicht": "Bucheinsicht",
|
||||
"deadlines.de.group.inf": "Verletzungsverfahren",
|
||||
"deadlines.de.group.null": "Nichtigkeitsverfahren",
|
||||
"deadlines.de.inf.lg": "LG (1. Instanz)",
|
||||
@@ -254,6 +261,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.party.both.label": "beide Seiten",
|
||||
"deadlines.court.set": "vom Gericht bestimmt",
|
||||
"deadlines.court.indirect": "unbestimmt",
|
||||
"deadlines.conditional.depends_on": "abhängig von {parent}",
|
||||
"deadlines.conditional.unset": "abhängig von vorgelagertem Ereignis",
|
||||
"deadlines.optional.badge": "auf Antrag",
|
||||
"deadlines.priority.mandatory": "Pflicht",
|
||||
"deadlines.priority.recommended": "empfohlen",
|
||||
@@ -303,10 +312,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
"deadlines.view.columns": "Spalten",
|
||||
"deadlines.notes.show": "Hinweise anzeigen",
|
||||
"deadlines.durations.show": "Dauern anzeigen",
|
||||
"deadlines.col.ours": "Unsere Seite",
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
"deadlines.col.both": "Beide Parteien",
|
||||
"deadlines.col.proactive": "Proaktiv",
|
||||
"deadlines.col.reactive": "Reaktiv",
|
||||
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
|
||||
"choices.caret.title": "Optionen für dieses Ereignis",
|
||||
"choices.appellant.title": "Berufung durch …",
|
||||
@@ -325,6 +337,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"choices.include_ccr.chip": "mit Nichtigkeitswiderklage",
|
||||
"choices.reset": "Auswahl zurücksetzen",
|
||||
"choices.commit.error": "Konnte Auswahl nicht speichern",
|
||||
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
|
||||
"choices.show_hidden.label": "Ausgeblendete anzeigen",
|
||||
"choices.show_hidden.count": "Ausgeblendete ({n})",
|
||||
"choices.unhide.chip": "Wieder einblenden",
|
||||
// t-paliad-293 \u2014 iconified state markers on the Verfahrensablauf
|
||||
// event cards. Tooltip-only text; the glyph is the primary signal.
|
||||
"state.optional.tooltip": "Optionales Ereignis",
|
||||
"state.hidden.tooltip": "Ausgeblendet \u2014 \u00fcber Optionen-Men\u00fc wieder einblenden",
|
||||
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||
"deadlines.mode.event": "Was kommt nach\u2026",
|
||||
@@ -439,13 +459,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Seite:",
|
||||
"deadlines.side.claimant": "Klägerseite",
|
||||
"deadlines.side.defendant": "Beklagtenseite",
|
||||
"deadlines.side.both": "Beide",
|
||||
"deadlines.side.undefined": "Nicht festgelegt",
|
||||
"deadlines.side.from_project": "Aus Akte:",
|
||||
"deadlines.side.override": "Andere Seite wählen",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
|
||||
"deadlines.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -1497,7 +1514,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// t-paliad-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Aus Projekt importieren",
|
||||
"submissions.draft.parties.title": "Parteien",
|
||||
"submissions.draft.parties.hint": "Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.",
|
||||
"submissions.draft.parties.hint": "Wählen Sie die im Schriftsatz genannten Parteien oder fügen Sie pro Seite weitere hinzu.",
|
||||
// t-paliad-276 — DE/EN language toggle on the draft editor.
|
||||
"submissions.draft.language": "Sprache",
|
||||
"submissions.draft.language.de": "DE",
|
||||
@@ -2881,7 +2898,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// `admin.procedural_events.*` aliases live after the EN block — they
|
||||
// pin the contract for when .tsx files rebind in Slice B (B.5).
|
||||
"nav.admin.rules": "Verfahrensschritte verwalten",
|
||||
"nav.admin.rules_export": "Verfahrensschritt-Migrations",
|
||||
"admin.card.rules.title": "Verfahrensschritte verwalten",
|
||||
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
|
||||
|
||||
@@ -2889,7 +2905,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.list.heading": "Verfahrensschritte verwalten",
|
||||
"admin.rules.list.subtitle": "Verfahrensschritte (Schriftsätze, Anhörungen, Entscheidungen, …) anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ Neuer Verfahrensschritt",
|
||||
"admin.rules.list.export": "Migrations exportieren",
|
||||
"admin.rules.tab.rules": "Regeln",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Lade…",
|
||||
@@ -3051,23 +3066,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.modal.restore.title": "Wiederherstellen",
|
||||
"admin.rules.edit.modal.restore.body": "Regel wird wiederhergestellt (archived → published).",
|
||||
|
||||
"admin.rules.export.title": "Regel-Migrations exportieren — Paliad",
|
||||
"admin.rules.export.heading": "Regel-Migrations exportieren",
|
||||
"admin.rules.export.subtitle": "Generiert ein *.up.sql-Blob mit allen unsynchronisierten Audit-Veränderungen. Manuell in internal/db/migrations/ einchecken.",
|
||||
"admin.rules.export.breadcrumb": "← Regeln verwalten",
|
||||
"admin.rules.export.field.since": "Startend ab Audit-ID (optional)",
|
||||
"admin.rules.export.run": "Export generieren",
|
||||
"admin.rules.export.running": "Lade…",
|
||||
"admin.rules.export.download": "Als Datei herunterladen",
|
||||
"admin.rules.export.copy": "In Zwischenablage kopieren",
|
||||
"admin.rules.export.copied": "In Zwischenablage kopiert.",
|
||||
"admin.rules.export.copy_failed": "Kopieren fehlgeschlagen.",
|
||||
"admin.rules.export.count": "Audit-Zeilen: {n}",
|
||||
"admin.rules.export.latest": "Letzte Audit-ID: {id}",
|
||||
"admin.rules.export.ok": "{n} Audit-Zeilen exportiert.",
|
||||
"admin.rules.export.error": "Export fehlgeschlagen.",
|
||||
"admin.rules.export.no_pending": "Keine offenen Audit-Zeilen zum Export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). Symmetric past/future chip fan
|
||||
// around an ALLES centre. Used by the filter-bar 'time' axis from
|
||||
// Slice A onwards; future slices will migrate /agenda and
|
||||
@@ -3333,6 +3331,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.dmgs.cfi": "Damages Determination",
|
||||
"deadlines.upc.disc.cfi": "Lay-open Books",
|
||||
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
|
||||
"deadlines.upc.apl.unified": "Appeal",
|
||||
"deadlines.appeal_target.label": "Appeal against:",
|
||||
"deadlines.appeal_target.endentscheidung": "Final Decision",
|
||||
"deadlines.appeal_target.kostenentscheidung": "Cost Decision",
|
||||
"deadlines.appeal_target.anordnung": "Order",
|
||||
"deadlines.appeal_target.schadensbemessung": "Damages Determination",
|
||||
"deadlines.appeal_target.bucheinsicht": "Lay-open Books",
|
||||
"deadlines.upc.apl.order": "Order Appeal (15-day)",
|
||||
"deadlines.de.group.inf": "Infringement proceedings",
|
||||
"deadlines.de.group.null": "Nullity proceedings",
|
||||
@@ -3351,6 +3356,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.party.both.label": "both parties",
|
||||
"deadlines.court.set": "set by court",
|
||||
"deadlines.court.indirect": "tbd",
|
||||
"deadlines.conditional.depends_on": "depends on {parent}",
|
||||
"deadlines.conditional.unset": "depends on an upstream event",
|
||||
"deadlines.optional.badge": "on request",
|
||||
"deadlines.priority.mandatory": "Mandatory",
|
||||
"deadlines.priority.recommended": "Recommended",
|
||||
@@ -3400,10 +3407,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
"deadlines.view.columns": "Columns",
|
||||
"deadlines.notes.show": "Show details",
|
||||
"deadlines.durations.show": "Show durations",
|
||||
"deadlines.col.ours": "Client Side",
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
"deadlines.col.both": "Both parties",
|
||||
"deadlines.col.proactive": "Proactive",
|
||||
"deadlines.col.reactive": "Reactive",
|
||||
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
|
||||
"choices.caret.title": "Options for this event",
|
||||
"choices.appellant.title": "Appeal by …",
|
||||
@@ -3422,6 +3432,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"choices.include_ccr.chip": "with nullity counterclaim",
|
||||
"choices.reset": "Reset choice",
|
||||
"choices.commit.error": "Could not save selection",
|
||||
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
|
||||
"choices.show_hidden.label": "Show hidden",
|
||||
"choices.show_hidden.count": "Hidden ({n})",
|
||||
"choices.unhide.chip": "Show again",
|
||||
// t-paliad-293 — iconified state markers on the Verfahrensablauf
|
||||
// event cards. Tooltip-only text; the glyph is the primary signal.
|
||||
"state.optional.tooltip": "Optional event",
|
||||
"state.hidden.tooltip": "Hidden — restore via the options menu",
|
||||
"deadlines.adjusted": "Adjusted",
|
||||
"deadlines.adjusted.reason": "weekend/holiday",
|
||||
"deadlines.adjusted.weekend": "weekend",
|
||||
@@ -3543,13 +3561,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Side:",
|
||||
"deadlines.side.claimant": "Claimant",
|
||||
"deadlines.side.defendant": "Defendant",
|
||||
"deadlines.side.both": "Both",
|
||||
"deadlines.side.undefined": "Undefined",
|
||||
"deadlines.side.from_project": "From case:",
|
||||
"deadlines.side.override": "Choose other side",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.side.hint": "Pick a side to focus the columns.",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
@@ -4580,7 +4595,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// t-paliad-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Import from project",
|
||||
"submissions.draft.parties.title": "Parties",
|
||||
"submissions.draft.parties.hint": "Select which parties to mention in this submission.",
|
||||
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
@@ -5944,7 +5959,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
||||
// t-paliad-262 Slice A — "Rule" relabelled as "Procedural event".
|
||||
"nav.admin.rules": "Manage procedural events",
|
||||
"nav.admin.rules_export": "Procedural-event migrations",
|
||||
"admin.card.rules.title": "Manage procedural events",
|
||||
"admin.card.rules.desc": "Author, edit and publish procedural-event templates. Audit log, preview, migration export.",
|
||||
|
||||
@@ -5952,7 +5966,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.list.heading": "Manage procedural events",
|
||||
"admin.rules.list.subtitle": "Author, edit and publish procedural events (filings, hearings, decisions, …). Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ New procedural event",
|
||||
"admin.rules.list.export": "Export migrations",
|
||||
"admin.rules.tab.rules": "Rules",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Loading…",
|
||||
@@ -6114,23 +6127,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.modal.restore.title": "Restore",
|
||||
"admin.rules.edit.modal.restore.body": "Rule will be restored (archived → published).",
|
||||
|
||||
"admin.rules.export.title": "Export rule migrations — Paliad",
|
||||
"admin.rules.export.heading": "Export rule migrations",
|
||||
"admin.rules.export.subtitle": "Generates a *.up.sql blob with every un-exported audit change. Commit manually into internal/db/migrations/.",
|
||||
"admin.rules.export.breadcrumb": "← Manage Rules",
|
||||
"admin.rules.export.field.since": "Starting from audit id (optional)",
|
||||
"admin.rules.export.run": "Generate export",
|
||||
"admin.rules.export.running": "Loading…",
|
||||
"admin.rules.export.download": "Download as file",
|
||||
"admin.rules.export.copy": "Copy to clipboard",
|
||||
"admin.rules.export.copied": "Copied to clipboard.",
|
||||
"admin.rules.export.copy_failed": "Copy failed.",
|
||||
"admin.rules.export.count": "Audit rows: {n}",
|
||||
"admin.rules.export.latest": "Latest audit id: {id}",
|
||||
"admin.rules.export.ok": "{n} audit rows exported.",
|
||||
"admin.rules.export.error": "Export failed.",
|
||||
"admin.rules.export.no_pending": "No pending audit rows to export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). See DE block above for details.
|
||||
"date_range.button.label": "Time range",
|
||||
"date_range.button.label.custom_range": "From {from} to {to}",
|
||||
|
||||
@@ -137,6 +137,13 @@ interface VariableGroup {
|
||||
id: string;
|
||||
label: VariableLabel;
|
||||
keys: string[];
|
||||
// t-paliad-287 — render with a click-to-toggle disclosure caret; the
|
||||
// initial state is collapsed iff collapsedByDefault. Used for the
|
||||
// Frist section which lawyers rarely need to override (the variables
|
||||
// stay resolvable in the bag for the few templates that still want
|
||||
// them, but render no body content by default).
|
||||
collapsible?: boolean;
|
||||
collapsedByDefault?: boolean;
|
||||
}
|
||||
|
||||
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
@@ -205,33 +212,19 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
"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
|
||||
// (where the picker UI lives — this group only carries the manual
|
||||
// {{parties.*}} overrides for power-users), then Frist collapsed by
|
||||
// default (the deadline.* keys still resolve in the bag but the default
|
||||
// templates don't render them in the body any more), then Sonstiges for
|
||||
// the firm/date/user trim. The legacy procedural_event/rule namespaces
|
||||
// fold into Mandant/Verfahren so the lawyer reads them in their natural
|
||||
// context.
|
||||
const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
{
|
||||
id: "procedural_event",
|
||||
label: { de: "Verfahrensschritt", en: "Procedural event" },
|
||||
keys: [
|
||||
"procedural_event.name",
|
||||
"procedural_event.legal_source_pretty",
|
||||
"procedural_event.primary_party",
|
||||
"procedural_event.event_kind",
|
||||
"procedural_event.code",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "parties",
|
||||
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
|
||||
keys: [
|
||||
"parties.claimant.name",
|
||||
"parties.claimant.representative",
|
||||
"parties.defendant.name",
|
||||
"parties.defendant.representative",
|
||||
"parties.other.name",
|
||||
"parties.other.representative",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "project",
|
||||
label: { de: "Verfahren", en: "Proceeding" },
|
||||
id: "mandant_verfahren",
|
||||
label: { de: "Mandant & Verfahren", en: "Client & proceeding" },
|
||||
keys: [
|
||||
"project.title",
|
||||
"project.case_number",
|
||||
@@ -246,11 +239,43 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
"project.matter_number",
|
||||
"project.reference",
|
||||
"project.instance_level",
|
||||
"procedural_event.name",
|
||||
"procedural_event.legal_source_pretty",
|
||||
"procedural_event.primary_party",
|
||||
"procedural_event.event_kind",
|
||||
"procedural_event.code",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "parties",
|
||||
label: { de: "Parteien (Variablen)", en: "Parties (variables)" },
|
||||
// Manual overrides for {{parties.<role>.*}} placeholders — power-
|
||||
// user escape hatch when the lawyer wants the rendered string to
|
||||
// differ from the picker selection (e.g. honourific prefix on
|
||||
// representative). Collapsed by default because the picker above
|
||||
// is the canonical surface; these rows exist only as a safety
|
||||
// valve.
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
keys: [
|
||||
"parties.claimant.name",
|
||||
"parties.claimant.representative",
|
||||
"parties.defendant.name",
|
||||
"parties.defendant.representative",
|
||||
"parties.other.name",
|
||||
"parties.other.representative",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deadline",
|
||||
label: { de: "Frist", en: "Deadline" },
|
||||
label: { de: "Frist (intern)", en: "Deadline (internal)" },
|
||||
// t-paliad-287 — the {{deadline.*}} placeholders no longer render
|
||||
// in the default skeleton body (internal context that doesn't
|
||||
// belong in a court-bound submission). The values still resolve
|
||||
// here so a custom template can pick them up if needed; collapsed
|
||||
// because most drafts never touch them.
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
keys: [
|
||||
"deadline.due_date",
|
||||
"deadline.due_date_long_de",
|
||||
@@ -261,10 +286,11 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "firm",
|
||||
label: { de: "Kanzlei & Datum", en: "Firm & date" },
|
||||
id: "sonstiges",
|
||||
label: { de: "Sonstiges", en: "Other" },
|
||||
keys: [
|
||||
"firm.name",
|
||||
"firm.signature_block",
|
||||
"user.display_name",
|
||||
"user.email",
|
||||
"user.office",
|
||||
@@ -291,6 +317,29 @@ interface State {
|
||||
saveTimer: number | null;
|
||||
pendingOverrides: Record<string, string> | null;
|
||||
inFlight: AbortController | null;
|
||||
// t-paliad-287 — per-section collapse memory. Sticky across repaints
|
||||
// so autosave (which calls paintVariables) doesn't snap an open
|
||||
// section shut. Seeded lazily from VARIABLE_GROUPS.collapsedByDefault.
|
||||
collapsedGroups: Record<string, boolean>;
|
||||
// t-paliad-287 — which side the Add-Party panel is currently open for
|
||||
// (one panel can be open at a time; clicking the other side's button
|
||||
// toggles). null means closed.
|
||||
addPartyOpen: PartySide | null;
|
||||
addPartyMode: "manual" | "search";
|
||||
addPartySearchHits: PartySearchHit[];
|
||||
addPartyBusy: boolean;
|
||||
}
|
||||
|
||||
type PartySide = "claimant" | "defendant" | "other";
|
||||
|
||||
interface PartySearchHit {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
project_reference?: string | null;
|
||||
name: string;
|
||||
role?: string;
|
||||
representative?: string;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
@@ -300,6 +349,11 @@ const state: State = {
|
||||
saveTimer: null,
|
||||
pendingOverrides: null,
|
||||
inFlight: null,
|
||||
collapsedGroups: {},
|
||||
addPartyOpen: null,
|
||||
addPartyMode: "manual",
|
||||
addPartySearchHits: [],
|
||||
addPartyBusy: false,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -607,24 +661,31 @@ function paintImportRow(): void {
|
||||
btn.onclick = () => { void onImportFromProject(btn); };
|
||||
}
|
||||
|
||||
// t-paliad-277 — multi-select party picker. Lists every party on the
|
||||
// draft's project (view.available_parties), grouped by role, with one
|
||||
// checkbox per party. Checked = include in the variable bag. Empty
|
||||
// selection falls back to the legacy "include every party" default
|
||||
// (consistent with the migration default).
|
||||
// t-paliad-277 / t-paliad-287 — multi-select party picker plus Add-
|
||||
// Party affordance per side. Lists every party on the draft's project
|
||||
// (view.available_parties), grouped by role, with one checkbox per
|
||||
// party. Each side (Klägerseite / Beklagtenseite / Sonstige) carries
|
||||
// an "+ Partei hinzufügen" button that opens an inline panel with two
|
||||
// modes: manual entry (creates a fresh paliad.parties row) or DB
|
||||
// picker (searches every visible project, clones the row into THIS
|
||||
// project on selection). Empty selection still falls back to the
|
||||
// legacy "include every party" default.
|
||||
function paintPartyPicker(): void {
|
||||
const block = document.getElementById("submission-draft-parties");
|
||||
const list = document.getElementById("submission-draft-parties-list");
|
||||
if (!block || !list || !state.view) return;
|
||||
|
||||
const parties = state.view.available_parties ?? [];
|
||||
if (!state.view.draft.project_id || parties.length === 0) {
|
||||
// t-paliad-287 — picker is now shown even on empty-roster projects so
|
||||
// the lawyer can use Add Party to populate. Still hidden when there
|
||||
// is no project attached (no row to attach a party to).
|
||||
if (!state.view.draft.project_id) {
|
||||
block.style.display = "none";
|
||||
list.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
block.style.display = "";
|
||||
|
||||
const parties = state.view.available_parties ?? [];
|
||||
const selected = new Set(state.view.draft.selected_parties ?? []);
|
||||
// Empty selection is the implicit "all" default — pre-check every
|
||||
// party so the lawyer can see what's currently being mentioned and
|
||||
@@ -637,9 +698,13 @@ function paintPartyPicker(): void {
|
||||
const grouped = groupPartiesByRole(parties);
|
||||
let html = "";
|
||||
for (const group of grouped) {
|
||||
if (group.parties.length === 0) continue;
|
||||
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
|
||||
html += `<legend>${escapeHtml(group.label)}</legend>`;
|
||||
if (group.parties.length === 0) {
|
||||
html += `<p class="submission-draft-parties-empty">${escapeHtml(
|
||||
isEN() ? "No parties yet." : "Noch keine Parteien.",
|
||||
)}</p>`;
|
||||
}
|
||||
for (const p of group.parties) {
|
||||
const checked = effective.has(p.id) ? " checked" : "";
|
||||
const chip = p.role
|
||||
@@ -658,6 +723,7 @@ function paintPartyPicker(): void {
|
||||
html += rep;
|
||||
html += `</label>`;
|
||||
}
|
||||
html += renderAddPartyControls(group.bucket);
|
||||
html += `</fieldset>`;
|
||||
}
|
||||
list.innerHTML = html;
|
||||
@@ -665,6 +731,198 @@ function paintPartyPicker(): void {
|
||||
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
|
||||
inp.addEventListener("change", () => onPartySelectionChange());
|
||||
});
|
||||
wireAddPartyControls(list);
|
||||
}
|
||||
|
||||
// renderAddPartyControls emits the per-side "+ Add party" button and
|
||||
// (when expanded) the inline panel offering manual entry OR DB search.
|
||||
// Sticky panel state lives in state.addPartyOpen so a repaint after
|
||||
// search-fetch / autosave / language-switch doesn't snap the panel
|
||||
// shut mid-edit.
|
||||
function renderAddPartyControls(side: PartySide): string {
|
||||
const open = state.addPartyOpen === side;
|
||||
const mode = state.addPartyMode;
|
||||
const sideLabel = sideLabelFor(side);
|
||||
const btnLabel = isEN()
|
||||
? `+ Add party (${sideLabel})`
|
||||
: `+ Partei hinzufügen (${sideLabel})`;
|
||||
|
||||
let html = `<div class="submission-draft-addparty">`;
|
||||
html += `<button type="button" class="btn-small btn-secondary submission-draft-addparty-toggle"`;
|
||||
html += ` data-side="${side}" aria-expanded="${open ? "true" : "false"}">`;
|
||||
html += escapeHtml(btnLabel);
|
||||
html += `</button>`;
|
||||
|
||||
if (!open) {
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// Tabs — manual / search.
|
||||
html += `<div class="submission-draft-addparty-panel">`;
|
||||
html += `<div class="submission-draft-addparty-tabs" role="tablist">`;
|
||||
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
|
||||
if (mode === "manual") html += ` submission-draft-addparty-tab--active`;
|
||||
html += `" data-tab="manual" data-side="${side}" aria-selected="${mode === "manual"}">`;
|
||||
html += escapeHtml(isEN() ? "Manual entry" : "Manuell");
|
||||
html += `</button>`;
|
||||
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
|
||||
if (mode === "search") html += ` submission-draft-addparty-tab--active`;
|
||||
html += `" data-tab="search" data-side="${side}" aria-selected="${mode === "search"}">`;
|
||||
html += escapeHtml(isEN() ? "From DB" : "Aus DB übernehmen");
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
|
||||
if (mode === "manual") {
|
||||
html += renderAddPartyManualForm(side);
|
||||
} else {
|
||||
html += renderAddPartySearchPanel(side);
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderAddPartyManualForm(side: PartySide): string {
|
||||
const defaultRole = defaultRoleFor(side);
|
||||
const busyCls = state.addPartyBusy ? " submission-draft-addparty-form--busy" : "";
|
||||
let html = `<form class="submission-draft-addparty-form${busyCls}" data-side="${side}" data-mode="manual">`;
|
||||
html += `<label class="submission-draft-addparty-field">`;
|
||||
html += `<span>${escapeHtml(isEN() ? "Name" : "Name")}</span>`;
|
||||
html += `<input type="text" name="name" required class="entity-form-input"`;
|
||||
html += ` placeholder="${escapeHtml(isEN() ? "Acme Inc." : "z. B. Acme GmbH")}" />`;
|
||||
html += `</label>`;
|
||||
html += `<label class="submission-draft-addparty-field">`;
|
||||
html += `<span>${escapeHtml(isEN() ? "Role" : "Rolle")}</span>`;
|
||||
html += `<input type="text" name="role" class="entity-form-input"`;
|
||||
html += ` value="${escapeHtml(defaultRole)}"`;
|
||||
html += ` placeholder="${escapeHtml(isEN() ? "claimant / defendant / intervenor / …" : "Klägerin / Beklagte / Streithelferin / …")}" />`;
|
||||
html += `</label>`;
|
||||
html += `<label class="submission-draft-addparty-field">`;
|
||||
html += `<span>${escapeHtml(isEN() ? "Representative (optional)" : "Vertreter:in (optional)")}</span>`;
|
||||
html += `<input type="text" name="representative" class="entity-form-input"`;
|
||||
html += ` placeholder="${escapeHtml(isEN() ? "Dr. Müller, …" : "RA Dr. Müller, …")}" />`;
|
||||
html += `</label>`;
|
||||
html += `<div class="submission-draft-addparty-actions">`;
|
||||
html += `<button type="submit" class="btn-small btn-primary"${state.addPartyBusy ? " disabled" : ""}>`;
|
||||
html += escapeHtml(isEN() ? "Add party" : "Hinzufügen");
|
||||
html += `</button>`;
|
||||
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
|
||||
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
html += `</form>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderAddPartySearchPanel(side: PartySide): string {
|
||||
let html = `<div class="submission-draft-addparty-search" data-side="${side}" data-mode="search">`;
|
||||
html += `<input type="search" class="entity-form-input submission-draft-addparty-search-input"`;
|
||||
html += ` data-side="${side}"`;
|
||||
html += ` placeholder="${escapeHtml(
|
||||
isEN()
|
||||
? "Search across projects (name or representative)…"
|
||||
: "In allen Projekten suchen (Name oder Vertreter)…",
|
||||
)}" />`;
|
||||
html += renderPartySearchResultsList();
|
||||
html += `<p class="submission-draft-addparty-search-hint">${escapeHtml(
|
||||
isEN()
|
||||
? "Picking a row clones it as a fresh party on this project — no typing."
|
||||
: "Auswählen kopiert die Partei in dieses Projekt — kein erneutes Tippen.",
|
||||
)}</p>`;
|
||||
html += `<div class="submission-draft-addparty-actions">`;
|
||||
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
|
||||
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function wireAddPartyControls(root: HTMLElement): void {
|
||||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const side = (btn.dataset.side as PartySide) ?? "other";
|
||||
if (state.addPartyOpen === side) {
|
||||
// Toggle off.
|
||||
state.addPartyOpen = null;
|
||||
state.addPartySearchHits = [];
|
||||
} else {
|
||||
state.addPartyOpen = side;
|
||||
state.addPartyMode = "manual";
|
||||
state.addPartySearchHits = [];
|
||||
}
|
||||
paintPartyPicker();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = btn.dataset.tab;
|
||||
if (tab !== "manual" && tab !== "search") return;
|
||||
state.addPartyMode = tab;
|
||||
if (tab === "manual") state.addPartySearchHits = [];
|
||||
paintPartyPicker();
|
||||
if (tab === "search") {
|
||||
// Pre-load most-recent matches with empty query so the lawyer
|
||||
// sees options without typing first.
|
||||
void runPartySearch("");
|
||||
}
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-cancel").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.addPartyOpen = null;
|
||||
state.addPartySearchHits = [];
|
||||
paintPartyPicker();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLFormElement>(".submission-draft-addparty-form").forEach((form) => {
|
||||
form.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
const side = (form.dataset.side as PartySide) ?? "other";
|
||||
const data = new FormData(form);
|
||||
const name = String(data.get("name") ?? "").trim();
|
||||
if (!name) return;
|
||||
const role = String(data.get("role") ?? "").trim();
|
||||
const representative = String(data.get("representative") ?? "").trim();
|
||||
void onAddPartyManualSubmit(side, { name, role, representative });
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLInputElement>(".submission-draft-addparty-search-input").forEach((inp) => {
|
||||
let timer: number | null = null;
|
||||
inp.addEventListener("input", () => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
void runPartySearch(inp.value.trim());
|
||||
}, 200);
|
||||
});
|
||||
// Pre-load on first render of the search tab.
|
||||
if (state.addPartyMode === "search" && state.addPartySearchHits.length === 0) {
|
||||
void runPartySearch("");
|
||||
}
|
||||
});
|
||||
root.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const hitID = li.dataset.hitId;
|
||||
if (!hitID) return;
|
||||
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
|
||||
if (!hit) return;
|
||||
const side = state.addPartyOpen ?? "other";
|
||||
void onAddPartySearchPick(side, hit);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sideLabelFor(side: PartySide): string {
|
||||
if (side === "claimant") return isEN() ? "Claimant side" : "Klägerseite";
|
||||
if (side === "defendant") return isEN() ? "Defendant side" : "Beklagtenseite";
|
||||
return isEN() ? "Other parties" : "Weitere Parteien";
|
||||
}
|
||||
|
||||
function defaultRoleFor(side: PartySide): string {
|
||||
if (side === "claimant") return isEN() ? "claimant" : "Klägerin";
|
||||
if (side === "defendant") return isEN() ? "defendant" : "Beklagte";
|
||||
return "";
|
||||
}
|
||||
|
||||
interface PartyRoleGroup {
|
||||
@@ -781,8 +1039,27 @@ function paintVariables(): void {
|
||||
let html = "";
|
||||
for (const group of VARIABLE_GROUPS) {
|
||||
const groupLabel = isEN() ? group.label.en : group.label.de;
|
||||
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
|
||||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
||||
// Re-use the user's prior toggle state across paintVariables calls
|
||||
// (autosave / language switch trigger a repaint). Default sticky
|
||||
// state lives in state.collapsedGroups; on first render the
|
||||
// collapsedByDefault flag seeds it.
|
||||
if (!Object.prototype.hasOwnProperty.call(state.collapsedGroups, group.id)) {
|
||||
state.collapsedGroups[group.id] = !!(group.collapsible && group.collapsedByDefault);
|
||||
}
|
||||
const collapsed = !!state.collapsedGroups[group.id];
|
||||
const collapsibleCls = group.collapsible ? " submission-draft-var-group--collapsible" : "";
|
||||
const collapsedCls = collapsed ? " submission-draft-var-group--collapsed" : "";
|
||||
html += `<section class="submission-draft-var-group${collapsibleCls}${collapsedCls}" data-group="${group.id}">`;
|
||||
if (group.collapsible) {
|
||||
html += `<button type="button" class="submission-draft-var-group-toggle"`;
|
||||
html += ` data-toggle-group="${escapeHtml(group.id)}" aria-expanded="${collapsed ? "false" : "true"}">`;
|
||||
html += `<span class="submission-draft-var-group-caret" aria-hidden="true">▸</span>`;
|
||||
html += `<span class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</span>`;
|
||||
html += `</button>`;
|
||||
} else {
|
||||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
||||
}
|
||||
html += `<div class="submission-draft-var-group-body">`;
|
||||
for (const key of group.keys) {
|
||||
const label = labelFor(key);
|
||||
const override = overrides[key];
|
||||
@@ -813,10 +1090,19 @@ function paintVariables(): void {
|
||||
// Visual hint: marker text appears in preview when override is "".
|
||||
void mergedVal;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `</section>`;
|
||||
}
|
||||
host.innerHTML = html;
|
||||
|
||||
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-group-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = btn.dataset.toggleGroup;
|
||||
if (!id) return;
|
||||
state.collapsedGroups[id] = !state.collapsedGroups[id];
|
||||
paintVariables();
|
||||
});
|
||||
});
|
||||
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
|
||||
inp.addEventListener("input", () => onVarChange(inp));
|
||||
// t-paliad-274 (B) — focus into a sidebar field highlights every
|
||||
@@ -1021,6 +1307,175 @@ async function onPartySelectionChange(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function runPartySearch(query: string): Promise<void> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set("q", query);
|
||||
const resp = await fetch(`/api/parties/search?${params.toString()}`);
|
||||
if (!resp.ok) throw new Error(`search ${resp.status}`);
|
||||
const data = (await resp.json()) as { results: PartySearchHit[] };
|
||||
// Filter out parties already on THIS project — picking one of them
|
||||
// would be a no-op clone that doubles the row.
|
||||
const existingIDs = new Set(
|
||||
(state.view?.available_parties ?? []).map((p) => p.id),
|
||||
);
|
||||
state.addPartySearchHits = (data.results ?? []).filter((h) => !existingIDs.has(h.id));
|
||||
|
||||
// Refresh ONLY the results <ul> in place — repainting the whole
|
||||
// picker would steal focus from the search input on every
|
||||
// keystroke. The input keeps its value/selection and the lawyer
|
||||
// can keep typing.
|
||||
const ul = document.querySelector<HTMLUListElement>(
|
||||
".submission-draft-addparty-search-results",
|
||||
);
|
||||
if (ul) {
|
||||
ul.outerHTML = renderPartySearchResultsList();
|
||||
const fresh = document.querySelector<HTMLUListElement>(
|
||||
".submission-draft-addparty-search-results",
|
||||
);
|
||||
if (fresh) {
|
||||
fresh.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const hitID = li.dataset.hitId;
|
||||
if (!hitID) return;
|
||||
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
|
||||
if (!hit) return;
|
||||
const side = state.addPartyOpen ?? "other";
|
||||
void onAddPartySearchPick(side, hit);
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// First load (panel just opened) — full picker paint to wire up
|
||||
// every control. Subsequent keystroke updates take the cheaper
|
||||
// path above.
|
||||
paintPartyPicker();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("submission-draft party-search:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPartySearchResultsList(): string {
|
||||
let html = `<ul class="submission-draft-addparty-search-results">`;
|
||||
if (state.addPartySearchHits.length === 0) {
|
||||
html += `<li class="submission-draft-addparty-search-empty">${escapeHtml(
|
||||
isEN() ? "No matches." : "Keine Treffer.",
|
||||
)}</li>`;
|
||||
} else {
|
||||
for (const hit of state.addPartySearchHits) {
|
||||
const ref = hit.project_reference
|
||||
? `<span class="submission-draft-addparty-search-projref">${escapeHtml(hit.project_reference)}</span>`
|
||||
: "";
|
||||
const role = hit.role
|
||||
? `<span class="submission-draft-party-chip">${escapeHtml(hit.role)}</span>`
|
||||
: "";
|
||||
const rep = hit.representative
|
||||
? `<span class="submission-draft-addparty-search-rep">${escapeHtml(
|
||||
(isEN() ? "Repr.: " : "Vertr.: ") + hit.representative,
|
||||
)}</span>`
|
||||
: "";
|
||||
html += `<li class="submission-draft-addparty-search-row" data-hit-id="${escapeHtml(hit.id)}">`;
|
||||
html += `<span class="submission-draft-addparty-search-name">${escapeHtml(hit.name)}</span>`;
|
||||
html += role;
|
||||
html += rep;
|
||||
html += `<span class="submission-draft-addparty-search-projwrap">`;
|
||||
html += escapeHtml(isEN() ? "Project: " : "Projekt: ");
|
||||
html += `<span class="submission-draft-addparty-search-proj">${escapeHtml(hit.project_title)}</span>`;
|
||||
html += ref;
|
||||
html += `</span>`;
|
||||
html += `</li>`;
|
||||
}
|
||||
}
|
||||
html += `</ul>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
async function onAddPartyManualSubmit(
|
||||
side: PartySide,
|
||||
payload: { name: string; role: string; representative: string },
|
||||
): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const projectID = state.view.draft.project_id;
|
||||
if (!projectID) return;
|
||||
// Disable the submit button in-place rather than repainting the form
|
||||
// mid-flight (a repaint would blow away the lawyer's typed values on
|
||||
// error and reset focus). The post-success/-error repaint runs once
|
||||
// the call settles.
|
||||
const submitBtn = document.querySelector<HTMLButtonElement>(
|
||||
`.submission-draft-addparty-form[data-side="${side}"] button[type="submit"]`,
|
||||
);
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
state.addPartyBusy = true;
|
||||
try {
|
||||
const body: Record<string, unknown> = { name: payload.name };
|
||||
if (payload.role) body.role = payload.role;
|
||||
if (payload.representative) body.representative = payload.representative;
|
||||
const resp = await fetch(`/api/projects/${projectID}/parties`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`create party ${resp.status}`);
|
||||
const created = (await resp.json()) as { id: string };
|
||||
await refreshDraftViewAndSelect(created.id);
|
||||
state.addPartyOpen = null;
|
||||
setSaveStatus(isEN() ? "Party added" : "Partei hinzugefügt");
|
||||
state.addPartyBusy = false;
|
||||
paintPartyPicker();
|
||||
} catch (err) {
|
||||
console.error("submission-draft add-party manual:", err);
|
||||
setSaveStatus(isEN() ? "Add party failed" : "Hinzufügen fehlgeschlagen", true);
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
state.addPartyBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddPartySearchPick(side: PartySide, hit: PartySearchHit): Promise<void> {
|
||||
// DB picks clone the row into the current project — the simplest
|
||||
// semantics that survive paliad.parties' project_id-NOT-NULL schema.
|
||||
// The lawyer asked for "no manual re-typing"; this honours that
|
||||
// without bending the data model.
|
||||
await onAddPartyManualSubmit(side, {
|
||||
name: hit.name,
|
||||
role: hit.role ?? defaultRoleFor(side),
|
||||
representative: hit.representative ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
// refreshDraftViewAndSelect refetches the editor payload (so
|
||||
// available_parties picks up the new row) and ensures the newly-added
|
||||
// party is checked in selected_parties. If the lawyer was on the
|
||||
// implicit-all default (empty selected_parties), the new party comes
|
||||
// in pre-selected via the "empty=all" rule and no PATCH is needed.
|
||||
async function refreshDraftViewAndSelect(newPartyID: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const draftID = state.view.draft.id;
|
||||
const view = state.view.draft.project_id
|
||||
? await fetchView(state.view.draft.project_id, state.view.draft.submission_code, draftID)
|
||||
: await fetchGlobalView(draftID);
|
||||
state.view = view;
|
||||
|
||||
// If the previous draft had a non-empty selected_parties subset,
|
||||
// explicitly add the new party so it isn't silently dropped from the
|
||||
// submission. Empty selected_parties = "all" → no PATCH needed.
|
||||
const currentSel = state.view.draft.selected_parties ?? [];
|
||||
if (currentSel.length > 0 && !currentSel.includes(newPartyID)) {
|
||||
const next = [...currentSel, newPartyID];
|
||||
try {
|
||||
const patched = await patchDraft({ selected_parties: next });
|
||||
state.view = patched;
|
||||
} catch (err) {
|
||||
console.error("submission-draft select new party:", err);
|
||||
}
|
||||
}
|
||||
|
||||
paintImportRow();
|
||||
paintPartyPicker();
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
}
|
||||
|
||||
async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const draftID = state.view.draft.id;
|
||||
|
||||
@@ -32,18 +32,20 @@ import {
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
|
||||
// view is shareable and survives reload:
|
||||
// ?side=claimant|defendant → swaps which column owns the user's
|
||||
// side (proactive vs reactive label).
|
||||
// Default null = claimant-on-the-left.
|
||||
// ?appellant=claimant|defendant → collapses party=both rows into the
|
||||
// appellant's column (no mirror).
|
||||
// Only meaningful for role-swap
|
||||
// proceedings (Appeal etc.). Default
|
||||
// null = legacy mirror behaviour.
|
||||
// Perspective state. URL-driven so the view is shareable + survives
|
||||
// reload:
|
||||
// ?side=claimant|defendant — swaps which column owns the user's
|
||||
// side (proactive vs reactive label).
|
||||
// Default null = claimant-on-the-left.
|
||||
//
|
||||
// t-paliad-301 / m/paliad#132 collapsed the duplicate ?side= +
|
||||
// ?appellant= selectors into the single proactive-side picker above.
|
||||
// For role-swap proceedings (Appeal / EPA Opposition / DE Revision /
|
||||
// DPMA Appeal) the picker's labels swap to per-proceeding role
|
||||
// strings (Berufungskläger / Berufungsbeklagter, …) via ROLE_LABELS
|
||||
// below — but the underlying claimant/defendant value the engine
|
||||
// consumes is unchanged.
|
||||
let currentSide: Side = null;
|
||||
let currentAppellant: Side = null;
|
||||
|
||||
// Project-driven auto-fill state (t-paliad-279 / m/paliad#111). When the
|
||||
// page is opened with ?project=<id> and that project has our_side set,
|
||||
@@ -52,21 +54,15 @@ let currentAppellant: Side = null;
|
||||
// link, which clears this flag (radio cluster takes over again).
|
||||
let sidePrefilledFromProject = false;
|
||||
|
||||
// Proceedings where one party initiates and "both" rows are role-swap
|
||||
// (i.e. either party files depending on who acted at the lower
|
||||
// instance). For these proceedings the appellant selector is meaningful
|
||||
// — when set, "both" rows collapse to a single row in the appellant's
|
||||
// column. For first-instance proceedings (Inf, Rev, …) the selector is
|
||||
// hidden because there's no appellant axis.
|
||||
//
|
||||
// Today: every upc.apl.* family member plus dpma.appeal.* and
|
||||
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
|
||||
// Conservative — false negatives just hide a control; false positives
|
||||
// would show an irrelevant control.
|
||||
// Role-swap proceedings — the side picker doubles as the appellant
|
||||
// axis. After t-paliad-301 collapsed the duplicate selectors, the
|
||||
// engine reads "appellant" from the single side value for these
|
||||
// proceedings (so a row with primary_party=both renders only in the
|
||||
// chosen side's column). For first-instance proceedings (Inf, Rev,
|
||||
// …) the side picker still narrows columns but doesn't collapse
|
||||
// the "both" rows.
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.merits",
|
||||
"upc.apl.cost",
|
||||
"upc.apl.order",
|
||||
"upc.apl.unified",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
@@ -75,6 +71,67 @@ const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"epa.opp.boa",
|
||||
]);
|
||||
|
||||
// Per-proceeding role labels (t-paliad-301 / m/paliad#132 Bug A).
|
||||
// Mirrors paliad.proceeding_types.role_*_label_* — the canonical
|
||||
// definition lives in the DB; this map is the frontend's view of
|
||||
// it. Proceedings absent from the map fall back to the generic
|
||||
// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys.
|
||||
//
|
||||
// Keep in sync with mig 137's backfill. Adding a row here without a
|
||||
// matching DB row is fine (the DB col is NULL → still falls back to
|
||||
// default; UI shows the override). Adding to the DB without here
|
||||
// means the UI uses defaults — harmless but inconsistent.
|
||||
type RoleLabels = { proDE: string; reDE: string; proEN: string; reEN: string };
|
||||
const ROLE_LABELS: Record<string, RoleLabels> = {
|
||||
"upc.apl.unified": {
|
||||
proDE: "Berufungskläger",
|
||||
reDE: "Berufungsbeklagter",
|
||||
proEN: "Appellant",
|
||||
reEN: "Appellee",
|
||||
},
|
||||
"upc.rev.cfi": {
|
||||
proDE: "Antragsteller (Nichtigkeit)",
|
||||
reDE: "Antragsgegner (Nichtigkeit)",
|
||||
proEN: "Revocation claimant",
|
||||
reEN: "Revocation defendant",
|
||||
},
|
||||
"epa.opp.opd": {
|
||||
proDE: "Einsprechende(r)",
|
||||
reDE: "Patentinhaber(in)",
|
||||
proEN: "Opponent",
|
||||
reEN: "Patentee",
|
||||
},
|
||||
"epa.opp.boa": {
|
||||
proDE: "Einsprechende(r)",
|
||||
reDE: "Patentinhaber(in)",
|
||||
proEN: "Opponent",
|
||||
reEN: "Patentee",
|
||||
},
|
||||
};
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
||||
// Proceedings that surface the appeal-target chip group. Currently
|
||||
// only the unified upc.apl proceeding; future variants (e.g. de.apl)
|
||||
// can opt in by adding the code here.
|
||||
const APPEAL_TARGET_PROCEEDINGS = new Set([
|
||||
"upc.apl.unified",
|
||||
]);
|
||||
|
||||
// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered
|
||||
// in sync with pkg/litigationplanner/types.go AppealTargets).
|
||||
const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
function hasAppealTarget(proceedingType: string): boolean {
|
||||
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
@@ -84,11 +141,6 @@ function readSideFromURL(): Side {
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function readAppellantFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("appellant");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (s === null) url.searchParams.delete("side");
|
||||
@@ -96,13 +148,59 @@ function writeSideToURL(s: Side) {
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
function writeAppellantToURL(a: Side) {
|
||||
// t-paliad-301 / m/paliad#132: applies ROLE_LABELS to the side-row
|
||||
// radio labels for the currently selected proceeding. Proceedings
|
||||
// without an entry fall back to the existing
|
||||
// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys.
|
||||
function applyRoleLabels(proceedingType: string) {
|
||||
const lang = getLang() === "en" ? "en" : "de";
|
||||
const claimantSpan = document.querySelector<HTMLElement>(
|
||||
"input[type=radio][name=side][value=claimant] + span"
|
||||
);
|
||||
const defendantSpan = document.querySelector<HTMLElement>(
|
||||
"input[type=radio][name=side][value=defendant] + span"
|
||||
);
|
||||
if (!claimantSpan || !defendantSpan) return;
|
||||
|
||||
const labels = ROLE_LABELS[proceedingType];
|
||||
if (labels) {
|
||||
claimantSpan.textContent = lang === "en" ? labels.proEN : labels.proDE;
|
||||
defendantSpan.textContent = lang === "en" ? labels.reEN : labels.reDE;
|
||||
} else {
|
||||
// Default — let i18n drive via data-i18n attribute. Reset to the
|
||||
// canonical i18n value so a previous override doesn't stick when
|
||||
// switching from upc.apl.unified back to upc.inf.cfi.
|
||||
claimantSpan.textContent = t("deadlines.side.claimant");
|
||||
defendantSpan.textContent = t("deadlines.side.defendant");
|
||||
}
|
||||
}
|
||||
|
||||
// Slice B1 — appeal-target URL state. Empty string = no target picked
|
||||
// (the row is hidden because the proceeding isn't an appeal). Any
|
||||
// other value must be one of APPEAL_TARGETS; unknown values are
|
||||
// rejected by readAppealTargetFromURL so a stale link can't break
|
||||
// the engine filter.
|
||||
function readAppealTargetFromURL(): AppealTarget {
|
||||
const raw = new URLSearchParams(window.location.search).get("target") || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function writeAppealTargetToURL(t: AppealTarget) {
|
||||
const url = new URL(window.location.href);
|
||||
if (a === null) url.searchParams.delete("appellant");
|
||||
else url.searchParams.set("appellant", a);
|
||||
if (t === "") url.searchParams.delete("target");
|
||||
else url.searchParams.set("target", t);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Default target on first picker entry into upc.apl. m: Endentscheidung
|
||||
// is the most-common appeal target; the chip group also defaults
|
||||
// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in
|
||||
// sync so the URL-less default render hits the same code path.
|
||||
let currentAppealTarget: AppealTarget = "";
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
@@ -143,6 +241,25 @@ function writeChoicesToURL(choices: EventChoice[]) {
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
|
||||
// calculator re-surfaces cards whose submission_code is in the active
|
||||
// skipRules set; they render faded with a "Wieder einblenden" chip.
|
||||
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
|
||||
// the visibility. Default OFF — m's not asking to see hidden by
|
||||
// default, just to be able to.
|
||||
function readShowHiddenFromURL(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
|
||||
}
|
||||
|
||||
function writeShowHiddenToURL(on: boolean) {
|
||||
const url = new URL(window.location.href);
|
||||
if (on) url.searchParams.set("show_hidden", "1");
|
||||
else url.searchParams.delete("show_hidden");
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
let showHidden = readShowHiddenFromURL();
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
@@ -159,6 +276,21 @@ function writeNotesPref(on: boolean): void {
|
||||
}
|
||||
let showNotes = readNotesPref();
|
||||
|
||||
// Durations toggle (m/paliad#133, t-paliad-302) — when off (default),
|
||||
// the per-rule duration label ("2 Mo. nach") only shows on hover via
|
||||
// the date span's `title` attribute. When on, the label renders inline
|
||||
// in the timeline meta row of every event card. Persisted in
|
||||
// localStorage under its own key so the preference is independent of
|
||||
// "Hinweise anzeigen".
|
||||
const DURATIONS_PREF_KEY = "paliad.verfahrensablauf.durations-show";
|
||||
function readDurationsPref(): boolean {
|
||||
try { return localStorage.getItem(DURATIONS_PREF_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
function writeDurationsPref(on: boolean): void {
|
||||
try { localStorage.setItem(DURATIONS_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
||||
}
|
||||
let showDurations = readDurationsPref();
|
||||
|
||||
// Jurisdiction display prefix for the proceeding-summary chip + the
|
||||
// trigger-event placeholder. Same forum slugs the .proceeding-group
|
||||
// `data-forum` attribute carries in verfahrensablauf.tsx /
|
||||
@@ -249,6 +381,13 @@ async function doCalc() {
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1): for the unified upc.apl Berufung,
|
||||
// default to "endentscheidung" when no chip pick is stored in URL.
|
||||
// For non-appeal proceedings the engine ignores opts.AppealTarget.
|
||||
const appealTarget = hasAppealTarget(selectedType)
|
||||
? (currentAppealTarget || "endentscheidung")
|
||||
: "";
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
@@ -256,14 +395,34 @@ async function doCalc() {
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
perCardChoices,
|
||||
includeHidden: showHidden,
|
||||
appealTarget,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
lastResponse = data;
|
||||
renderResults(data);
|
||||
syncHiddenBadge(data.hiddenCount ?? 0);
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
// syncHiddenBadge updates the "Ausgeblendete (N)" count next to the
|
||||
// toggle. Visible regardless of toggle state so the user knows whether
|
||||
// there's anything to re-surface even when the toggle is OFF. Hides the
|
||||
// whole row when the projection has zero hidden cards — no clutter on
|
||||
// a project that's never used the skip feature. (t-paliad-290)
|
||||
function syncHiddenBadge(count: number) {
|
||||
const row = document.getElementById("show-hidden-row");
|
||||
const badge = document.getElementById("show-hidden-count");
|
||||
if (!row || !badge) return;
|
||||
if (count <= 0) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
badge.textContent = tDyn("choices.show_hidden.count").replace("{n}", String(count));
|
||||
}
|
||||
|
||||
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||
// label from the calc response. Precedence:
|
||||
//
|
||||
@@ -338,10 +497,16 @@ function renderResults(data: DeadlineResponse) {
|
||||
? renderColumnsBody(data, {
|
||||
editable: true,
|
||||
showNotes,
|
||||
showDurations,
|
||||
side: currentSide,
|
||||
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
|
||||
// t-paliad-301: the appellant axis collapses into the single
|
||||
// side picker. For role-swap proceedings, currentSide IS the
|
||||
// appellant pick (so a row with primary_party=both renders only
|
||||
// in the picked side's column). For non-role-swap proceedings,
|
||||
// the appellant axis is irrelevant — pass null.
|
||||
appellant: hasAppellantAxis(selectedType) ? currentSide : null,
|
||||
})
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations });
|
||||
|
||||
container.innerHTML = headerHtml + noteHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
@@ -408,7 +573,8 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppellantRowVisibility();
|
||||
syncAppealTargetRowVisibility();
|
||||
applyRoleLabels(selectedType);
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
@@ -416,20 +582,20 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
scheduleCalc(0);
|
||||
}
|
||||
|
||||
// syncAppellantRowVisibility hides the appellant selector for
|
||||
// proceedings that have no appellant axis (first-instance Inf, Rev,
|
||||
// …). Clears the in-memory state and the URL param when hidden so a
|
||||
// shared link with ?appellant= doesn't leak into an unrelated
|
||||
// proceeding's render.
|
||||
function syncAppellantRowVisibility() {
|
||||
const row = document.getElementById("appellant-row");
|
||||
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
||||
// syncAppealTargetRowVisibility shows the appeal-target chip group
|
||||
// when the unified upc.apl Berufung tile is selected, hides it
|
||||
// otherwise. Mirrors syncAppellantRowVisibility's pattern: clears
|
||||
// state + URL when hiding so a stale ?target= can't leak.
|
||||
function syncAppealTargetRowVisibility() {
|
||||
const row = document.getElementById("appeal-target-row");
|
||||
if (!row) return;
|
||||
const visible = hasAppellantAxis(selectedType);
|
||||
const visible = hasAppealTarget(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppellant !== null) {
|
||||
currentAppellant = null;
|
||||
writeAppellantToURL(null);
|
||||
syncRadioGroup("appellant", "");
|
||||
if (!visible && currentAppealTarget !== "") {
|
||||
currentAppealTarget = "";
|
||||
writeAppealTargetToURL("");
|
||||
syncRadioGroup("appeal-target", "endentscheidung");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,7 +663,17 @@ async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide |
|
||||
function sideLabelI18n(s: Side): string {
|
||||
if (s === "claimant") return t("deadlines.side.claimant");
|
||||
if (s === "defendant") return t("deadlines.side.defendant");
|
||||
return t("deadlines.side.both");
|
||||
return t("deadlines.side.undefined");
|
||||
}
|
||||
|
||||
// syncSideHintVisibility shows the "pick a side" hint chip only while
|
||||
// currentSide is unset (m/paliad#120). When the user has picked
|
||||
// claimant / defendant the columns are already focused, so the prompt
|
||||
// would be misleading.
|
||||
function syncSideHintVisibility() {
|
||||
const hint = document.getElementById("side-hint");
|
||||
if (!hint) return;
|
||||
hint.style.display = currentSide === null ? "" : "none";
|
||||
}
|
||||
|
||||
// renderSideChip swaps the radio cluster for a read-only chip showing
|
||||
@@ -521,6 +697,9 @@ function showSideRadioCluster() {
|
||||
if (!cluster || !chip) return;
|
||||
cluster.style.display = "";
|
||||
chip.style.display = "none";
|
||||
// Cluster re-appears after override → re-evaluate hint visibility so
|
||||
// we don't leave a stale "pick a side" prompt above a checked radio.
|
||||
syncSideHintVisibility();
|
||||
}
|
||||
|
||||
// applySidePrefill takes a project's our_side, maps it to the side axis,
|
||||
@@ -603,9 +782,10 @@ function initViewToggle() {
|
||||
// projection of the last response, no backend involved.
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppellant = readAppellantFromURL();
|
||||
currentAppealTarget = readAppealTargetFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
|
||||
syncSideHintVisibility();
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
@@ -613,17 +793,25 @@ function initPerspectiveControls() {
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
syncSideHintVisibility();
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
|
||||
// Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler.
|
||||
// Each chip change re-fetches with the new target slug so the
|
||||
// timeline re-renders against the matching rule subset.
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appeal-target]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeAppellantToURL(currentAppellant);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(v)) {
|
||||
currentAppealTarget = v as AppealTarget;
|
||||
} else {
|
||||
currentAppealTarget = "";
|
||||
}
|
||||
writeAppealTargetToURL(currentAppealTarget);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -696,6 +884,33 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Durations toggle (m/paliad#133, t-paliad-302) — sibling of the
|
||||
// notes toggle. Hover-only labels (default) become inline labels when
|
||||
// the user opts in.
|
||||
const durationsShowCb = document.getElementById("verfahrensablauf-durations-show") as HTMLInputElement | null;
|
||||
if (durationsShowCb) {
|
||||
durationsShowCb.checked = showDurations;
|
||||
durationsShowCb.addEventListener("change", () => {
|
||||
showDurations = durationsShowCb.checked;
|
||||
writeDurationsPref(showDurations);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
|
||||
// to URL + recalc (the backend reshapes the response — we can't just
|
||||
// re-render lastResponse since the hidden rows aren't in it when the
|
||||
// toggle was OFF).
|
||||
const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null;
|
||||
if (showHiddenCb) {
|
||||
showHiddenCb.checked = showHidden;
|
||||
showHiddenCb.addEventListener("change", () => {
|
||||
showHidden = showHiddenCb.checked;
|
||||
writeShowHiddenToURL(showHidden);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
|
||||
@@ -74,10 +74,11 @@ export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
|
||||
states.set(opts.container, state);
|
||||
|
||||
opts.container.addEventListener("click", (e) => {
|
||||
const target = (e.target as HTMLElement | null)?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (target) {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (caret) {
|
||||
e.stopPropagation();
|
||||
openPopover(state, target);
|
||||
openPopover(state, caret);
|
||||
return;
|
||||
}
|
||||
// Outside-click closes the popover.
|
||||
@@ -158,6 +159,7 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const isHidden = caret.dataset.isHidden === "1";
|
||||
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "event-card-choices-popover";
|
||||
@@ -165,6 +167,15 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
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[]));
|
||||
}
|
||||
@@ -259,6 +270,23 @@ function renderToggleBlock(state: AttachedState, code: string, kind: "include_cc
|
||||
</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();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
renderColumnsBody,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
@@ -67,6 +69,153 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-293 (m/paliad#125): the "Wieder einblenden" affordance
|
||||
// moved from an inline chip in the card header into the caret popover
|
||||
// to fix horizontal-scroll on narrow viewports (the long German label
|
||||
// pushed the card past its column width). The renderer now signals
|
||||
// hidden state two ways: (1) a 👁⃠ state-icon in the title row and
|
||||
// (2) data-is-hidden="1" on the caret button so event-card-choices.ts
|
||||
// can surface the prominent "Wieder einblenden" popover entry when
|
||||
// the user opens the menu. The legacy `.event-card-choices-unhide`
|
||||
// inline chip class must NOT appear in the output.
|
||||
describe("deadlineCardHtml — isHidden surfaces state-icon + caret hint (t-paliad-293)", () => {
|
||||
test("isHidden=true emits the hidden state-icon", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).toContain("timeline-state-icon--hidden");
|
||||
});
|
||||
|
||||
test("isHidden=true with choicesOffered.skip annotates the caret with data-is-hidden=\"1\"", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).toContain('data-is-hidden="1"');
|
||||
expect(html).toContain("event-card-choices-caret");
|
||||
});
|
||||
|
||||
test("isHidden=false (default) suppresses the state-icon and reports data-is-hidden=\"0\"", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).not.toContain("timeline-state-icon--hidden");
|
||||
expect(html).toContain('data-is-hidden="0"');
|
||||
});
|
||||
|
||||
test("isHidden=true with empty choicesOffered still emits caret with synthesized skip offer (defensive)", () => {
|
||||
// Edge case: admin edits the rule's choices_offered after a user
|
||||
// has already saved a `skip=true` choice. Without the fallback
|
||||
// the card would re-surface as hidden with no popover entrypoint
|
||||
// — the user would have no way to un-hide it. The renderer
|
||||
// synthesizes a `{skip:[true,false]}` offer so the prominent
|
||||
// "Wieder einblenden" button still renders in the popover.
|
||||
const html = deadlineCardHtml(dl({ isHidden: true }), { showParty: true });
|
||||
expect(html).toContain("event-card-choices-caret");
|
||||
expect(html).toContain('data-is-hidden="1"');
|
||||
expect(html).toContain("data-choices-offered=\"{"skip":[true,false]}\"");
|
||||
});
|
||||
|
||||
test("isHidden=false with empty choicesOffered suppresses caret (regression guard)", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).not.toContain("event-card-choices-caret");
|
||||
});
|
||||
|
||||
test("legacy inline `.event-card-choices-unhide` class is no longer emitted", () => {
|
||||
// Pinned to catch a regression that would re-introduce the
|
||||
// horizontal-scroll surface that motivated the move. The popover
|
||||
// now uses `.event-card-choices-unhide-btn` (with the -btn suffix)
|
||||
// inside the body-attached popover dom node — never in the card
|
||||
// header HTML the renderer returns.
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).not.toContain('class="event-card-choices-unhide"');
|
||||
expect(html).not.toMatch(/event-card-choices-unhide(?!-btn)/);
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-293: the `optional` priority used to render an inline text
|
||||
// badge in the card title. The overhaul replaces it with a ⊙ state
|
||||
// icon so the title row stays compact on narrow viewports. Tooltip is
|
||||
// driven by the `state.optional.tooltip` i18n key.
|
||||
describe("deadlineCardHtml — optional priority renders the state icon (t-paliad-293)", () => {
|
||||
test("priority='optional' emits the timeline-state-icon--optional marker", () => {
|
||||
const html = deadlineCardHtml(dl({ priority: "optional" }), { showParty: true });
|
||||
expect(html).toContain("timeline-state-icon--optional");
|
||||
expect(html).not.toContain("optional-badge");
|
||||
});
|
||||
|
||||
test("priority='mandatory' (default) omits the optional marker", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).not.toContain("timeline-state-icon--optional");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-289 — isConditional rules render an "abhängig von <parent>"
|
||||
// chip in place of the date column, and the chip keeps the click-to-edit
|
||||
// affordance so the user can pin a real date once the upstream anchor
|
||||
// resolves (oral hearing scheduled, opposing party's motion received, …).
|
||||
// Mirrors Symptom A (R.109(1) backward-anchor without oral-hearing date)
|
||||
// and Symptom B (R.262(2) without recorded Vertraulichkeitsantrag) from
|
||||
// the issue.
|
||||
describe("deadlineCardHtml — isConditional rendering (t-paliad-289)", () => {
|
||||
test("isConditional + parentRuleName emits 'abhängig von <parent>' chip with click-to-edit", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({
|
||||
code: "upc.inf.cfi.translation_request",
|
||||
isConditional: true,
|
||||
parentRuleCode: "upc.inf.cfi.oral",
|
||||
parentRuleName: "Mündliche Verhandlung",
|
||||
}),
|
||||
{ showParty: true, editable: true },
|
||||
);
|
||||
expect(html).toContain("timeline-conditional");
|
||||
expect(html).toContain("abhängig von Mündliche Verhandlung");
|
||||
expect(html).toContain('data-rule-code="upc.inf.cfi.translation_request"');
|
||||
expect(html).toContain('role="button"');
|
||||
expect(html).not.toContain("timeline-court-set");
|
||||
});
|
||||
|
||||
test("isConditional with no parentRuleName falls back to generic upstream-event label", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isConditional: true }),
|
||||
{ showParty: true, editable: true },
|
||||
);
|
||||
expect(html).toContain("timeline-conditional");
|
||||
expect(html).toContain("abhängig von vorgelagertem Ereignis");
|
||||
});
|
||||
|
||||
test("isConditional wins over isCourtSet — overlapping cases render conditional chip", () => {
|
||||
// Court-set ancestor without override sets BOTH isCourtSet=true AND
|
||||
// isConditional=true on the wire. The renderer must pick the
|
||||
// conditional chip; otherwise the row keeps the legacy "wird vom
|
||||
// Gericht bestimmt" label and the user can't see WHICH upstream
|
||||
// event blocks them.
|
||||
const html = deadlineCardHtml(
|
||||
dl({
|
||||
isConditional: true,
|
||||
isCourtSet: true,
|
||||
isCourtSetIndirect: true,
|
||||
parentRuleName: "Entscheidung",
|
||||
}),
|
||||
{ showParty: true, editable: true },
|
||||
);
|
||||
expect(html).toContain("abhängig von Entscheidung");
|
||||
expect(html).not.toContain("timeline-court-set");
|
||||
});
|
||||
|
||||
test("isConditional=false keeps the normal date span (regression guard)", () => {
|
||||
const html = deadlineCardHtml(dl({ isConditional: false }), { showParty: true });
|
||||
expect(html).toContain("timeline-date");
|
||||
expect(html).not.toContain("timeline-conditional");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||
@@ -178,6 +327,29 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=defendant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
|
||||
// When the user has committed to a perspective via `?side=`, the
|
||||
// mirror is visual noise: the same card renders twice on one row,
|
||||
// once in 'Unsere Seite' and once in 'Gegnerseite'. The card's
|
||||
// '↔ beide Seiten' indicator already conveys the both-parties
|
||||
// semantic, so collapsing into ours is sufficient.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
|
||||
{ side: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=claimant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
|
||||
{ side: "claimant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
|
||||
const sameDate = "2026-07-23";
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
@@ -245,4 +417,73 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
["Decision"],
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// m's correction in m/paliad#127 (t-paliad-295) reverted half of #88's
|
||||
// header refresh: the user-perspective labels "Unsere Seite"/"Gegnerseite"
|
||||
// only make sense once the user has picked a side. While the side is
|
||||
// still "Nicht festgelegt" (side === null — the default after #120) the
|
||||
// header falls back to the semantic-neutral "Proaktiv"/"Reaktiv" labels.
|
||||
// Picking a side re-enables the #88 labels. The bucketing primitive
|
||||
// itself is unchanged — only the column-header text differs.
|
||||
describe("renderColumnsBody — side-aware column header labels (m/paliad#127)", () => {
|
||||
const dlFix = (party: string, name: string, due: string): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party,
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
});
|
||||
const data: DeadlineResponse = {
|
||||
proceedingType: "upc.inf.cfi",
|
||||
proceedingName: "UPC Verletzungsverfahren",
|
||||
triggerDate: "2026-01-01",
|
||||
deadlines: [
|
||||
dlFix("claimant", "Klageschrift", "2026-01-01"),
|
||||
dlFix("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
],
|
||||
};
|
||||
|
||||
test("side=null renders Proaktiv/Gericht/Reaktiv headers", () => {
|
||||
const html = renderColumnsBody(data, { side: null });
|
||||
expect(html).toContain(">Proaktiv<");
|
||||
expect(html).toContain(">Gericht<");
|
||||
expect(html).toContain(">Reaktiv<");
|
||||
expect(html).not.toContain(">Unsere Seite<");
|
||||
expect(html).not.toContain(">Gegnerseite<");
|
||||
});
|
||||
|
||||
test("side=null when opts omitted (default) still renders Proaktiv/Reaktiv", () => {
|
||||
const html = renderColumnsBody(data);
|
||||
expect(html).toContain(">Proaktiv<");
|
||||
expect(html).toContain(">Reaktiv<");
|
||||
});
|
||||
|
||||
test("side=claimant renders Unsere Seite/Gericht/Gegnerseite headers", () => {
|
||||
const html = renderColumnsBody(data, { side: "claimant" });
|
||||
expect(html).toContain(">Unsere Seite<");
|
||||
expect(html).toContain(">Gericht<");
|
||||
expect(html).toContain(">Gegnerseite<");
|
||||
expect(html).not.toContain(">Proaktiv<");
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
|
||||
test("side=defendant renders Unsere Seite/Gegnerseite headers (column swap is bucketing, not labels)", () => {
|
||||
// The user-perspective labels are picked once a side is set; the
|
||||
// bucketer still routes defendant filings into the `ours` column when
|
||||
// side=defendant, so the left column's header truthfully reads
|
||||
// "Unsere Seite" regardless of which underlying party occupies it.
|
||||
const html = renderColumnsBody(data, { side: "defendant" });
|
||||
expect(html).toContain(">Unsere Seite<");
|
||||
expect(html).toContain(">Gegnerseite<");
|
||||
expect(html).not.toContain(">Proaktiv<");
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,59 @@ export interface CalculatedDeadline {
|
||||
// page-level appellant axis still applies in that case). The bucketer
|
||||
// reads this in preference to the page-level appellant.
|
||||
appellantContext?: string;
|
||||
// isHidden (t-paliad-290 / m/paliad#122): server-side flag set when
|
||||
// a previously-hidden card is re-surfaced via the "Ausgeblendete
|
||||
// anzeigen" toggle. The renderer fades the card and exposes an
|
||||
// inline "Wieder einblenden" chip that deletes the skip choice.
|
||||
isHidden?: boolean;
|
||||
// isConditional (t-paliad-289): the rule's anchor is uncertain, so
|
||||
// no concrete date is projected. Set by the calculator when the rule
|
||||
// depends on a court-set ancestor without override, when a backward-
|
||||
// anchored rule's forward anchor isn't set, or for optional rules
|
||||
// whose true triggering event sits outside the rule data (e.g.
|
||||
// R.262(2) Erwiderung auf Vertraulichkeitsantrag — anchored on SoC
|
||||
// in the data, but the real trigger is the opposing party's
|
||||
// confidentiality motion). The renderer drops the date column entry
|
||||
// and shows an "abhängig von <parentRuleName>" chip instead.
|
||||
isConditional?: boolean;
|
||||
// parentRuleCode / parentRuleName / parentRuleNameEN surface the
|
||||
// parent rule's identity so the renderer can label the
|
||||
// "abhängig von <parent>" chip on conditional rows. Populated for
|
||||
// every rule with a parent (not just conditional ones), so the
|
||||
// dependency-footer logic can reuse it. Empty for root rules.
|
||||
parentRuleCode?: string;
|
||||
parentRuleName?: string;
|
||||
parentRuleNameEN?: string;
|
||||
// durationValue / durationUnit / timing surface the rule's arithmetic
|
||||
// so the timeline card can show "2 Mo. nach" on hover (and inline when
|
||||
// the "Dauern anzeigen" toggle is on). Zero-duration rules (root
|
||||
// event, court-set) carry durationValue=0 and the renderer suppresses
|
||||
// the affordance — those don't have an explainable interval.
|
||||
// (m/paliad#133, t-paliad-302)
|
||||
durationValue?: number;
|
||||
durationUnit?: string;
|
||||
timing?: string;
|
||||
}
|
||||
|
||||
// formatDurationLabel renders the per-rule duration ("2 Mo. nach") for
|
||||
// the Verfahrensablauf card affordance (m/paliad#133, t-paliad-302).
|
||||
// Returns empty string for rules without a usable duration so the
|
||||
// caller can skip the tooltip / inline span entirely.
|
||||
//
|
||||
// Pluralisation key naming mirrors the Fristenrechner event-mode
|
||||
// renderer (deadlines.event.unit.<unit>.{one,many}) — the unit and
|
||||
// timing translations already exist for /tools/fristenrechner's
|
||||
// "Was kommt nach…" mode and are reused here as the single
|
||||
// source of truth.
|
||||
export function formatDurationLabel(dl: CalculatedDeadline): string {
|
||||
const value = dl.durationValue ?? 0;
|
||||
const unit = dl.durationUnit || "";
|
||||
if (value <= 0 || !unit) return "";
|
||||
const unitKey = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many");
|
||||
const unitStr = tDyn(unitKey);
|
||||
const timing = dl.timing || "";
|
||||
const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : "";
|
||||
return timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
|
||||
}
|
||||
|
||||
// priorityRendering returns the per-priority UX hints the save-modal
|
||||
@@ -131,6 +184,13 @@ export interface DeadlineResponse {
|
||||
// (m/paliad#81)
|
||||
triggerEventLabel?: string;
|
||||
triggerEventLabelEN?: string;
|
||||
// hiddenCount (t-paliad-290 / m/paliad#122): number of rules that
|
||||
// would have been hidden in this projection (i.e. their
|
||||
// submission_code is in skipRules and they passed the condition_expr
|
||||
// gate). Surfaces the "Ausgeblendete (N)" badge on the toggle even
|
||||
// when the toggle is OFF — so users know there's something to
|
||||
// re-surface.
|
||||
hiddenCount?: number;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -160,6 +220,17 @@ export interface CalcParams {
|
||||
choice_kind: string;
|
||||
choice_value: string;
|
||||
}>;
|
||||
// includeHidden (t-paliad-290): when true the calculator returns
|
||||
// previously-skipped rules as faded cards instead of dropping them.
|
||||
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
|
||||
// ON.
|
||||
includeHidden?: boolean;
|
||||
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung
|
||||
// (upc.apl) timeline to the rule subset whose applies_to_target
|
||||
// contains the requested slug. Empty = no filter. Valid values:
|
||||
// endentscheidung | kostenentscheidung | anordnung |
|
||||
// schadensbemessung | bucheinsicht.
|
||||
appealTarget?: string;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -175,10 +246,20 @@ export function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// Pure-string HTML escape — keeps the module testable in bun test
|
||||
// (plain Node, no jsdom). Used to be backed by document.createElement,
|
||||
// which forced fixtures to leave any field that flowed through it
|
||||
// empty just to exercise unrelated branches; the regex form is safe
|
||||
// for arbitrary text including the per-rule name strings that the
|
||||
// conditional-row chip ("abhängig von <parent>") now exposes.
|
||||
// (t-paliad-289)
|
||||
export function escHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
@@ -270,37 +351,108 @@ export interface CardOpts {
|
||||
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
|
||||
// re-renders. Default false — notes are noisy on long timelines.
|
||||
showNotes?: boolean;
|
||||
// showDurations controls per-rule duration rendering on event cards
|
||||
// (m/paliad#133, t-paliad-302):
|
||||
// true → inline `<span class="timeline-duration">2 Mo. nach</span>`
|
||||
// next to the date.
|
||||
// false → hover-only tooltip on the date span (browser-native
|
||||
// `title` attribute). Cards without a usable
|
||||
// `durationValue > 0` get neither — court-set and trigger-
|
||||
// event cards have no explainable interval.
|
||||
// /tools/verfahrensablauf exposes a toggle ("Dauern anzeigen") that
|
||||
// flips this and re-renders; persisted via the localStorage key
|
||||
// `paliad.verfahrensablauf.durations-show`. Default false.
|
||||
showDurations?: boolean;
|
||||
}
|
||||
|
||||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||||
const wantsEditable = !!opts.editable;
|
||||
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
|
||||
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
|
||||
// Duration affordance (m/paliad#133, t-paliad-302). Computed once so
|
||||
// both the date-span tooltip and the inline meta-row span pull from
|
||||
// the same string. Empty for rules without a usable duration.
|
||||
const durationLabel = formatDurationLabel(dl);
|
||||
// Hover affordance on the date span: prefer the duration tooltip when
|
||||
// we have one, else fall back to the edit-hint when the cell is
|
||||
// click-to-edit. The edit affordance still works either way — the
|
||||
// title is purely advisory.
|
||||
const dateTitle = durationLabel
|
||||
? durationLabel
|
||||
: (editable ? t("deadlines.date.edit.hint") : "");
|
||||
const editAttrs = editable
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
|
||||
: "";
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
const dateStr = dl.isCourtSet
|
||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0"${dateTitle ? ` title="${escAttr(dateTitle)}"` : ""}`
|
||||
: (dateTitle ? ` title="${escAttr(dateTitle)}"` : "");
|
||||
// Conditional rows (t-paliad-289) replace the date column with an
|
||||
// "abhängig von <parent>" chip. The chip remains click-to-edit so
|
||||
// the user can pin a real date once known (e.g. once the oral
|
||||
// hearing date is set, or the opposing party's Vertraulichkeits-
|
||||
// antrag arrives) — the same data-rule-code wiring fires the
|
||||
// existing inline date editor. IsConditional wins over IsCourtSet:
|
||||
// they overlap (court-set ancestor without override produces both),
|
||||
// and "abhängig von <parent>" is the clearer user-facing signal.
|
||||
const parentLabel = (getLang() === "en"
|
||||
? (dl.parentRuleNameEN || dl.parentRuleName)
|
||||
: dl.parentRuleName) || "";
|
||||
let dateStr: string;
|
||||
if (dl.isConditional) {
|
||||
const chipText = parentLabel
|
||||
? tDyn("deadlines.conditional.depends_on").replace("{parent}", escHtml(parentLabel))
|
||||
: t("deadlines.conditional.unset");
|
||||
dateStr = `<span class="timeline-conditional frist-date-edit"${editAttrs}>${chipText}</span>`;
|
||||
} else if (dl.isCourtSet) {
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
dateStr = `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`;
|
||||
} else {
|
||||
dateStr = `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
}
|
||||
|
||||
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
|
||||
// priority directly. Optional badge fires only on 'optional'
|
||||
// priority (RoP.151-style opt-in deadlines).
|
||||
const mandatoryBadge = dl.priority === "optional"
|
||||
? '<span class="optional-badge">optional</span>'
|
||||
: "";
|
||||
// t-paliad-293 — iconified state markers. The card surface speaks
|
||||
// "cut the tree of possibilities": each card carries 0–N small icons
|
||||
// in the title row that summarise its decision state at a glance.
|
||||
// The text "optional" badge that used to sit inline next to the name
|
||||
// is now a ⊙ icon (state.optional). Hidden cards get a 👁⃠ eye-slash
|
||||
// marker. Conditional cards already have the date-column chip; the
|
||||
// marker is redundant in the title row. CCR-included / appellant
|
||||
// picks remain on the chip row (event-card-choices-chip) — see below.
|
||||
// Tooltips are i18n-driven so they read in the user's language.
|
||||
const stateIcons: string[] = [];
|
||||
if (dl.priority === "optional") {
|
||||
stateIcons.push(
|
||||
`<span class="timeline-state-icon timeline-state-icon--optional" role="img" aria-label="${escAttr(t("state.optional.tooltip"))}" title="${escAttr(t("state.optional.tooltip"))}">⊙</span>`,
|
||||
);
|
||||
}
|
||||
if (dl.isHidden) {
|
||||
stateIcons.push(
|
||||
`<span class="timeline-state-icon timeline-state-icon--hidden" role="img" aria-label="${escAttr(t("state.hidden.tooltip"))}" title="${escAttr(t("state.hidden.tooltip"))}">👁⃠</span>`,
|
||||
);
|
||||
}
|
||||
const stateIconsHtml = stateIcons.join("");
|
||||
|
||||
// t-paliad-265 — caret affordance + chip indicator when this rule
|
||||
// offers per-card choices and the user has made a pick. The popover
|
||||
// open/commit lifecycle lives in client/views/event-card-choices.ts;
|
||||
// the data-* attributes here are the wire contract between the two.
|
||||
const choicesHtml = dl.code !== "" && dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0
|
||||
//
|
||||
// t-paliad-293 — hidden cards always expose the caret so the user
|
||||
// can un-hide via the popover's "Wieder einblenden" entry. Normally
|
||||
// a hidden card was hidden via a skip choice, so `choicesOffered.skip`
|
||||
// is present. Defensive fallback: if a rule's `choices_offered` was
|
||||
// edited away after the skip entry was saved, the user would lose
|
||||
// the un-hide path entirely. Synthesize a `{skip:[true,false]}`
|
||||
// offer for the popover in that edge case so the prominent
|
||||
// "Wieder einblenden" button still renders.
|
||||
const offeredForCaret = (dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0)
|
||||
? dl.choicesOffered
|
||||
: (dl.isHidden ? { skip: [true, false] } : null);
|
||||
const showCaret = dl.code !== "" && offeredForCaret !== null;
|
||||
const choicesHtml = showCaret
|
||||
? `<button type="button" class="event-card-choices-caret"
|
||||
data-submission-code="${escAttr(dl.code)}"
|
||||
data-choices-offered="${escAttr(JSON.stringify(dl.choicesOffered))}"
|
||||
data-choices-offered="${escAttr(JSON.stringify(offeredForCaret))}"
|
||||
data-is-hidden="${dl.isHidden ? "1" : "0"}"
|
||||
aria-label="${escAttr(t("choices.caret.title"))}"
|
||||
title="${escAttr(t("choices.caret.title"))}">▾</button>`
|
||||
: "";
|
||||
@@ -335,9 +487,19 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef || noteHint)
|
||||
// Inline duration affordance (m/paliad#133, t-paliad-302). Only
|
||||
// emitted when the "Dauern anzeigen" toggle is on AND the rule has a
|
||||
// usable duration; the default-off hover-tooltip path is wired
|
||||
// separately on the date span itself.
|
||||
const showDurations = opts.showDurations === true;
|
||||
const durationInline = showDurations && durationLabel
|
||||
? `<span class="timeline-duration">${escHtml(durationLabel)}</span>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef || noteHint || durationInline)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${durationInline}
|
||||
${ruleRef}
|
||||
${noteHint}
|
||||
</div>`
|
||||
@@ -354,7 +516,7 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
return `<div class="timeline-item-header">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${mandatoryBadge}
|
||||
${stateIconsHtml}
|
||||
${chipHtml}
|
||||
</span>
|
||||
${dateStr}
|
||||
@@ -449,8 +611,20 @@ export function wireDateEditClicks(
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
const itemClasses = [
|
||||
"timeline-item",
|
||||
dl.isRootEvent ? "timeline-root" : "",
|
||||
// t-paliad-290: re-surfaced hidden cards render faded via the
|
||||
// shared timeline-item--hidden modifier (same modifier the columns
|
||||
// view uses; see fr-col-item--hidden below).
|
||||
dl.isHidden ? "timeline-item--hidden" : "",
|
||||
// t-paliad-289: dotted-border + faded styling for conditional rows
|
||||
// so the "abhängig von <parent>" state is visually distinct from
|
||||
// both anchored deadlines and direct court-set rows.
|
||||
dl.isConditional ? "timeline-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
html += `
|
||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||||
<div class="${itemClasses}">
|
||||
<div class="timeline-dot-col">
|
||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||
<div class="timeline-line"></div>
|
||||
@@ -503,6 +677,9 @@ type ColumnPosition = "ours" | "opponent";
|
||||
export interface ColumnsBodyOpts {
|
||||
editable?: boolean;
|
||||
showNotes?: boolean;
|
||||
// Forwarded to deadlineCardHtml — see CardOpts.showDurations.
|
||||
// (m/paliad#133, t-paliad-302)
|
||||
showDurations?: boolean;
|
||||
// side: which side the user is on. Drives column placement;
|
||||
// does NOT filter rows. Default null = claimant-on-the-left
|
||||
// (i.e. "ours = claimant", legacy default).
|
||||
@@ -587,7 +764,18 @@ export function bucketDeadlinesIntoColumns(
|
||||
// Role-swap collapse: appellant initiated → both → one row
|
||||
// in appellant's column. Mirror suppressed.
|
||||
row[appellantColumn].push(dl);
|
||||
} else if (userSide !== null) {
|
||||
// Side picked but no appellant axis (first-instance Inf, Rev,
|
||||
// …): the user has committed to a perspective, so the mirror
|
||||
// is visual noise — the same card appears twice on the same
|
||||
// row, once in "Unsere Seite" and once in "Gegnerseite".
|
||||
// Collapse into ours; the "↔ beide Seiten" indicator on the
|
||||
// card already conveys that the rule applies to both parties.
|
||||
// (m/paliad#135 / t-paliad-304)
|
||||
row.ours.push(dl);
|
||||
} else {
|
||||
// No perspective picked → keep the legacy mirror so neither
|
||||
// axis is privileged. Pinned by the "default (no opts)" test.
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
}
|
||||
@@ -613,12 +801,23 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
|
||||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
const cardOpts: CardOpts = {
|
||||
showParty: false,
|
||||
editable: opts.editable,
|
||||
showNotes: opts.showNotes,
|
||||
showDurations: opts.showDurations,
|
||||
};
|
||||
|
||||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||||
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
|
||||
// be misleading. Keep it for the legacy mirror path.
|
||||
const showMirrorTag = !appellantPinned;
|
||||
// be misleading. Both collapse paths suppress it:
|
||||
// - appellantPinned: role-swap collapse into appellant's column
|
||||
// - userSide !== null without appellantPinned: perspective-locked
|
||||
// collapse into ours (m/paliad#135 / t-paliad-304).
|
||||
// Legacy mirror path (no side, no appellant) keeps the tag — both
|
||||
// sibling rows still render so the tag has a visual referent.
|
||||
const sideCollapse = userSide !== null;
|
||||
const showMirrorTag = !appellantPinned && !sideCollapse;
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
@@ -629,7 +828,17 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const mirrorTag = showMirrorTag && dl.party === "both"
|
||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
const itemClasses = [
|
||||
"fr-col-item",
|
||||
dl.isRootEvent ? "fr-col-root" : "",
|
||||
// t-paliad-290: re-surfaced hidden cards render faded via the
|
||||
// shared fr-col-item--hidden modifier.
|
||||
dl.isHidden ? "fr-col-item--hidden" : "",
|
||||
// t-paliad-289: same conditional treatment as the linear
|
||||
// timeline-item — dotted border + faded styling.
|
||||
dl.isConditional ? "fr-col-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return `<div class="${itemClasses}">
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
@@ -641,14 +850,29 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
// Column-header labels have two modes (m/paliad#127):
|
||||
// - side picked → "Unsere Seite" / "Gegnerseite" (the columns
|
||||
// truthfully describe whose filings sit there,
|
||||
// because the bucketer routed the user's side into
|
||||
// `ours`).
|
||||
// - side === null → "Proaktiv" / "Reaktiv" (semantic-neutral). The
|
||||
// user-perspective labels would lie here: we don't
|
||||
// know yet which party is "us", so calling the left
|
||||
// column "Unsere Seite" presumes a pick the user
|
||||
// hasn't made. The neutral Proaktiv/Reaktiv pair
|
||||
// keeps the spatial axis ("who initiates vs who
|
||||
// responds") legible while the hint chip on the
|
||||
// page nudges the user to pick a side.
|
||||
//
|
||||
// Note: the COLUMN PROJECTION does not change — the bucketing primitive
|
||||
// still routes claimant→left, defendant→right when side=null (legacy
|
||||
// claimant-on-the-left fallback). Only the HEADER label changes.
|
||||
const leftLabel = userSide === null ? t("deadlines.col.proactive") : t("deadlines.col.ours");
|
||||
const rightLabel = userSide === null ? t("deadlines.col.reactive") : t("deadlines.col.opponent");
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(leftLabel, "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
html += headerCell(rightLabel, "fr-col-opponent");
|
||||
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.ours);
|
||||
@@ -680,6 +904,8 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
|
||||
? params.perCardChoices
|
||||
: undefined,
|
||||
includeHidden: params.includeHidden ? true : undefined,
|
||||
appealTarget: params.appealTarget || undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -205,7 +205,6 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
|
||||
@@ -401,22 +401,6 @@ export type I18nKey =
|
||||
| "admin.rules.edit.title"
|
||||
| "admin.rules.empty"
|
||||
| "admin.rules.error.load"
|
||||
| "admin.rules.export.breadcrumb"
|
||||
| "admin.rules.export.copied"
|
||||
| "admin.rules.export.copy"
|
||||
| "admin.rules.export.copy_failed"
|
||||
| "admin.rules.export.count"
|
||||
| "admin.rules.export.download"
|
||||
| "admin.rules.export.error"
|
||||
| "admin.rules.export.field.since"
|
||||
| "admin.rules.export.heading"
|
||||
| "admin.rules.export.latest"
|
||||
| "admin.rules.export.no_pending"
|
||||
| "admin.rules.export.ok"
|
||||
| "admin.rules.export.run"
|
||||
| "admin.rules.export.running"
|
||||
| "admin.rules.export.subtitle"
|
||||
| "admin.rules.export.title"
|
||||
| "admin.rules.filter.lifecycle"
|
||||
| "admin.rules.filter.lifecycle.any"
|
||||
| "admin.rules.filter.proceeding"
|
||||
@@ -428,7 +412,6 @@ export type I18nKey =
|
||||
| "admin.rules.lifecycle.archived"
|
||||
| "admin.rules.lifecycle.draft"
|
||||
| "admin.rules.lifecycle.published"
|
||||
| "admin.rules.list.export"
|
||||
| "admin.rules.list.heading"
|
||||
| "admin.rules.list.new"
|
||||
| "admin.rules.list.subtitle"
|
||||
@@ -1021,10 +1004,13 @@ export type I18nKey =
|
||||
| "choices.include_ccr.title"
|
||||
| "choices.include_ccr.true"
|
||||
| "choices.reset"
|
||||
| "choices.show_hidden.count"
|
||||
| "choices.show_hidden.label"
|
||||
| "choices.skip.false"
|
||||
| "choices.skip.title"
|
||||
| "choices.skip.true"
|
||||
| "choices.skipped.chip"
|
||||
| "choices.unhide.chip"
|
||||
| "common.cancel"
|
||||
| "common.close"
|
||||
| "common.forbidden"
|
||||
@@ -1198,10 +1184,12 @@ export type I18nKey =
|
||||
| "deadlines.adjusted.weekend"
|
||||
| "deadlines.adjusted.weekend.saturday"
|
||||
| "deadlines.adjusted.weekend.sunday"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
| "deadlines.appellant.none"
|
||||
| "deadlines.appeal_target.anordnung"
|
||||
| "deadlines.appeal_target.bucheinsicht"
|
||||
| "deadlines.appeal_target.endentscheidung"
|
||||
| "deadlines.appeal_target.kostenentscheidung"
|
||||
| "deadlines.appeal_target.label"
|
||||
| "deadlines.appeal_target.schadensbemessung"
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.card.calc.add_to_project"
|
||||
| "deadlines.card.calc.add_to_project.disabled"
|
||||
@@ -1230,11 +1218,15 @@ export type I18nKey =
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
| "deadlines.complete.action"
|
||||
| "deadlines.complete.confirm"
|
||||
| "deadlines.conditional.depends_on"
|
||||
| "deadlines.conditional.unset"
|
||||
| "deadlines.court.indirect"
|
||||
| "deadlines.court.label"
|
||||
| "deadlines.court.set"
|
||||
@@ -1272,6 +1264,7 @@ export type I18nKey =
|
||||
| "deadlines.dpma.appeal.bgh"
|
||||
| "deadlines.dpma.appeal.bpatg"
|
||||
| "deadlines.dpma.opp.dpma"
|
||||
| "deadlines.durations.show"
|
||||
| "deadlines.empty.filtered"
|
||||
| "deadlines.empty.hint"
|
||||
| "deadlines.empty.title"
|
||||
@@ -1460,12 +1453,13 @@ export type I18nKey =
|
||||
| "deadlines.search.placeholder"
|
||||
| "deadlines.search.results.count"
|
||||
| "deadlines.search.results.count_one"
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.from_project"
|
||||
| "deadlines.side.hint"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.side.override"
|
||||
| "deadlines.side.undefined"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
@@ -1523,6 +1517,7 @@ export type I18nKey =
|
||||
| "deadlines.upc.apl.cost"
|
||||
| "deadlines.upc.apl.merits"
|
||||
| "deadlines.upc.apl.order"
|
||||
| "deadlines.upc.apl.unified"
|
||||
| "deadlines.upc.ccr.cfi"
|
||||
| "deadlines.upc.disc.cfi"
|
||||
| "deadlines.upc.dmgs.cfi"
|
||||
@@ -1986,7 +1981,6 @@ export type I18nKey =
|
||||
| "nav.admin.paliadin"
|
||||
| "nav.admin.partner_units"
|
||||
| "nav.admin.rules"
|
||||
| "nav.admin.rules_export"
|
||||
| "nav.admin.team"
|
||||
| "nav.agenda"
|
||||
| "nav.akten"
|
||||
@@ -2615,6 +2609,8 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "state.hidden.tooltip"
|
||||
| "state.optional.tooltip"
|
||||
| "submissions.draft.action.delete"
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
|
||||
@@ -1917,7 +1917,11 @@ input[type="range"]::-moz-range-thumb {
|
||||
.fristen-row.is-active .fristen-row-num {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text, #111);
|
||||
/* Lime is high-luminance; foreground stays midnight in both themes via
|
||||
--color-accent-dark (light: midnight by default, dark: midnight
|
||||
explicit). Using --color-text here would flip to cream in dark mode
|
||||
and collapse contrast on lime. */
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.fristen-row.is-prefilled .fristen-row-num {
|
||||
@@ -3328,7 +3332,11 @@ input[type="range"]::-moz-range-thumb {
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-height: 4rem;
|
||||
/* t-paliad-293: tighter min-height. Previously 4rem — too much
|
||||
vertical air per card on long projections. Title row + meta row
|
||||
fits comfortably in 2.75rem; longer cards (with notes expanded
|
||||
or adjusted-date banners) still grow naturally. */
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.timeline-item:last-child .timeline-line {
|
||||
@@ -3369,19 +3377,37 @@ input[type="range"]::-moz-range-thumb {
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
padding-bottom: 1rem;
|
||||
/* t-paliad-293: tighter inter-card gutter. Was 1rem; 0.6rem keeps
|
||||
the dotted-connector line readable without bloating long
|
||||
projections. */
|
||||
padding-bottom: 0.6rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
gap: 0.5rem;
|
||||
/* t-paliad-293: allow shrink + wrap so a long title plus the state
|
||||
icons + caret never push the card past its column. Combined with
|
||||
min-width:0 on the name, no inline child can blow the row width
|
||||
on 375/414/768 viewports. */
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
/* min-width:0 lets the name shrink and wrap inside its flex parent
|
||||
— otherwise overflow:hidden in an ancestor would clip it but the
|
||||
flex item would still demand its intrinsic width. */
|
||||
min-width: 0;
|
||||
/* Word-break on long German compounds (Vertraulichkeitswiderklage …)
|
||||
so they wrap mid-word rather than pushing the date column off-
|
||||
screen. (t-paliad-293) */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
@@ -3467,15 +3493,37 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--status-neutral-fg-3);
|
||||
}
|
||||
|
||||
.optional-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 99px;
|
||||
background: var(--status-amber-bg);
|
||||
/* t-paliad-293 — compact state icons in the card title row. They
|
||||
* replace the legacy `.optional-badge` text chip and add a uniform
|
||||
* language for the per-card decision state ("cut the tree of
|
||||
* possibilities"). Each icon carries its own modifier so the tint
|
||||
* matches the state semantic. The glyph itself is the primary signal;
|
||||
* the i18n tooltip on the span carries the accessible description. */
|
||||
.timeline-state-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
cursor: help;
|
||||
user-select: none;
|
||||
/* Cancel the wrapper fade so the marker stays legible inside
|
||||
.timeline-item--hidden which fades the whole content panel. */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.timeline-state-icon--optional {
|
||||
color: var(--status-amber-fg);
|
||||
}
|
||||
|
||||
.timeline-state-icon--hidden {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* t-paliad-265 — per-event-card optional choices. The caret sits in
|
||||
* the card header next to the date; the chip surfaces the active pick
|
||||
* inline with the title; the popover is body-attached and positioned
|
||||
@@ -3531,6 +3579,96 @@ input[type="range"]::-moz-range-thumb {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* t-paliad-290 (m/paliad#122) — re-surfaced "hidden" cards. The user
|
||||
* has previously marked these optional events as "Überspringen"; the
|
||||
* "Ausgeblendete anzeigen" toggle on /tools/verfahrensablauf returns
|
||||
* them with a faded + dotted-border treatment so they're visually
|
||||
* distinct from the active timeline. The inline "Wieder einblenden"
|
||||
* chip cancels the skip on click. */
|
||||
.timeline-item--hidden .timeline-content,
|
||||
.fr-col-item--hidden {
|
||||
opacity: 0.55;
|
||||
border: 1px dotted var(--color-border, #d4d4d4);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
/* t-paliad-293 — prominent "Wieder einblenden" entry inside the caret
|
||||
* popover. Surfaced only when the caret is opened on a hidden card
|
||||
* (data-is-hidden="1"). Used to be an inline chip in the card header,
|
||||
* but that caused horizontal scroll on narrow viewports (m/paliad#125)
|
||||
* because its German label is wide ("Wieder einblenden") and the
|
||||
* card header is a non-wrapping flex row. Moving it into the popover
|
||||
* removes the surface entirely and matches m's "actions live in the
|
||||
* caret menu" framing. */
|
||||
.event-card-choices-block--unhide {
|
||||
/* No top border separator — this block sits at the top of the
|
||||
popover with the highest visual priority. */
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.event-card-choices-unhide-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
background: var(--color-accent, #c6f41c);
|
||||
/* Match the active-option pin (lime fg → midnight text) so the
|
||||
button reads against the lime in both light and dark themes
|
||||
(m/paliad#123). */
|
||||
color: var(--color-accent-dark);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.event-card-choices-unhide-btn:hover,
|
||||
.event-card-choices-unhide-btn:focus-visible {
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.show-hidden-count {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
/* t-paliad-289: rules whose anchor is uncertain (court-set ancestor
|
||||
without override, backward-anchor with unset forward date, optional
|
||||
event not recorded). The "abhängig von <parent>" chip on the date
|
||||
column makes the conditional state explicit; the dotted border on
|
||||
the content panel + slight desaturation reinforces it at glance so
|
||||
the row reads as "pending an upstream input" rather than as a real
|
||||
scheduled item. The frist-date-edit affordance on the chip still
|
||||
wires through — the user can pin a concrete date once the anchor
|
||||
resolves. */
|
||||
.timeline-item--conditional .timeline-content,
|
||||
.fr-col-item--conditional {
|
||||
border: 1px dashed var(--color-border, #d4d4d4);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: var(--color-bg-soft, #fafafa);
|
||||
}
|
||||
|
||||
.timeline-item--conditional .timeline-name,
|
||||
.fr-col-item--conditional .timeline-name {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.timeline-conditional {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
.event-card-choices-popover {
|
||||
background: var(--color-bg, #fff);
|
||||
border: 1px solid var(--color-border, #d4d4d4);
|
||||
@@ -3578,7 +3716,10 @@ input[type="range"]::-moz-range-thumb {
|
||||
.event-card-choices-option--active {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
color: var(--color-text);
|
||||
/* Foreground stays midnight in both themes — --color-text would flip
|
||||
to cream in dark mode and leave the active "Berufung durch …"
|
||||
chip unreadable on lime (m/paliad#123). */
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -3609,6 +3750,16 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Per-rule duration label rendered inline in the meta row when
|
||||
"Dauern anzeigen" is on (m/paliad#133, t-paliad-302). Matches the
|
||||
sibling .timeline-rule weight so the meta line reads as one band of
|
||||
secondary metadata; non-mono so the value reads as prose ("2 Mo. nach")
|
||||
rather than a code reference. */
|
||||
.timeline-duration {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.timeline-adjusted {
|
||||
font-size: 0.78rem;
|
||||
color: var(--status-amber-fg-2);
|
||||
@@ -3711,6 +3862,22 @@ input[type="range"]::-moz-range-thumb {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* "Pick a side" hint that sits next to the side-radio cluster while
|
||||
currentSide is null (m/paliad#120). Both columns still render every
|
||||
rule in that state — the chip just nudges the user that picking a
|
||||
side focuses their column. Hidden by JS once a side is picked. */
|
||||
.side-radio-cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.side-hint {
|
||||
color: var(--color-text-muted, #666);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Read-only auto-fill chip for #side-row. Renders when ?project=<id>
|
||||
resolves a project whose our_side is set: shows the inferred side
|
||||
with a small "Andere Seite wählen" override link that swaps the row
|
||||
@@ -6376,6 +6543,194 @@ dialog.modal::backdrop {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* t-paliad-287 — collapsible variable-group section (Frist + Parteien
|
||||
override). The toggle button is the section header; clicking it
|
||||
flips state.collapsedGroups[id] and re-renders. The visible caret
|
||||
rotates via the parent's --collapsed class. */
|
||||
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0 0 0.5rem 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.submission-draft-var-group-caret {
|
||||
display: inline-block;
|
||||
transition: transform 120ms ease;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.submission-draft-var-group--collapsible:not(.submission-draft-var-group--collapsed)
|
||||
.submission-draft-var-group-caret {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.submission-draft-var-group--collapsed .submission-draft-var-group-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* t-paliad-287 — Add Party affordance per side. */
|
||||
.submission-draft-addparty {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-tab {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.85em;
|
||||
border-radius: 4px 4px 0 0;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-tab--active {
|
||||
color: var(--color-text);
|
||||
border-bottom-color: var(--color-accent, #c6f41c);
|
||||
background: var(--color-bg-lime-tint, #f0fac6);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-form--busy {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-field > span {
|
||||
font-size: 0.82em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-row {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-row:hover {
|
||||
background: var(--color-bg-lime-tint, #f0fac6);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-empty {
|
||||
padding: 0.6rem;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-rep {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-projwrap {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-text-muted);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-proj {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-projref {
|
||||
margin-left: 0.3rem;
|
||||
padding: 0 0.4em;
|
||||
border-radius: 3px;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-hint {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.submission-draft-parties-empty {
|
||||
font-size: 0.82em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.checklist-instance-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
@@ -7976,7 +8331,7 @@ dialog.modal::backdrop {
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
color: var(--color-accent-dark);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -15906,7 +16261,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
color: var(--color-accent-dark);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
@@ -16514,7 +16869,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
.smart-timeline-anchor-submit {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
color: var(--color-text, #333);
|
||||
color: var(--color-accent-dark);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
@@ -17452,7 +17807,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
.admin-rules-chip.active {
|
||||
background: var(--color-accent, #BFF355);
|
||||
border-color: var(--color-accent, #BFF355);
|
||||
color: var(--color-text, #000);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.admin-rules-pill {
|
||||
@@ -17840,42 +18195,6 @@ dialog.quick-add-sheet::backdrop {
|
||||
border-top: 1px solid var(--color-border, #d4d4d8);
|
||||
}
|
||||
|
||||
/* Export page */
|
||||
|
||||
.admin-rules-export-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-rules-export-controls .form-field {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.admin-rules-export-summary {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted, #71717a);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-rules-export-pre {
|
||||
background: var(--color-bg-subtle, #f4f4f5);
|
||||
border: 1px solid var(--color-border, #d4d4d8);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
max-height: 60vh;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8rem;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Date-range picker (t-paliad-248) ------------------------------------
|
||||
Symmetric past/future chip fan around an ALLES centre, in a popover
|
||||
anchored under a closed-state trigger button. Reuses .agenda-chip /
|
||||
|
||||
@@ -172,10 +172,13 @@ export function renderSubmissionDraft(): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-277: multi-select party picker.
|
||||
{/* t-paliad-277 / t-paliad-287: multi-select party
|
||||
picker plus per-side Add-Party affordance.
|
||||
Populated from view.available_parties; checkbox
|
||||
per party, grouped by role. Hidden when no
|
||||
project or no parties on the project. */}
|
||||
project is attached; visible even on empty
|
||||
rosters so the lawyer can use Add Party to
|
||||
populate. */}
|
||||
<div
|
||||
id="submission-draft-parties"
|
||||
className="submission-draft-parties"
|
||||
|
||||
@@ -28,16 +28,20 @@ function proceedingBtn(p: ProceedingDef): string {
|
||||
);
|
||||
}
|
||||
|
||||
// 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.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
|
||||
{ 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" },
|
||||
{ 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
|
||||
@@ -190,9 +194,18 @@ export function renderVerfahrensablauf(): string {
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.both">Beide</span>
|
||||
<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
|
||||
@@ -207,23 +220,49 @@ export function renderVerfahrensablauf(): string {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
{/* 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="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
<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="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
<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="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
<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-
|
||||
@@ -302,6 +341,13 @@ export function renderVerfahrensablauf(): string {
|
||||
<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">
|
||||
|
||||
134
internal/db/migration_136_test.go
Normal file
134
internal/db/migration_136_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Slice B.1 (t-paliad-273) — migration 136 backfill invariants.
|
||||
//
|
||||
// The dry-run gate (migrate_test.go: TestMigrations_DryRun) catches
|
||||
// migrations that crash on apply, but it rolls back inside its own
|
||||
// transaction — the post-state assertions in mig 136's PL/pgSQL block
|
||||
// run, but a future refactor of those assertions might forget a check
|
||||
// or introduce a silent count drift. This test layers a Go-side
|
||||
// invariant check on top so the contract is restated in test code,
|
||||
// outside the PL/pgSQL block, against the resulting tables.
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL, same pattern as
|
||||
// internal/services/submission_codes_shape_test.go.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// TestMigration136_BackfillInvariants applies every embedded migration
|
||||
// (which lands mig 136 along the way) and then asserts the four
|
||||
// invariants the B.1 design + B.0 findings nailed down:
|
||||
//
|
||||
// 1. procedural_events row count = (distinct submission_codes in
|
||||
// deadline_rules) + (deadline_rules with NULL submission_code).
|
||||
// Codes-bearing branch is 1:1 per the B.0 audit (no multi-row
|
||||
// codes since the _archived_litigation.* removal); the NULL
|
||||
// branch gets one synthetic procedural_event per rule.
|
||||
// 2. sequencing_rules row count = deadline_rules row count (1:1).
|
||||
// 3. legal_sources row count = distinct legal_source in
|
||||
// deadline_rules (NULL excluded).
|
||||
// 4. every sequencing_rules row's procedural_event_id resolves to a
|
||||
// procedural_events row (NOT NULL FK already enforces this at the
|
||||
// DB level — this test catches a future relaxation of the FK).
|
||||
// 5. no two synthetic codes collide (covered by the UNIQUE on
|
||||
// procedural_events.code; restated here for documentation).
|
||||
//
|
||||
// The test is robust against corpus size — it derives all expected
|
||||
// counts from the live deadline_rules state, so a scratch DB with 0
|
||||
// rules trivially passes, and a prod-shaped scratch DB exercises the
|
||||
// real invariants.
|
||||
func TestMigration136_BackfillInvariants(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping mig 136 invariant test")
|
||||
}
|
||||
if err := ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
var (
|
||||
drTotal, drCodesDistinct, drCodesNull, drLegalDistinct int
|
||||
peTotal, srTotal, lsTotal int
|
||||
orphanPE, dupSynthetic int
|
||||
)
|
||||
|
||||
mustQ := func(label, q string, dst *int) {
|
||||
t.Helper()
|
||||
if err := conn.QueryRowContext(ctx, q).Scan(dst); err != nil {
|
||||
t.Fatalf("%s: %v", label, err)
|
||||
}
|
||||
}
|
||||
|
||||
mustQ("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &drTotal)
|
||||
mustQ("dr_codes_distinct",
|
||||
`SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL`,
|
||||
&drCodesDistinct)
|
||||
mustQ("dr_codes_null",
|
||||
`SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL`,
|
||||
&drCodesNull)
|
||||
mustQ("dr_legal_distinct",
|
||||
`SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`,
|
||||
&drLegalDistinct)
|
||||
mustQ("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &peTotal)
|
||||
mustQ("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &srTotal)
|
||||
mustQ("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &lsTotal)
|
||||
|
||||
// Invariant 1: procedural_events = distinct_codes + null_codes
|
||||
wantPE := drCodesDistinct + drCodesNull
|
||||
if peTotal != wantPE {
|
||||
t.Errorf("procedural_events count mismatch: got %d, want %d (distinct codes=%d + null-code rules=%d)",
|
||||
peTotal, wantPE, drCodesDistinct, drCodesNull)
|
||||
}
|
||||
|
||||
// Invariant 2: sequencing_rules 1:1 with deadline_rules
|
||||
if srTotal != drTotal {
|
||||
t.Errorf("sequencing_rules count mismatch: got %d, want %d (1:1 with deadline_rules)",
|
||||
srTotal, drTotal)
|
||||
}
|
||||
|
||||
// Invariant 3: legal_sources = distinct legal_source
|
||||
if lsTotal != drLegalDistinct {
|
||||
t.Errorf("legal_sources count mismatch: got %d, want %d (distinct legal_source)",
|
||||
lsTotal, drLegalDistinct)
|
||||
}
|
||||
|
||||
// Invariant 4: every sequencing_rules.procedural_event_id resolves
|
||||
mustQ("orphan_pe", `
|
||||
SELECT COUNT(*)
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.id IS NULL`, &orphanPE)
|
||||
if orphanPE != 0 {
|
||||
t.Errorf("FK integrity violated: %d sequencing_rules row(s) have no resolving procedural_event_id", orphanPE)
|
||||
}
|
||||
|
||||
// Invariant 5: no duplicate synthetic codes
|
||||
mustQ("dup_synthetic", `
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT code FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
GROUP BY code
|
||||
HAVING COUNT(*) > 1
|
||||
) d`, &dupSynthetic)
|
||||
if dupSynthetic != 0 {
|
||||
t.Errorf("synthetic code uniqueness violated: %d duplicate(s) under 'null.%%' prefix", dupSynthetic)
|
||||
}
|
||||
|
||||
t.Logf("mig 136 invariants OK: deadline_rules=%d, procedural_events=%d (=%d+%d), "+
|
||||
"sequencing_rules=%d, legal_sources=%d (distinct legal_source=%d)",
|
||||
drTotal, peTotal, drCodesDistinct, drCodesNull, srTotal, lsTotal, drLegalDistinct)
|
||||
}
|
||||
72
internal/db/migrations/134_berufung_unification.down.sql
Normal file
72
internal/db/migrations/134_berufung_unification.down.sql
Normal file
@@ -0,0 +1,72 @@
|
||||
-- 134_berufung_unification — DOWN
|
||||
--
|
||||
-- Reverses the Berufung unification: un-archives the 3 old appeal
|
||||
-- proceeding_types, points the 16 rules back at their original
|
||||
-- proceeding by their applies_to_target stamp, drops the new
|
||||
-- upc.apl row, drops the two columns + their CHECK constraints.
|
||||
--
|
||||
-- The 3 old proceeding_types are recovered by code (we archived them,
|
||||
-- never deleted them — that's what makes this down-migration safe).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger — step 2 UPDATEs
|
||||
-- paliad.deadline_rules to reverse the reassignment).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 134 DOWN: revert Slice B1 — restore 3 separate UPC appeal proceeding_types, drop applies_to_target column',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Un-archive the 3 old appeal proceeding_types.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = true
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Point rules back at their original proceeding_type by stamp.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.merits'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['endentscheidung']::text[];
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.cost'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['kostenentscheidung']::text[];
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.order'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['anordnung']::text[];
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Drop the unified upc.apl.unified row (now orphaned).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DELETE FROM paliad.proceeding_types WHERE code = 'upc.apl.unified';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Drop the new columns + their CHECK constraints.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_applies_to_target_chk;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS applies_to_target;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP CONSTRAINT IF EXISTS proceeding_types_appeal_target_chk;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS appeal_target;
|
||||
272
internal/db/migrations/134_berufung_unification.up.sql
Normal file
272
internal/db/migrations/134_berufung_unification.up.sql
Normal file
@@ -0,0 +1,272 @@
|
||||
-- 134_berufung_unification — Slice B1, m/paliad#124, t-paliad-298+
|
||||
--
|
||||
-- Collapses the 3 active UPC appeal proceeding_types (upc.apl.merits,
|
||||
-- upc.apl.cost, upc.apl.order — 16 rules across 3 codes) into ONE
|
||||
-- unified upc.apl proceeding type + an `appeal_target` discriminator on
|
||||
-- both proceeding_types (top-level marker) and deadline_rules
|
||||
-- (per-row applies-to set, text[] for multi-target rules).
|
||||
--
|
||||
-- ADDITIVE ONLY. The migration:
|
||||
-- 1. Adds the two columns + check constraints.
|
||||
-- 2. Inserts the new upc.apl proceeding type.
|
||||
-- 3. Audit-first: NOTICES every row about to be touched.
|
||||
-- 4. Reassigns rule rows from the 3 old types to upc.apl, stamping
|
||||
-- applies_to_target by source proceeding code.
|
||||
-- 5. Archives (is_active=false) the 3 old proceeding_types — NEVER
|
||||
-- deletes them, so any historical project_event_choices / FK
|
||||
-- references stay intact.
|
||||
--
|
||||
-- Schadensbemessung + Bucheinsicht get NO rule rows in this migration
|
||||
-- (m's 2026-05-26 decision: distinct rule sets, not shared with
|
||||
-- merits). Their appeal_target enum values are defined and addressable
|
||||
-- by CalcOptions.AppealTarget; the engine returns an empty timeline
|
||||
-- until rules are seeded in a follow-up slice (likely via
|
||||
-- /admin/rules, pairing with t-paliad-193 orphan-concept-seed).
|
||||
--
|
||||
-- See docs/design-litigation-planner-2026-05-26.md §18.1.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
|
||||
-- paliad.deadline_rules — step 4 reassigns 16 rules).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 134: t-paliad-292 Slice B1 — Berufung unification, collapse 3 UPC appeal proceeding_types into upc.apl.unified + appeal_target discriminator',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Schema additions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN appeal_target text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD CONSTRAINT proceeding_types_appeal_target_chk
|
||||
CHECK (appeal_target IS NULL OR appeal_target IN (
|
||||
'endentscheidung',
|
||||
'kostenentscheidung',
|
||||
'anordnung',
|
||||
'schadensbemessung',
|
||||
'bucheinsicht'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.appeal_target IS
|
||||
'Top-level appeal-target marker. NULL on non-appeal proceedings. '
|
||||
'Reserved for future variants — today only the unified upc.apl row '
|
||||
'has this NULL (the actual per-rule target set lives on '
|
||||
'paliad.deadline_rules.applies_to_target).';
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN applies_to_target text[] NULL;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_applies_to_target_chk
|
||||
CHECK (
|
||||
applies_to_target IS NULL
|
||||
OR applies_to_target <@ ARRAY[
|
||||
'endentscheidung',
|
||||
'kostenentscheidung',
|
||||
'anordnung',
|
||||
'schadensbemessung',
|
||||
'bucheinsicht'
|
||||
]::text[]
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.applies_to_target IS
|
||||
'Set of appeal_target slugs this rule applies to. NULL on rules '
|
||||
'that don''t belong to an appeal proceeding. The engine filters '
|
||||
'by CalcOptions.AppealTarget — rules whose applies_to_target '
|
||||
'contains the requested slug are emitted; others are suppressed.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Insert the unified upc.apl row.
|
||||
--
|
||||
-- Inherits default_color from the merits row (the most-used appeal
|
||||
-- track today). sort_order follows the cluster of UPC proceedings;
|
||||
-- placed just before upc.apl.merits's old slot so the chip-grouped
|
||||
-- picker UI lands Berufung in a sensible position. Tweakable later
|
||||
-- without a migration.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.proceeding_types (
|
||||
code, name, name_en, description, jurisdiction, category,
|
||||
default_color, sort_order, is_active, display_order,
|
||||
appeal_target
|
||||
)
|
||||
SELECT
|
||||
'upc.apl.unified',
|
||||
'Berufungsverfahren',
|
||||
'Appeal',
|
||||
'Vereinheitlichtes Berufungsverfahren — wählen Sie anschließend, '
|
||||
'worauf die Berufung sich richtet (Endentscheidung, '
|
||||
'Kostenentscheidung, Anordnung, Schadensbemessung, Bucheinsicht).',
|
||||
'UPC',
|
||||
'fristenrechner',
|
||||
default_color,
|
||||
sort_order,
|
||||
true,
|
||||
display_order,
|
||||
NULL
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.merits';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Audit-first RAISE NOTICE pass.
|
||||
--
|
||||
-- Lists every rule row that will be reassigned + every proceeding_type
|
||||
-- row that will be archived. The migration runs to completion either
|
||||
-- way; the operator reads the notices to confirm scope before the
|
||||
-- next migration in the chain.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
upc_apl_id int;
|
||||
rules_touched int := 0;
|
||||
procs_archived int := 0;
|
||||
BEGIN
|
||||
SELECT id INTO upc_apl_id
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.unified';
|
||||
RAISE NOTICE '[mig 134] new upc.apl.unified proceeding_type_id = %', upc_apl_id;
|
||||
|
||||
RAISE NOTICE '[mig 134] Rules to reassign to upc.apl.unified with applies_to_target:';
|
||||
FOR rec IN
|
||||
SELECT dr.id AS rule_id,
|
||||
pt.code AS old_proceeding,
|
||||
dr.submission_code,
|
||||
dr.name
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
AND dr.is_active = true
|
||||
ORDER BY pt.code, dr.sequence_order
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] % % % (%)',
|
||||
rec.old_proceeding, rec.submission_code, rec.name, rec.rule_id;
|
||||
rules_touched := rules_touched + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 134] Total rules to reassign: %', rules_touched;
|
||||
|
||||
RAISE NOTICE '[mig 134] Proceeding_types to archive (is_active=false):';
|
||||
FOR rec IN
|
||||
SELECT id, code, name
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
ORDER BY sort_order
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] % % (id=%)', rec.code, rec.name, rec.id;
|
||||
procs_archived := procs_archived + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 134] Total proceeding_types to archive: %', procs_archived;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Reassign rule rows.
|
||||
--
|
||||
-- Stamp applies_to_target by source proceeding code, then point all
|
||||
-- 16 rules at the new upc.apl row.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- 4a. upc.apl.merits → applies_to_target = {endentscheidung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['endentscheidung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.merits'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4b. upc.apl.cost → applies_to_target = {kostenentscheidung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['kostenentscheidung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.cost'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4c. upc.apl.order → applies_to_target = {anordnung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['anordnung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.order'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4d. Reassign all 16 rules to the new upc.apl.unified proceeding_type row.
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.unified'
|
||||
)
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. Archive the 3 old proceeding_types.
|
||||
--
|
||||
-- NEVER DELETE — historical project_event_choices and project FKs
|
||||
-- (paliad.projects.proceeding_type_id) may still reference these IDs.
|
||||
-- The is_active=false flag stops them appearing in the picker but
|
||||
-- preserves FK integrity for historical reads.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6. Post-migration sanity check.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
unified_count int;
|
||||
archived_count int;
|
||||
target_distribution record;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO unified_count
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true;
|
||||
RAISE NOTICE '[mig 134] post: rules on unified upc.apl.unified = % (expected 16)', unified_count;
|
||||
IF unified_count <> 16 THEN
|
||||
RAISE EXCEPTION '[mig 134] FAILED — expected 16 rules on upc.apl.unified, got %', unified_count;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO archived_count
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
AND is_active = false;
|
||||
RAISE NOTICE '[mig 134] post: archived old appeal proceeding_types = % (expected 3)', archived_count;
|
||||
IF archived_count <> 3 THEN
|
||||
RAISE EXCEPTION '[mig 134] FAILED — expected 3 archived types, got %', archived_count;
|
||||
END IF;
|
||||
|
||||
FOR target_distribution IN
|
||||
SELECT unnest(applies_to_target) AS target, COUNT(*) AS n
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true
|
||||
GROUP BY unnest(applies_to_target)
|
||||
ORDER BY 1
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] post: applies_to_target=% count=%',
|
||||
target_distribution.target, target_distribution.n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- TODO (follow-up slice, not in 134):
|
||||
--
|
||||
-- Seed rules for Schadensbemessung-as-appeal + Bucheinsicht-as-appeal.
|
||||
-- m's 2026-05-26 decision: distinct rule sets, NOT shared with merits.
|
||||
-- - Schadensbemessung: anchor on R.118.4 decision; conjecture 2/4-month
|
||||
-- merits-style track but distinct legal basis.
|
||||
-- - Bucheinsicht: anchor on R.142 (Lay-open-books decision); conjecture
|
||||
-- 15-day track per R.220.2 + R.224.2.b.
|
||||
-- Can pair with t-paliad-193 orphan-concept-seed if m wants a combined
|
||||
-- editorial pass via /admin/rules.
|
||||
-- ---------------------------------------------------------------
|
||||
8
internal/db/migrations/135_primary_party_check.down.sql
Normal file
8
internal/db/migrations/135_primary_party_check.down.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- 135_primary_party_check — DOWN
|
||||
--
|
||||
-- Drops the CHECK constraint added in 135.up. No data revert needed
|
||||
-- — the column stays text, the four-value vocab is enforced only by
|
||||
-- application code thereafter.
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_primary_party_chk;
|
||||
92
internal/db/migrations/135_primary_party_check.up.sql
Normal file
92
internal/db/migrations/135_primary_party_check.up.sql
Normal file
@@ -0,0 +1,92 @@
|
||||
-- 135_primary_party_check — Slice B3, m/paliad#124 §18.3
|
||||
--
|
||||
-- Tightens paliad.deadline_rules.primary_party from free-text to a
|
||||
-- CHECK constraint over the canonical four-value vocabulary
|
||||
-- (claimant / defendant / court / both). NULL stays valid for the
|
||||
-- 78 cross-cutting orphan concept seeds (Wiedereinsetzung,
|
||||
-- Versäumnisurteil-Einspruch, Schriftsatznachreichung,
|
||||
-- Weiterbehandlung) — they have no proceeding_type_id binding so
|
||||
-- they're outside the calculator's path; loosening the CHECK to
|
||||
-- "IS NULL OR IN (…)" keeps them valid without backfill gymnastics.
|
||||
--
|
||||
-- Audit-first: the DO block RAISEs NOTICE for every non-conforming
|
||||
-- row before adding the CHECK, and RAISEs EXCEPTION if any dirty
|
||||
-- rows are found so the operator can decide a manual cleanup path.
|
||||
-- Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows
|
||||
-- on the current corpus: 26 claimant + 26 defendant + 38 court +
|
||||
-- 63 both + 78 NULL = 231 total, all in the canonical vocab. The
|
||||
-- audit pass stays in the migration for safety against future drift
|
||||
-- (e.g. a rule editor write that bypassed the application-layer
|
||||
-- validation hook this slice also adds).
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
dirty_count int := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 135] primary_party audit pass — non-conforming rows:';
|
||||
FOR rec IN
|
||||
SELECT dr.id, dr.name, dr.primary_party,
|
||||
pt.code AS proceeding_code
|
||||
FROM paliad.deadline_rules dr
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE dr.is_active = true
|
||||
AND dr.primary_party IS NOT NULL
|
||||
AND dr.primary_party NOT IN ('claimant', 'defendant', 'court', 'both')
|
||||
ORDER BY pt.code NULLS LAST, dr.name
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 135] % % primary_party=% (rule=%)',
|
||||
COALESCE(rec.proceeding_code, '<orphan>'),
|
||||
rec.name,
|
||||
rec.primary_party,
|
||||
rec.id;
|
||||
dirty_count := dirty_count + 1;
|
||||
END LOOP;
|
||||
IF dirty_count > 0 THEN
|
||||
RAISE EXCEPTION '[mig 135] FAILED — % rule(s) carry non-canonical primary_party values. '
|
||||
'Manual cleanup required: update each row to one of '
|
||||
'''claimant'', ''defendant'', ''court'', ''both'', or NULL. '
|
||||
'See the NOTICE lines above for the offending rows.', dirty_count;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 135] audit clean — proceeding with CHECK constraint';
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- Add the CHECK constraint. NULL stays valid; the four canonical
|
||||
-- values are the only allowed non-NULL forms.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_primary_party_chk
|
||||
CHECK (
|
||||
primary_party IS NULL
|
||||
OR primary_party IN ('claimant', 'defendant', 'court', 'both')
|
||||
);
|
||||
|
||||
COMMENT ON CONSTRAINT deadline_rules_primary_party_chk
|
||||
ON paliad.deadline_rules IS
|
||||
'Slice B3 (mig 135, m/paliad#124 §18.3) — canonical four-value '
|
||||
'vocab for primary_party (claimant / defendant / court / both). '
|
||||
'NULL allowed for cross-cutting orphan concept seeds (78 rows in '
|
||||
'live corpus as of mig 135). See pkg/litigationplanner.PrimaryParties '
|
||||
'for the in-code vocabulary.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- Post-migration distribution check — informational NOTICE only.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 135] post: primary_party distribution after constraint add:';
|
||||
FOR rec IN
|
||||
SELECT COALESCE(primary_party, '<NULL>') AS party, COUNT(*) AS n
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
GROUP BY primary_party
|
||||
ORDER BY party
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 135] % count=%', rec.party, rec.n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 136_procedural_events_additive (down) — Slice B.1, t-paliad-273
|
||||
--
|
||||
-- Safe to run at any point in B.1's lifetime. Up does NOT touch
|
||||
-- paliad.deadline_rules, so dropping the new tables + columns loses no
|
||||
-- application data — every source row in deadline_rules is intact and
|
||||
-- authoritative through the dual-write window.
|
||||
--
|
||||
-- Reverse order: drop indexes implicitly via DROP TABLE, drop the two
|
||||
-- deadlines link columns first (their FKs target procedural_events +
|
||||
-- sequencing_rules), then drop the three new tables in FK-safe order
|
||||
-- (sequencing_rules → procedural_events → legal_sources).
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS procedural_event_id,
|
||||
DROP COLUMN IF EXISTS sequencing_rule_id;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules;
|
||||
DROP TABLE IF EXISTS paliad.procedural_events;
|
||||
DROP TABLE IF EXISTS paliad.legal_sources;
|
||||
488
internal/db/migrations/136_procedural_events_additive.up.sql
Normal file
488
internal/db/migrations/136_procedural_events_additive.up.sql
Normal file
@@ -0,0 +1,488 @@
|
||||
-- 136_procedural_events_additive — Slice B.1, t-paliad-273 / m/paliad#93
|
||||
--
|
||||
-- ADDITIVE ONLY. Creates the three new tables that split today's
|
||||
-- paliad.deadline_rules into its three latent concepts (per the
|
||||
-- 2026-05-25 inventor design + 2026-05-26 B.0 re-validation):
|
||||
--
|
||||
-- 1. paliad.legal_sources — the source-of-law citations
|
||||
-- (DE.PatG.102, UPC.RoP.220.1, …)
|
||||
-- 2. paliad.procedural_events — the procedural-event templates
|
||||
-- (Rechtsbeschwerdebegründung, etc.;
|
||||
-- successor of `submission_code`)
|
||||
-- 3. paliad.sequencing_rules — the timing + trigger + condition
|
||||
-- mechanics (today's per-row data)
|
||||
--
|
||||
-- and adds two nullable link columns on paliad.deadlines so B.2's
|
||||
-- dual-write phase has somewhere to point.
|
||||
--
|
||||
-- The migration does NOT touch paliad.deadline_rules. The legacy table
|
||||
-- stays intact and authoritative for reads until B.3 flips the cutover.
|
||||
-- deadlines.rule_id stays in place (read by the calculator + projection
|
||||
-- service). No app code is changed by this migration; B.2 introduces
|
||||
-- the dual-write that wires services to the new tables.
|
||||
--
|
||||
-- Backfill plan (cf. design §5.1 + B.0 findings §7):
|
||||
-- * legal_sources <- DISTINCT legal_source FROM deadline_rules WHERE
|
||||
-- legal_source IS NOT NULL. pretty_de/pretty_en
|
||||
-- LEFT NULL for now (legalSourcePretty() in Go
|
||||
-- continues to materialise them on read; a future
|
||||
-- slice backfills them via a Go shim).
|
||||
-- * procedural_events <-
|
||||
-- (a) DISTINCT ON (submission_code) FROM deadline_rules WHERE
|
||||
-- submission_code IS NOT NULL — picks the lowest-id rule per
|
||||
-- code as the procedural-event identity source.
|
||||
-- (b) one synthetic procedural_event per NULL-submission_code
|
||||
-- rule, code = 'null.' || substring(replace(id::text,'-',''),1,8).
|
||||
-- m's pick (paliadin instruction 2026-05-26): mint synthetic
|
||||
-- codes so every deadline_rules row ends up with a
|
||||
-- procedural_events row, preserving the 1:1 sequencing-rule
|
||||
-- backfill and keeping the NOT NULL FK on
|
||||
-- sequencing_rules.procedural_event_id intact.
|
||||
-- * sequencing_rules <- 1:1 from deadline_rules. The new row inherits
|
||||
-- the source row's id so that any existing
|
||||
-- paliad.deadlines.rule_id FK target stays resolvable through
|
||||
-- the dual-write window (design §5.1 step 4).
|
||||
-- * deadlines.procedural_event_id + sequencing_rule_id <- joined from
|
||||
-- sequencing_rules on the inherited id.
|
||||
--
|
||||
-- Design deviations (intentional, documented):
|
||||
-- - procedural_events.event_kind is NULLABLE (design proposed NOT NULL
|
||||
-- with 'other' fallback). Today 89 deadline_rules rows have NULL
|
||||
-- event_type — these are "structural / parent-only rows in the
|
||||
-- proceeding tree" per B.0 §1. Forcing them to 'other' would lose
|
||||
-- semantics. A later slice can tighten this to NOT NULL after the
|
||||
-- 78+11 NULLs are reclassified.
|
||||
-- - legal_sources.pretty_de / pretty_en are NULLABLE (design proposed
|
||||
-- NOT NULL). Materialising them requires the Go-side
|
||||
-- legalSourcePretty() function — out of scope for a SQL migration.
|
||||
-- The Go read path continues to compute them on the fly from
|
||||
-- legal_source / citation; a future slice (Go shim driven from
|
||||
-- internal/services/submission_vars.go:619) backfills them.
|
||||
-- - submission_drafts is NOT modified. The design proposes adding
|
||||
-- procedural_event_id there too (§4.1 §5.1 step 6) but the B.1
|
||||
-- instruction scope is explicit: tables + deadlines columns only.
|
||||
-- submission_drafts continues to key off submission_code text.
|
||||
--
|
||||
-- Audit pattern follows mig 135 (Slice B3): PRE-pass counts what we
|
||||
-- expect to write, BACKFILL runs the SELECT-INSERTs, POST-pass verifies
|
||||
-- row counts and FK integrity. Any mismatch RAISE EXCEPTIONs and the
|
||||
-- transaction rolls back — operator sees the NOTICE lines and the
|
||||
-- failed assertion message.
|
||||
--
|
||||
-- See: docs/design-procedural-events-model-2026-05-25.md §4 + §5
|
||||
-- docs/design-procedural-events-b0-findings-2026-05-26.md §7
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. PRE pass — snapshot what we're about to backfill
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_rules int;
|
||||
v_codes_nn int;
|
||||
v_codes_distinct int;
|
||||
v_codes_null int;
|
||||
v_legal_distinct int;
|
||||
v_concept_linked int;
|
||||
v_dups int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_rules FROM paliad.deadline_rules;
|
||||
SELECT COUNT(*) INTO v_codes_nn FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(DISTINCT submission_code) INTO v_codes_distinct
|
||||
FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_codes_null FROM paliad.deadline_rules WHERE submission_code IS NULL;
|
||||
SELECT COUNT(DISTINCT legal_source) INTO v_legal_distinct
|
||||
FROM paliad.deadline_rules WHERE legal_source IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_concept_linked FROM paliad.deadline_rules WHERE concept_id IS NOT NULL;
|
||||
|
||||
RAISE NOTICE '[mig 136] PRE: deadline_rules=%, with_submission_code=%, distinct_codes=%, null_codes=%, distinct_legal_sources=%, concept_linked=%',
|
||||
v_rules, v_codes_nn, v_codes_distinct, v_codes_null, v_legal_distinct, v_concept_linked;
|
||||
|
||||
-- Defensive: refuse to run if multi-row submission_codes have crept
|
||||
-- back in. B.0 (2026-05-26) found zero; mig 134 + 135 do not add
|
||||
-- any. If this CHECK ever fires the backfill arithmetic below
|
||||
-- breaks silently (one PE per code becomes ambiguous), so abort.
|
||||
SELECT COUNT(*) INTO v_dups FROM (
|
||||
SELECT submission_code
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
GROUP BY submission_code
|
||||
HAVING COUNT(*) > 1
|
||||
) d;
|
||||
IF v_dups > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED PRE: % submission_code value(s) appear on >1 deadline_rules row. '
|
||||
'The B.0 audit (2026-05-26) found zero. If you are seeing this, a rule was added that '
|
||||
'duplicates an existing submission_code (or the _archived_litigation.* rows returned). '
|
||||
'Decide whether the new schema collapses them (multiple sequencing rules → one '
|
||||
'procedural event) or whether each row gets its own code, then update this migration '
|
||||
'or the offending data before re-running.', v_dups;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. CREATE TABLE paliad.legal_sources
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.legal_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
citation text NOT NULL UNIQUE,
|
||||
jurisdiction text NOT NULL,
|
||||
pretty_de text,
|
||||
pretty_en text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.legal_sources IS
|
||||
'Source-of-law citations (DE.PatG.102, UPC.RoP.220.1, …). One row per '
|
||||
'distinct citation shorthand. pretty_de/pretty_en backfilled by a '
|
||||
'future Go-driven slice; until then NULL and the Go service ('
|
||||
'internal/services/submission_vars.go:619 legalSourcePretty) computes '
|
||||
'the human-readable form on read from the citation. Slice B.1 t-paliad-273.';
|
||||
|
||||
CREATE INDEX legal_sources_jurisdiction_idx ON paliad.legal_sources(jurisdiction);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. CREATE TABLE paliad.procedural_events
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.procedural_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
name_en text NOT NULL DEFAULT '',
|
||||
description text,
|
||||
event_kind text,
|
||||
primary_party_default text,
|
||||
legal_source_id uuid REFERENCES paliad.legal_sources(id),
|
||||
concept_id uuid REFERENCES paliad.deadline_concepts(id),
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.procedural_events(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.procedural_events IS
|
||||
'Procedural-event templates — the "what kind of step is this in the '
|
||||
'proceeding" hat of the legacy paliad.deadline_rules row. One row per '
|
||||
'unique submission_code, plus one synthetic row per NULL-submission_code '
|
||||
'rule (code prefix "null."). Slice B.1 t-paliad-273.';
|
||||
|
||||
COMMENT ON COLUMN paliad.procedural_events.event_kind IS
|
||||
'filing|reply|hearing|decision|order|other. NULLABLE for now — 89 '
|
||||
'rules in the live corpus have NULL event_type (structural / parent-only '
|
||||
'rows in the proceeding tree). A future slice can tighten to NOT NULL '
|
||||
'after these are reclassified.';
|
||||
|
||||
COMMENT ON COLUMN paliad.procedural_events.concept_id IS
|
||||
'Optional reference to a deadline_concepts row. N:1 — one concept may '
|
||||
'be shared by many procedural events (e.g. "Berufungsfrist" attaches to '
|
||||
'all four court-specific Berufung procedural events). Do NOT add UNIQUE.';
|
||||
|
||||
CREATE INDEX procedural_events_concept_id_idx ON paliad.procedural_events(concept_id);
|
||||
CREATE INDEX procedural_events_event_kind_idx ON paliad.procedural_events(event_kind);
|
||||
CREATE INDEX procedural_events_lifecycle_idx ON paliad.procedural_events(lifecycle_state);
|
||||
CREATE INDEX procedural_events_legal_source_idx ON paliad.procedural_events(legal_source_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. CREATE TABLE paliad.sequencing_rules
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
procedural_event_id uuid NOT NULL REFERENCES paliad.procedural_events(id),
|
||||
proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
parent_id uuid REFERENCES paliad.sequencing_rules(id),
|
||||
trigger_event_id bigint REFERENCES paliad.trigger_events(id),
|
||||
duration_value integer NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months',
|
||||
timing text DEFAULT 'after',
|
||||
alt_duration_value integer,
|
||||
alt_duration_unit text,
|
||||
alt_rule_code text,
|
||||
anchor_alt text,
|
||||
combine_op text,
|
||||
condition_expr jsonb,
|
||||
primary_party text,
|
||||
sequence_order integer NOT NULL DEFAULT 0,
|
||||
is_spawn boolean NOT NULL DEFAULT false,
|
||||
spawn_label text,
|
||||
spawn_proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
is_bilateral boolean NOT NULL DEFAULT false,
|
||||
is_court_set boolean NOT NULL DEFAULT false,
|
||||
priority text NOT NULL DEFAULT 'mandatory',
|
||||
rule_code text,
|
||||
rule_codes text[],
|
||||
deadline_notes text,
|
||||
deadline_notes_en text,
|
||||
choices_offered jsonb,
|
||||
applies_to_target text[],
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.sequencing_rules(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules IS
|
||||
'Sequencing-rule mechanics — the "how and when does this fire" hat of '
|
||||
'the legacy paliad.deadline_rules row. 1:1 with deadline_rules during '
|
||||
'the dual-write window; the id is inherited from deadline_rules.id so '
|
||||
'paliad.deadlines.rule_id FKs continue to resolve transitively. '
|
||||
'Slice B.1 t-paliad-273.';
|
||||
|
||||
COMMENT ON COLUMN paliad.sequencing_rules.primary_party IS
|
||||
'Per-rule override of procedural_events.primary_party_default. Same '
|
||||
'four-value vocab as deadline_rules.primary_party (mig 135 CHECK). '
|
||||
'NULL = use procedural-event default. A future slice can add the '
|
||||
'same CHECK here.';
|
||||
|
||||
CREATE INDEX sequencing_rules_pe_proc_lifecycle_idx
|
||||
ON paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state);
|
||||
CREATE INDEX sequencing_rules_parent_id_idx ON paliad.sequencing_rules(parent_id);
|
||||
CREATE INDEX sequencing_rules_trigger_event_idx ON paliad.sequencing_rules(trigger_event_id);
|
||||
CREATE INDEX sequencing_rules_proceeding_type_idx ON paliad.sequencing_rules(proceeding_type_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. ALTER paliad.deadlines — add link columns
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id),
|
||||
ADD COLUMN sequencing_rule_id uuid REFERENCES paliad.sequencing_rules(id);
|
||||
|
||||
COMMENT ON COLUMN paliad.deadlines.procedural_event_id IS
|
||||
'NULLABLE link to the procedural event this deadline instantiates. '
|
||||
'Added Slice B.1 (mig 136). B.2 dual-write populates it on every new '
|
||||
'deadline; B.3 cutover flips reads to use this instead of rule_id. '
|
||||
'rule_id stays in place until B.4 destructive drop.';
|
||||
COMMENT ON COLUMN paliad.deadlines.sequencing_rule_id IS
|
||||
'NULLABLE link to the sequencing rule. Same lifecycle as '
|
||||
'procedural_event_id — added Slice B.1, dual-written B.2, read in B.3, '
|
||||
'rule_id dropped in B.4.';
|
||||
|
||||
CREATE INDEX deadlines_procedural_event_id_idx ON paliad.deadlines(procedural_event_id);
|
||||
CREATE INDEX deadlines_sequencing_rule_id_idx ON paliad.deadlines(sequencing_rule_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. BACKFILL — legal_sources
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
||||
SELECT DISTINCT
|
||||
legal_source AS citation,
|
||||
COALESCE(NULLIF(split_part(legal_source, '.', 1), ''), 'other') AS jurisdiction
|
||||
FROM paliad.deadline_rules
|
||||
WHERE legal_source IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6. BACKFILL — procedural_events
|
||||
-- (a) codes-bearing branch: DISTINCT ON (submission_code) picks the
|
||||
-- lowest-id (tie-break sequence_order) deadline_rules row as the
|
||||
-- identity source per the design's §5.1 step 3.
|
||||
-- (b) NULL-code branch: one synthetic row per rule, code minted from
|
||||
-- the rule id's first 8 hex chars (sans dashes) — m's pick
|
||||
-- 2026-05-26 (paliadin instruction).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- (a) codes-bearing rules → one procedural_events row per distinct code
|
||||
INSERT INTO paliad.procedural_events
|
||||
(code, name, name_en, description, event_kind, primary_party_default,
|
||||
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
|
||||
SELECT
|
||||
src.submission_code,
|
||||
src.name,
|
||||
src.name_en,
|
||||
src.description,
|
||||
src.event_type,
|
||||
src.primary_party,
|
||||
ls.id,
|
||||
src.concept_id,
|
||||
src.lifecycle_state,
|
||||
src.published_at,
|
||||
src.is_active
|
||||
FROM (
|
||||
SELECT DISTINCT ON (submission_code)
|
||||
submission_code, name, name_en, description, event_type,
|
||||
primary_party, concept_id, legal_source, lifecycle_state,
|
||||
published_at, is_active
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
ORDER BY submission_code, id, sequence_order
|
||||
) src
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.citation = src.legal_source;
|
||||
|
||||
-- (b) NULL-code rules → one synthetic procedural_events row each
|
||||
INSERT INTO paliad.procedural_events
|
||||
(code, name, name_en, description, event_kind, primary_party_default,
|
||||
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
|
||||
SELECT
|
||||
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8) AS code,
|
||||
dr.name,
|
||||
dr.name_en,
|
||||
dr.description,
|
||||
dr.event_type,
|
||||
dr.primary_party,
|
||||
ls.id,
|
||||
dr.concept_id,
|
||||
dr.lifecycle_state,
|
||||
dr.published_at,
|
||||
dr.is_active
|
||||
FROM paliad.deadline_rules dr
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.citation = dr.legal_source
|
||||
WHERE dr.submission_code IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 7. BACKFILL — sequencing_rules
|
||||
-- 1:1 with deadline_rules. id inherited so deadlines.rule_id FKs
|
||||
-- continue to resolve through the dual-write window (design §5.1
|
||||
-- step 4). procedural_event_id resolved by JOIN on the (real or
|
||||
-- synthetic) code.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.sequencing_rules
|
||||
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
|
||||
duration_value, duration_unit, timing,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
||||
combine_op, condition_expr, primary_party, sequence_order,
|
||||
is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
is_bilateral, is_court_set, priority,
|
||||
rule_code, rule_codes, deadline_notes, deadline_notes_en,
|
||||
choices_offered, applies_to_target,
|
||||
lifecycle_state, draft_of, published_at, is_active,
|
||||
created_at, updated_at)
|
||||
SELECT
|
||||
dr.id,
|
||||
pe.id,
|
||||
dr.proceeding_type_id,
|
||||
dr.parent_id,
|
||||
dr.trigger_event_id,
|
||||
dr.duration_value, dr.duration_unit, dr.timing,
|
||||
dr.alt_duration_value, dr.alt_duration_unit, dr.alt_rule_code, dr.anchor_alt,
|
||||
dr.combine_op, dr.condition_expr, dr.primary_party, dr.sequence_order,
|
||||
dr.is_spawn, dr.spawn_label, dr.spawn_proceeding_type_id,
|
||||
dr.is_bilateral, dr.is_court_set, dr.priority,
|
||||
dr.rule_code, dr.rule_codes, dr.deadline_notes, dr.deadline_notes_en,
|
||||
dr.choices_offered, dr.applies_to_target,
|
||||
dr.lifecycle_state,
|
||||
-- draft_of is a self-FK on deadline_rules; preserve as a self-FK on
|
||||
-- sequencing_rules since the inherited ids are stable across both.
|
||||
dr.draft_of,
|
||||
dr.published_at, dr.is_active,
|
||||
dr.created_at, dr.updated_at
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.procedural_events pe
|
||||
ON pe.code = COALESCE(
|
||||
dr.submission_code,
|
||||
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 8. BACKFILL — paliad.deadlines link columns
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET procedural_event_id = sr.procedural_event_id,
|
||||
sequencing_rule_id = sr.id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE d.rule_id = sr.id;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 9. POST pass — integrity assertions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_dr_total int;
|
||||
v_dr_codes_distinct int;
|
||||
v_dr_codes_null int;
|
||||
v_dr_legal_distinct int;
|
||||
v_pe_total int;
|
||||
v_sr_total int;
|
||||
v_ls_total int;
|
||||
v_orphan_pe int;
|
||||
v_dup_synthetic int;
|
||||
v_deadlines_linked int;
|
||||
v_deadlines_total int;
|
||||
v_pe_missing_ls int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_dr_total FROM paliad.deadline_rules;
|
||||
SELECT COUNT(DISTINCT submission_code)
|
||||
INTO v_dr_codes_distinct FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_dr_codes_null FROM paliad.deadline_rules WHERE submission_code IS NULL;
|
||||
SELECT COUNT(DISTINCT legal_source)
|
||||
INTO v_dr_legal_distinct FROM paliad.deadline_rules WHERE legal_source IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_pe_total FROM paliad.procedural_events;
|
||||
SELECT COUNT(*) INTO v_sr_total FROM paliad.sequencing_rules;
|
||||
SELECT COUNT(*) INTO v_ls_total FROM paliad.legal_sources;
|
||||
SELECT COUNT(*) INTO v_deadlines_total FROM paliad.deadlines;
|
||||
SELECT COUNT(*) INTO v_deadlines_linked FROM paliad.deadlines WHERE procedural_event_id IS NOT NULL;
|
||||
|
||||
-- a. procedural_events row count = distinct_codes + null_codes
|
||||
IF v_pe_total <> v_dr_codes_distinct + v_dr_codes_null THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: procedural_events count mismatch — got %, expected % (% distinct codes + % null-code rules)',
|
||||
v_pe_total, v_dr_codes_distinct + v_dr_codes_null, v_dr_codes_distinct, v_dr_codes_null;
|
||||
END IF;
|
||||
|
||||
-- b. sequencing_rules row count = deadline_rules row count (1:1)
|
||||
IF v_sr_total <> v_dr_total THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: sequencing_rules count mismatch — got %, expected % (1:1 with deadline_rules)',
|
||||
v_sr_total, v_dr_total;
|
||||
END IF;
|
||||
|
||||
-- c. legal_sources row count = distinct legal_source in deadline_rules
|
||||
IF v_ls_total <> v_dr_legal_distinct THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: legal_sources count mismatch — got %, expected % (distinct legal_source)',
|
||||
v_ls_total, v_dr_legal_distinct;
|
||||
END IF;
|
||||
|
||||
-- d. every sequencing_rules row's procedural_event_id resolves
|
||||
SELECT COUNT(*)
|
||||
INTO v_orphan_pe
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.id IS NULL;
|
||||
IF v_orphan_pe > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % sequencing_rules row(s) have no resolving procedural_event_id', v_orphan_pe;
|
||||
END IF;
|
||||
|
||||
-- e. no two synthetic codes collide (would have crashed the INSERT
|
||||
-- via UNIQUE, but assert again for clarity — collision among 78
|
||||
-- UUIDs at 8 hex chars is ~6e-7 probability)
|
||||
SELECT COUNT(*)
|
||||
INTO v_dup_synthetic
|
||||
FROM (
|
||||
SELECT code, COUNT(*) AS n
|
||||
FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
GROUP BY code
|
||||
HAVING COUNT(*) > 1
|
||||
) d;
|
||||
IF v_dup_synthetic > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % synthetic codes collided. '
|
||||
'Re-run with a longer substring (16 hex chars instead of 8) '
|
||||
'or full uuid in the code-mint expression.', v_dup_synthetic;
|
||||
END IF;
|
||||
|
||||
-- f. every procedural_events.legal_source_id either resolves or is
|
||||
-- NULL (NULL is fine — 119 of 231 rules have NULL legal_source)
|
||||
SELECT COUNT(*)
|
||||
INTO v_pe_missing_ls
|
||||
FROM paliad.procedural_events pe
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
|
||||
WHERE pe.legal_source_id IS NOT NULL
|
||||
AND ls.id IS NULL;
|
||||
IF v_pe_missing_ls > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % procedural_events row(s) reference a missing legal_sources id', v_pe_missing_ls;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 136] POST: legal_sources=%, procedural_events=%, sequencing_rules=%, deadlines=% (% linked)',
|
||||
v_ls_total, v_pe_total, v_sr_total, v_deadlines_total, v_deadlines_linked;
|
||||
RAISE NOTICE '[mig 136] integrity OK — backfill complete. '
|
||||
'deadline_rules untouched (1:1 with sequencing_rules; '
|
||||
'ready for B.2 dual-write).';
|
||||
END $$;
|
||||
18
internal/db/migrations/137_proceeding_role_labels.down.sql
Normal file
18
internal/db/migrations/137_proceeding_role_labels.down.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 137_proceeding_role_labels — DOWN
|
||||
--
|
||||
-- Drops the 4 role-label columns. Backfilled data is lost on
|
||||
-- down-migration; that's acceptable because the frontend renderer
|
||||
-- falls back to the default labels ("Klägerseite" / "Beklagtenseite")
|
||||
-- when the columns are absent.
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_reactive_label_en;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_reactive_label_de;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_proactive_label_en;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_proactive_label_de;
|
||||
137
internal/db/migrations/137_proceeding_role_labels.up.sql
Normal file
137
internal/db/migrations/137_proceeding_role_labels.up.sql
Normal file
@@ -0,0 +1,137 @@
|
||||
-- 137_proceeding_role_labels — t-paliad-301, m/paliad#132
|
||||
--
|
||||
-- Bug A fix: per-proceeding role labels so the Verfahrensablauf side
|
||||
-- selector can render "Berufungskläger / Berufungsbeklagter" for the
|
||||
-- unified UPC Berufung tile instead of the generic "Klägerseite /
|
||||
-- Beklagtenseite".
|
||||
--
|
||||
-- Four new optional columns on paliad.proceeding_types. NULL on a
|
||||
-- column falls back to the language-default ("Klägerseite" / "Claimant
|
||||
-- side" / "Beklagtenseite" / "Defendant side") in the frontend renderer.
|
||||
-- Only the proceedings whose role-naming actually differs get a backfill.
|
||||
--
|
||||
-- Live-DB audit (mcp__supabase__execute_sql) before drafting:
|
||||
-- - paliad.proceeding_types has 14 columns; the 4 target columns do
|
||||
-- NOT exist (zero name collisions).
|
||||
-- - Zero triggers on paliad.proceeding_types. No audit_reason
|
||||
-- setup needed.
|
||||
-- - No updated_at / created_at on the table — DO NOT include
|
||||
-- timestamp UPDATEs (lesson from mig 134 HOTFIX 3).
|
||||
--
|
||||
-- ADDITIVE ONLY. ALTER + UPDATE statements; no CHECK constraints
|
||||
-- (the columns are free-text labels, validated at the application layer).
|
||||
-- Down migration drops the 4 columns.
|
||||
--
|
||||
-- See m/paliad#132 for the full design rationale + the role-label
|
||||
-- matrix per proceeding code.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Schema additions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_proactive_label_de text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_proactive_label_en text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_reactive_label_de text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_reactive_label_en text NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_de IS
|
||||
'DE label for the proactive (claimant-equivalent) side of this '
|
||||
'proceeding. NULL = renderer falls back to "Klägerseite". '
|
||||
't-paliad-301 / m/paliad#132 Bug A.';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_en IS
|
||||
'EN label for the proactive side. NULL = "Claimant side".';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_de IS
|
||||
'DE label for the reactive (defendant-equivalent) side. NULL = '
|
||||
'"Beklagtenseite".';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_en IS
|
||||
'EN label for the reactive side. NULL = "Defendant side".';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Audit-first NOTICE pass.
|
||||
--
|
||||
-- Lists which proceeding_types are about to receive a backfill so
|
||||
-- the operator sees the scope before the UPDATE fires. NULL columns
|
||||
-- on every other row stay NULL (the frontend falls back to defaults).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
backfill_count int := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 137] Proceedings that will receive role-label backfill:';
|
||||
FOR rec IN
|
||||
SELECT code, name
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.unified', 'upc.rev.cfi', 'epa.opp.opd', 'epa.opp.boa')
|
||||
ORDER BY code
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 137] % %', rec.code, rec.name;
|
||||
backfill_count := backfill_count + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 137] Total: % proceedings (others stay NULL → renderer default)', backfill_count;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Backfill.
|
||||
--
|
||||
-- Per the design matrix in m/paliad#132:
|
||||
-- - upc.apl.unified → Berufungskläger / Berufungsbeklagter / Appellant / Appellee
|
||||
-- - upc.rev.cfi → Antragsteller (Nichtigkeit) / Antragsgegner (Nichtigkeit) /
|
||||
-- Revocation claimant / Revocation defendant
|
||||
-- - epa.opp.opd → Einsprechende(r) / Patentinhaber(in) /
|
||||
-- Opponent / Patentee
|
||||
-- - epa.opp.boa → Einsprechende(r) / Patentinhaber(in) /
|
||||
-- Opponent / Patentee
|
||||
-- - (others) → stay NULL → frontend defaults
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Berufungskläger',
|
||||
role_reactive_label_de = 'Berufungsbeklagter',
|
||||
role_proactive_label_en = 'Appellant',
|
||||
role_reactive_label_en = 'Appellee'
|
||||
WHERE code = 'upc.apl.unified';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Antragsteller (Nichtigkeit)',
|
||||
role_reactive_label_de = 'Antragsgegner (Nichtigkeit)',
|
||||
role_proactive_label_en = 'Revocation claimant',
|
||||
role_reactive_label_en = 'Revocation defendant'
|
||||
WHERE code = 'upc.rev.cfi';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Einsprechende(r)',
|
||||
role_reactive_label_de = 'Patentinhaber(in)',
|
||||
role_proactive_label_en = 'Opponent',
|
||||
role_reactive_label_en = 'Patentee'
|
||||
WHERE code IN ('epa.opp.opd', 'epa.opp.boa');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Post-migration NOTICE — informational only.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 137] post: backfilled role-label distribution:';
|
||||
FOR rec IN
|
||||
SELECT code,
|
||||
role_proactive_label_de,
|
||||
role_reactive_label_de
|
||||
FROM paliad.proceeding_types
|
||||
WHERE role_proactive_label_de IS NOT NULL
|
||||
ORDER BY code
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 137] % proactive=% reactive=%',
|
||||
rec.code, rec.role_proactive_label_de, rec.role_reactive_label_de;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -0,0 +1,71 @@
|
||||
-- 138_appeal_target_backfill_merits_order DOWN — t-paliad-303, m/paliad#134
|
||||
--
|
||||
-- Removes 'schadensbemessung' from the merits-track rules and
|
||||
-- 'bucheinsicht' from the order-track rules, restoring the pre-137
|
||||
-- shape (endentscheidung-only / anordnung-only / kostenentscheidung-only).
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
|
||||
-- paliad.deadline_rules).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 138 DOWN: t-paliad-303 — strip Schadensbemessung/Bucheinsicht from applies_to_target per m/paliad#134',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Strip new targets via array_remove.
|
||||
--
|
||||
-- WHERE clauses pinned to upc.apl.unified to avoid touching unrelated
|
||||
-- rules that might have been added later under other proceeding types.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- 1a. Remove schadensbemessung from merits-track rows.
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = array_remove(dr.applies_to_target, 'schadensbemessung')
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'schadensbemessung' = ANY(dr.applies_to_target);
|
||||
|
||||
-- 1b. Remove bucheinsicht from order-track rows.
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = array_remove(dr.applies_to_target, 'bucheinsicht')
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'bucheinsicht' = ANY(dr.applies_to_target);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Sanity check — no row may carry the new targets after the down.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
schad_left int;
|
||||
buch_left int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO schad_left
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'schadensbemessung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO buch_left
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'bucheinsicht' = ANY(dr.applies_to_target);
|
||||
|
||||
IF schad_left > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry schadensbemessung', schad_left;
|
||||
END IF;
|
||||
IF buch_left > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry bucheinsicht', buch_left;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 138 DOWN] stripped schadensbemessung + bucheinsicht from upc.apl.unified rules';
|
||||
END $$;
|
||||
@@ -0,0 +1,232 @@
|
||||
-- 138_appeal_target_backfill_merits_order — t-paliad-303, m/paliad#134
|
||||
--
|
||||
-- Slice B1 (mig 134) introduced the unified upc.apl.unified proceeding type
|
||||
-- with 5 appeal_target enum values: endentscheidung, kostenentscheidung,
|
||||
-- anordnung, schadensbemessung, bucheinsicht. The first three each carry
|
||||
-- rules; schadensbemessung and bucheinsicht returned an empty timeline
|
||||
-- because no rules referenced them yet.
|
||||
--
|
||||
-- m's 2026-05-26 decision (#134): extend applies_to_target on the existing
|
||||
-- rules — Schadensbemessung := merits track (R.224 anchored on R.118
|
||||
-- substantive decisions), Bucheinsicht := order track (R.220.2 +
|
||||
-- R.224.2.b + R.235.2 + R.237 + R.238.2 etc.). Legal premise verified
|
||||
-- against the 16 live rules — every endentscheidung rule is a generic
|
||||
-- R.224 merits step, every anordnung rule is a generic R.220/224/235/237/
|
||||
-- 238 order step. No rule carries content specific to a particular kind
|
||||
-- of underlying decision/order. Audit on the comment trail of #134.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
|
||||
-- paliad.deadline_rules — both UPDATEs below trigger it).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 138: t-paliad-303 — extend applies_to_target for Schadensbemessung (merits) + Bucheinsicht (order) per m/paliad#134',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Audit-first DO block.
|
||||
--
|
||||
-- Resolve upc.apl.unified, count the rows we are about to touch, and
|
||||
-- RAISE EXCEPTION if anything looks wrong (proceeding type missing,
|
||||
-- merits/order rule counts off, or a rule already carries the new
|
||||
-- target — which would mean an earlier partial run).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
upc_apl_id int;
|
||||
merits_count int;
|
||||
order_count int;
|
||||
schad_already int;
|
||||
buch_already int;
|
||||
BEGIN
|
||||
SELECT id INTO upc_apl_id
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.unified';
|
||||
IF upc_apl_id IS NULL THEN
|
||||
RAISE EXCEPTION '[mig 138] upc.apl.unified proceeding_type not found — mig 134 must run first';
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 138] upc.apl.unified proceeding_type_id = %', upc_apl_id;
|
||||
|
||||
SELECT COUNT(*) INTO merits_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'endentscheidung' = ANY(applies_to_target);
|
||||
SELECT COUNT(*) INTO order_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'anordnung' = ANY(applies_to_target);
|
||||
|
||||
RAISE NOTICE '[mig 138] live counts: endentscheidung=% anordnung=%', merits_count, order_count;
|
||||
IF merits_count <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] expected 7 endentscheidung rules under upc.apl.unified, got %', merits_count;
|
||||
END IF;
|
||||
IF order_count <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] expected 7 anordnung rules under upc.apl.unified, got %', order_count;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO schad_already
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'schadensbemessung' = ANY(applies_to_target);
|
||||
SELECT COUNT(*) INTO buch_already
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'bucheinsicht' = ANY(applies_to_target);
|
||||
IF schad_already > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138] % rules already carry schadensbemessung — partial run?', schad_already;
|
||||
END IF;
|
||||
IF buch_already > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138] % rules already carry bucheinsicht — partial run?', buch_already;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 138] rules to extend with schadensbemessung (merits track):';
|
||||
FOR rec IN
|
||||
SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.proceeding_type_id = upc_apl_id
|
||||
AND dr.is_active = true
|
||||
AND 'endentscheidung' = ANY(dr.applies_to_target)
|
||||
ORDER BY dr.sequence_order, dr.rule_code NULLS LAST
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 138] merits % % % pre=% → post=%',
|
||||
COALESCE(rec.rule_code, '(no-code)'),
|
||||
COALESCE(rec.legal_source, '(no-source)'),
|
||||
rec.name,
|
||||
rec.applies_to_target,
|
||||
rec.applies_to_target || 'schadensbemessung'::text;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE '[mig 138] rules to extend with bucheinsicht (order track):';
|
||||
FOR rec IN
|
||||
SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.proceeding_type_id = upc_apl_id
|
||||
AND dr.is_active = true
|
||||
AND 'anordnung' = ANY(dr.applies_to_target)
|
||||
ORDER BY dr.sequence_order, dr.rule_code NULLS LAST
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 138] order % % % pre=% → post=%',
|
||||
COALESCE(rec.rule_code, '(no-code)'),
|
||||
COALESCE(rec.legal_source, '(no-source)'),
|
||||
rec.name,
|
||||
rec.applies_to_target,
|
||||
rec.applies_to_target || 'bucheinsicht'::text;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Extend applies_to_target.
|
||||
--
|
||||
-- Narrow WHERE clauses key off upc.apl.unified + existing target +
|
||||
-- absence of new target, so the UPDATEs are idempotent in spirit
|
||||
-- (the audit block above already RAISE EXCEPTIONed if any row
|
||||
-- already had the new value).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- 2a. Schadensbemessung := merits track (7 rules expected).
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = applies_to_target || 'schadensbemessung'::text
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'endentscheidung' = ANY(dr.applies_to_target)
|
||||
AND NOT ('schadensbemessung' = ANY(dr.applies_to_target));
|
||||
|
||||
-- 2b. Bucheinsicht := order track (7 rules expected).
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = applies_to_target || 'bucheinsicht'::text
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'anordnung' = ANY(dr.applies_to_target)
|
||||
AND NOT ('bucheinsicht' = ANY(dr.applies_to_target));
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Post-migration sanity check.
|
||||
--
|
||||
-- Hard-fail on any divergence: the two new targets must each cover
|
||||
-- 7 rules, the original three targets must be unchanged in count,
|
||||
-- and no rule has lost its prior target.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
schad_post int;
|
||||
buch_post int;
|
||||
end_post int;
|
||||
anord_post int;
|
||||
cost_post int;
|
||||
target_distribution record;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO schad_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'schadensbemessung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO buch_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'bucheinsicht' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO end_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'endentscheidung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO anord_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'anordnung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO cost_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'kostenentscheidung' = ANY(dr.applies_to_target);
|
||||
|
||||
RAISE NOTICE '[mig 138] post: schadensbemessung=% bucheinsicht=% endentscheidung=% anordnung=% kostenentscheidung=%',
|
||||
schad_post, buch_post, end_post, anord_post, cost_post;
|
||||
|
||||
IF schad_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — expected 7 schadensbemessung rules, got %', schad_post;
|
||||
END IF;
|
||||
IF buch_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — expected 7 bucheinsicht rules, got %', buch_post;
|
||||
END IF;
|
||||
IF end_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — endentscheidung count drifted: expected 7, got %', end_post;
|
||||
END IF;
|
||||
IF anord_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — anordnung count drifted: expected 7, got %', anord_post;
|
||||
END IF;
|
||||
IF cost_post <> 2 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — kostenentscheidung count drifted: expected 2, got %', cost_post;
|
||||
END IF;
|
||||
|
||||
FOR target_distribution IN
|
||||
SELECT unnest(applies_to_target) AS target, COUNT(*) AS n
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true
|
||||
GROUP BY unnest(applies_to_target)
|
||||
ORDER BY 1
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 138] post: applies_to_target=% count=%',
|
||||
target_distribution.target, target_distribution.n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
13
internal/db/migrations/145_scenarios.down.sql
Normal file
13
internal/db/migrations/145_scenarios.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- 145_scenarios — DOWN
|
||||
--
|
||||
-- Reverses mig 145. Drops the FK on paliad.projects, the table, the
|
||||
-- trigger function, and the RLS policies (CASCADE on table drop kills
|
||||
-- policies). Any data in paliad.scenarios is lost on down.
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP COLUMN IF EXISTS active_scenario_id;
|
||||
|
||||
DROP TRIGGER IF EXISTS scenarios_touch_updated_at_trg ON paliad.scenarios;
|
||||
DROP FUNCTION IF EXISTS paliad.scenarios_touch_updated_at();
|
||||
|
||||
DROP TABLE IF EXISTS paliad.scenarios CASCADE;
|
||||
170
internal/db/migrations/145_scenarios.up.sql
Normal file
170
internal/db/migrations/145_scenarios.up.sql
Normal file
@@ -0,0 +1,170 @@
|
||||
-- 145_scenarios — Slice D, m/paliad#124 §5 (revised)
|
||||
--
|
||||
-- Creates paliad.scenarios + paliad.projects.active_scenario_id FK.
|
||||
-- A scenario is a named composition of existing proceedings + flags
|
||||
-- + per-card choices + anchor dates the user can switch between for
|
||||
-- a project (project_id NOT NULL) OR save as an abstract template on
|
||||
-- /tools/verfahrensablauf (project_id IS NULL).
|
||||
--
|
||||
-- m's 2026-05-26 picks (AskUserQuestion round, doc commit 6e58595):
|
||||
-- Q1: composition shape → primary+spawned (v1); multi-proceeding
|
||||
-- peer compose is the v2 goal. spec.jsonb
|
||||
-- architected for N entries from day 1.
|
||||
-- Q2: scope → per-project + abstract.
|
||||
-- Q3: trigger dates → per-anchor overrides over one base date.
|
||||
-- Q4: storage → NEW paliad.scenarios table with jsonb
|
||||
-- spec (NOT a project_event_choices column
|
||||
-- extension).
|
||||
--
|
||||
-- "users should not add their own rules" (m, t-paliad-301) — scenarios
|
||||
-- compose existing rules, never author new ones. spec.proceedings[*].code
|
||||
-- must resolve to an existing active paliad.proceeding_types row;
|
||||
-- spec.proceedings[*].anchor_overrides keys must resolve to existing
|
||||
-- submission_codes. Validation happens at the application layer
|
||||
-- (ScenarioService.validateSpec) — not in DB CHECK constraints (too
|
||||
-- expensive to express in pure SQL).
|
||||
--
|
||||
-- Migration number: 145. Coordination check 2026-05-26 17:38: curie's
|
||||
-- B.2-B.6 migrations land in the 139-143 range. 144 reserved as buffer.
|
||||
-- 145 is the next safe claim.
|
||||
--
|
||||
-- ADDITIVE ONLY: CREATE TABLE, ALTER ADD COLUMN, indexes, RLS policies.
|
||||
-- Down drops everything. No backfill (zero existing scenarios on day 1).
|
||||
--
|
||||
-- See docs/design-litigation-planner-2026-05-26.md §5 + §18.4 for the
|
||||
-- design.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. The scenarios table
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.scenarios (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- project_id NULL = abstract scenario (saved Verfahrensablauf
|
||||
-- template, no Akte). project_id NOT NULL = scenario attached to
|
||||
-- a real Akte.
|
||||
project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NULL,
|
||||
-- spec carries the full composition. Shape documented in the
|
||||
-- design doc §5; the application validates structure before write.
|
||||
spec jsonb NOT NULL,
|
||||
created_by uuid NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Within a single project, scenario names are unique. Abstract
|
||||
-- scenarios are unique per (created_by, name) so two users can
|
||||
-- each keep a "with_ccr" template without colliding. NULLS NOT
|
||||
-- DISTINCT means a single user can have one "name" per
|
||||
-- (project_id, created_by) tuple, where NULL project_id +
|
||||
-- NULL created_by is a single global namespace (used only by
|
||||
-- seed / system scenarios — none today).
|
||||
CONSTRAINT scenarios_unique_per_scope
|
||||
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name),
|
||||
|
||||
-- Non-empty name.
|
||||
CONSTRAINT scenarios_name_nonempty CHECK (char_length(name) > 0),
|
||||
|
||||
-- Non-empty spec — at least an object. The application checks
|
||||
-- structure (version, proceedings[], base_trigger_date format).
|
||||
CONSTRAINT scenarios_spec_object CHECK (jsonb_typeof(spec) = 'object')
|
||||
);
|
||||
|
||||
CREATE INDEX scenarios_project_id_idx
|
||||
ON paliad.scenarios(project_id) WHERE project_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX scenarios_abstract_user_idx
|
||||
ON paliad.scenarios(created_by) WHERE project_id IS NULL;
|
||||
|
||||
COMMENT ON TABLE paliad.scenarios IS
|
||||
'Named compositions of existing proceedings + flags + per-card '
|
||||
'choices + anchor dates. project_id NULL = abstract template; '
|
||||
'project_id NOT NULL = attached to an Akte. Design: '
|
||||
'docs/design-litigation-planner-2026-05-26.md §5. (Slice D)';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.spec IS
|
||||
'jsonb composition spec. Shape: {version: int, base_trigger_date: '
|
||||
'ISO date, proceedings: [{code, role, flags[], per_card_choices, '
|
||||
'anchor_overrides, skip_rules[]}, ...]}. Validated at write-time '
|
||||
'by ScenarioService.validateSpec.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. paliad.projects.active_scenario_id FK
|
||||
--
|
||||
-- NULL = use today's ad-hoc per-card choice state from
|
||||
-- paliad.project_event_choices (pre-scenario behaviour preserved).
|
||||
-- Non-NULL = the project's current SmartTimeline / Akte-Fristenrechner
|
||||
-- render reads from this scenario's spec instead.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN active_scenario_id uuid NULL
|
||||
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.active_scenario_id IS
|
||||
'FK to paliad.scenarios. NULL = read choices from '
|
||||
'paliad.project_event_choices (legacy). Non-NULL = read from the '
|
||||
'pointed scenario.spec.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. RLS — mirror paliad.project_event_choices's pattern (mig 129).
|
||||
--
|
||||
-- Project-scoped scenarios (project_id NOT NULL) inherit team visibility
|
||||
-- via paliad.can_see_project. Abstract scenarios (project_id IS NULL)
|
||||
-- are private to created_by — only the author can read / write them.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.scenarios ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Project-scoped: team visibility.
|
||||
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
|
||||
CREATE POLICY scenarios_project_select ON paliad.scenarios
|
||||
FOR SELECT
|
||||
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
|
||||
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));
|
||||
|
||||
-- Abstract: owner-only.
|
||||
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
|
||||
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
|
||||
FOR SELECT
|
||||
USING (project_id IS NULL AND created_by = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
|
||||
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());
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. updated_at trigger (mirrors other paliad tables that carry
|
||||
-- updated_at — keep it in lockstep with row mutations).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.scenarios_touch_updated_at()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER scenarios_touch_updated_at_trg
|
||||
BEFORE UPDATE ON paliad.scenarios
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. Informational NOTICE — schema-only migration, zero rows added.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 145] paliad.scenarios created (0 rows; awaits API usage)';
|
||||
RAISE NOTICE '[mig 145] paliad.projects.active_scenario_id added (all rows NULL initially)';
|
||||
END $$;
|
||||
@@ -299,21 +299,6 @@ func handleAdminPreviewRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/export-migrations?since=<audit_id>
|
||||
func handleAdminExportRuleMigrations(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
since := r.URL.Query().Get("since")
|
||||
out, err := dbSvc.ruleEditor.ExportMigrationsSince(r.Context(), since)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page handlers — serve the static SPA shells. Auth + admin gate live
|
||||
// at the route registration in handlers.go.
|
||||
@@ -327,10 +312,6 @@ func handleAdminRulesEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-edit.html")
|
||||
}
|
||||
|
||||
func handleAdminRulesExportPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-export.html")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// helpers
|
||||
// =============================================================================
|
||||
|
||||
@@ -63,6 +63,20 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// wins (what-if exploration overrides the saved state).
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,omitempty"`
|
||||
// t-paliad-290 (m/paliad#122): re-surface previously-hidden
|
||||
// optional cards. When true the calculator marks skipped rows
|
||||
// with UIDeadline.IsHidden instead of dropping them; descendants
|
||||
// stay in the result list. Default false preserves the legacy
|
||||
// suppression. HiddenCount on the response is independent.
|
||||
IncludeHidden bool `json:"includeHidden,omitempty"`
|
||||
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC
|
||||
// Berufung (upc.apl) timeline to the rule subset whose
|
||||
// applies_to_target contains the requested slug. Empty = no
|
||||
// filter. Valid values: endentscheidung | kostenentscheidung
|
||||
// | anordnung | schadensbemessung | bucheinsicht. Unknown
|
||||
// slugs are silently dropped (no filter) so a stale frontend
|
||||
// chip doesn't 400 the request.
|
||||
AppealTarget string `json:"appealTarget,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -109,6 +123,8 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
@@ -120,6 +120,11 @@ type Services struct {
|
||||
// the Verfahrensablauf timeline.
|
||||
EventChoice *services.EventChoiceService
|
||||
|
||||
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions
|
||||
// per project or as abstract templates. Nil when DATABASE_URL is
|
||||
// unset; the /api/scenarios routes return 503 in that case.
|
||||
Scenario *services.ScenarioService
|
||||
|
||||
// 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
|
||||
@@ -184,6 +189,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
backup: svc.Backup,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +452,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PUT /api/projects/{id}/event-choices", handlePutProjectEventChoice)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}", handleDeleteProjectEventChoice)
|
||||
|
||||
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions
|
||||
// per project or as abstract templates on /tools/verfahrensablauf.
|
||||
protected.HandleFunc("GET /api/scenarios", handleScenariosList)
|
||||
protected.HandleFunc("GET /api/scenarios/{id}", handleScenarioGet)
|
||||
protected.HandleFunc("POST /api/scenarios", handleScenarioCreate)
|
||||
protected.HandleFunc("PATCH /api/scenarios/{id}", handleScenarioPatch)
|
||||
protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete)
|
||||
protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario)
|
||||
|
||||
// Partner units (structural partner-led units; legacy "Dezernate").
|
||||
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
|
||||
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)
|
||||
@@ -458,6 +473,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// t-paliad-139 — set unit_role on a member.
|
||||
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
|
||||
|
||||
protected.HandleFunc("GET /api/parties/search", handlePartiesSearch)
|
||||
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
|
||||
|
||||
// Phase F — Appointments (appointments)
|
||||
@@ -669,10 +685,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// t-paliad-191 Slice 11a — admin rule-editor API.
|
||||
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
|
||||
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
|
||||
protected.HandleFunc("GET /admin/rules/export", adminGate(users, gateOnboarded(handleAdminRulesExportPage)))
|
||||
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
|
||||
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
|
||||
protected.HandleFunc("GET /admin/api/rules/export-migrations", adminGate(users, handleAdminExportRuleMigrations))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
|
||||
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
|
||||
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
|
||||
|
||||
@@ -71,6 +71,9 @@ type dbServices struct {
|
||||
|
||||
// t-paliad-265 — per-event-card optional choices.
|
||||
eventChoice *services.EventChoiceService
|
||||
|
||||
// Slice D — named scenario compositions (m/paliad#124 §5).
|
||||
scenario *services.ScenarioService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
@@ -701,6 +704,31 @@ func handleCreateParty(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
// GET /api/parties/search?q=...
|
||||
//
|
||||
// Cross-project party picker for the submission-draft editor
|
||||
// (t-paliad-287). Returns up to 25 parties from every project the
|
||||
// caller can see, matched by case-insensitive substring on name or
|
||||
// representative. Empty q returns the 20 most-recently-updated rows so
|
||||
// the picker isn't blank on first open. Visibility is enforced in the
|
||||
// service layer via the same predicate every project-scoped read uses.
|
||||
func handlePartiesSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
hits, err := dbSvc.parties.Search(r.Context(), uid, q, 25)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": hits})
|
||||
}
|
||||
|
||||
// DELETE /api/parties/{id}
|
||||
func handleDeleteParty(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
|
||||
216
internal/handlers/scenarios.go
Normal file
216
internal/handlers/scenarios.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// Slice D (m/paliad#124 §5, mig 145) — REST endpoints for paliad.scenarios.
|
||||
//
|
||||
// Routes (registered in handlers.go):
|
||||
//
|
||||
// GET /api/scenarios?project=<id> — list project's scenarios
|
||||
// GET /api/scenarios?abstract=true — list caller's abstract scenarios
|
||||
// GET /api/scenarios/{id} — fetch one
|
||||
// POST /api/scenarios — create
|
||||
// PATCH /api/scenarios/{id} — partial update
|
||||
// PUT /api/projects/{id}/active-scenario — set/clear active scenario
|
||||
// DELETE /api/scenarios/{id} — remove
|
||||
//
|
||||
// All endpoints require auth; visibility is enforced by
|
||||
// ScenarioService.requireProjectVisible / requireVisible.
|
||||
|
||||
func requireScenarioService(w http.ResponseWriter) bool {
|
||||
if dbSvc == nil || dbSvc.scenario == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Szenarien sind vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// scenarioErrorToStatus maps service errors to HTTP statuses. Mirrors
|
||||
// the patterns in projects.go and event_choices.go.
|
||||
func scenarioErrorToStatus(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, lp.ErrUnknownScenario), errors.Is(err, services.ErrScenarioNotVisible):
|
||||
return http.StatusNotFound, "Szenario nicht gefunden"
|
||||
case errors.Is(err, services.ErrInvalidInput), errors.Is(err, lp.ErrInvalidScenario), errors.Is(err, lp.ErrScenarioNoPrimary):
|
||||
return http.StatusBadRequest, err.Error()
|
||||
}
|
||||
return http.StatusInternalServerError, err.Error()
|
||||
}
|
||||
|
||||
// handleScenariosList — GET /api/scenarios?project=<uuid> OR ?abstract=true.
|
||||
func handleScenariosList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
abstract := r.URL.Query().Get("abstract") == "true"
|
||||
projectStr := r.URL.Query().Get("project")
|
||||
switch {
|
||||
case abstract:
|
||||
out, err := dbSvc.scenario.ListAbstractForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
case projectStr != "":
|
||||
pid, err := uuid.Parse(projectStr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenario.ListForProject(r.Context(), uid, pid)
|
||||
if err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "?project=<uuid> oder ?abstract=true erforderlich",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleScenarioGet — GET /api/scenarios/{id}.
|
||||
func handleScenarioGet(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(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.scenario.Get(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleScenarioCreate — POST /api/scenarios.
|
||||
func handleScenarioCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.CreateScenarioInput
|
||||
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.scenario.Create(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// handleScenarioPatch — PATCH /api/scenarios/{id}.
|
||||
func handleScenarioPatch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(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.PatchScenarioInput
|
||||
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.scenario.Patch(r.Context(), uid, id, input)
|
||||
if err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleScenarioDelete — DELETE /api/scenarios/{id}.
|
||||
func handleScenarioDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(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
|
||||
}
|
||||
if err := dbSvc.scenario.Delete(r.Context(), uid, id); err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSetActiveScenario — PUT /api/projects/{id}/active-scenario.
|
||||
// Body: {"scenario_id": "<uuid>"} or {"scenario_id": null} to clear.
|
||||
func handleSetActiveScenario(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ScenarioID *uuid.UUID `json:"scenario_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.scenario.SetActive(r.Context(), uid, pid, body.ScenarioID); err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -4,63 +4,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
||||
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
|
||||
// from Postgres breaks the row scan with "unsupported Scan, storing
|
||||
// driver.Value type <nil> into type *json.RawMessage" — exactly the
|
||||
// error that hid every approval_request from the inbox when m's first
|
||||
// "create" lifecycle row arrived with NULL pre_image (m's dogfood
|
||||
// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column
|
||||
// fixes the scan and preserves inline JSON output (no base64 cast).
|
||||
type NullableJSON []byte
|
||||
|
||||
func (n *NullableJSON) Scan(value any) error {
|
||||
if value == nil {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
*n = append((*n)[:0], v...)
|
||||
return nil
|
||||
case string:
|
||||
*n = []byte(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("NullableJSON: unsupported scan type %T", value)
|
||||
}
|
||||
|
||||
func (n NullableJSON) Value() (driver.Value, error) {
|
||||
if len(n) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
func (n NullableJSON) MarshalJSON() ([]byte, error) {
|
||||
if len(n) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
func (n *NullableJSON) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
*n = append((*n)[:0], data...)
|
||||
return nil
|
||||
}
|
||||
// NullableJSON is a jsonb column that may be NULL. Canonical definition
|
||||
// (with sql.Scanner / driver.Valuer / json.Marshaler / json.Unmarshaler)
|
||||
// lives in pkg/litigationplanner — kept here as a type alias so every
|
||||
// existing models.NullableJSON reference continues to compile.
|
||||
type NullableJSON = litigationplanner.NullableJSON
|
||||
|
||||
// User extends auth.users with firm-specific profile fields. Created by the
|
||||
// Phase D onboarding flow; without a row here, the user can't see any Projects.
|
||||
@@ -584,112 +541,10 @@ type Party struct {
|
||||
}
|
||||
|
||||
// DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.).
|
||||
type DeadlineRule struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
||||
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
DurationValue int `db:"duration_value" json:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
||||
Timing *string `db:"timing" json:"timing,omitempty"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||
DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"`
|
||||
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
|
||||
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
|
||||
// this rule's concept (joined via paliad.deadline_concept_event_types
|
||||
// where is_default = true). Lets the deadline create form auto-populate
|
||||
// the Typ chip when the user picks this rule. Hydrated by the service
|
||||
// layer; not a column. NULL when the concept has no mapped event_type.
|
||||
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
|
||||
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Phase 3 unified-rule columns (mig 078, t-paliad-182).
|
||||
// Slice 9 (t-paliad-195) dropped the legacy IsMandatory /
|
||||
// IsOptional / ConditionFlag / ConditionRuleID fields — they
|
||||
// were superseded by Priority / ConditionExpr / IsCourtSet and
|
||||
// the unified calculator no longer reads them.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
||||
// proceeding-rooted rules. Exactly one of (proceeding_type_id,
|
||||
// trigger_event_id) is set after Slice 3.
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
|
||||
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
||||
// when is_spawn=true and this is non-NULL, the calculator follows
|
||||
// the FK and emits the target proceeding's root rule chain. Slice
|
||||
// 7 backfills the 8 live is_spawn=true rows.
|
||||
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
||||
|
||||
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
||||
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
||||
// NULL = single-anchor arithmetic.
|
||||
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
||||
|
||||
// ConditionExpr is the jsonb gating expression replacing
|
||||
// ConditionFlag (design §2.4). Grammar:
|
||||
// {"flag": "<name>"}
|
||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||
// {"op":"not", "args":[<node>]}
|
||||
// NULL or {} = unconditional. NullableJSON so a NULL column scans
|
||||
// cleanly (the row mishap that hid approval rows from the inbox
|
||||
// must not recur on rule rows).
|
||||
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
||||
|
||||
// Priority is the 4-way unified enum replacing
|
||||
// (IsMandatory, IsOptional). Values: 'mandatory' (default),
|
||||
// 'recommended', 'optional', 'informational'. Backfilled in
|
||||
// Slice 2; legacy callers read IsMandatory + IsOptional until
|
||||
// Slice 4 cuts them over.
|
||||
Priority string `db:"priority" json:"priority"`
|
||||
|
||||
// IsCourtSet replaces the runtime heuristic
|
||||
// (primary_party='court' OR event_type IN ('hearing','decision',
|
||||
// 'order')). Backfilled in Slice 2; legacy callers read the
|
||||
// heuristic until Slice 4.
|
||||
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
||||
|
||||
// LifecycleState drives the rule-editor flow (design §4.2):
|
||||
// 'draft' (admin work-in-progress) | 'published' (live, calculator-
|
||||
// visible) | 'archived' (historical, retained for audit). Every
|
||||
// pre-Slice-1 row defaults to 'published' via the migration.
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
|
||||
// DraftOf points at the published rule this draft will replace on
|
||||
// publish. NULL on published / archived rows. NULL also on net-
|
||||
// new drafts that have no prior published peer.
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
|
||||
// PublishedAt records when the row entered LifecycleState='published'.
|
||||
// NULL while draft, set on publish, retained through archive.
|
||||
// Distinct from UpdatedAt (moves on every edit).
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
|
||||
// ChoicesOffered declares which per-event-card choice-kinds this
|
||||
// rule offers on the Verfahrensablauf timeline (mig 129,
|
||||
// t-paliad-265). NULL = no caret affordance (default). See the
|
||||
// COMMENT on paliad.deadline_rules.choices_offered for the value
|
||||
// shape. The engine and the frontend both read this column.
|
||||
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
|
||||
}
|
||||
// Canonical definition lives in pkg/litigationplanner.Rule — kept here
|
||||
// as a type alias so every existing models.DeadlineRule reference (sqlx
|
||||
// scans, hydration, projection service) continues to compile.
|
||||
type DeadlineRule = litigationplanner.Rule
|
||||
|
||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||
// append-only audit log for every change to paliad.deadline_rules.
|
||||
@@ -721,43 +576,19 @@ type DeadlineRuleAudit struct {
|
||||
MigrationExported bool `db:"migration_exported" json:"migration_exported"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
|
||||
// management) or the lowercase dot-separated fristenrechner codes
|
||||
// (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md.
|
||||
type ProceedingType struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
||||
Category *string `db:"category" json:"category,omitempty"`
|
||||
DefaultColor string `db:"default_color" json:"default_color"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
|
||||
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
|
||||
// that fires when no rule has IsRootEvent=true. Populated for UPC Appeal
|
||||
// (mig 121) so the caption reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// NULL on most proceedings — they already carry a root rule.
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||
}
|
||||
// ProceedingType is one of the litigation conceptual codes (INF / REV /
|
||||
// CCR / APM / APP / AMD / ZPO_CIVIL) or the lowercase dot-separated
|
||||
// fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md. Canonical
|
||||
// definition lives in pkg/litigationplanner.ProceedingType — kept here
|
||||
// as a type alias so every existing models.ProceedingType reference
|
||||
// continues to compile.
|
||||
type ProceedingType = litigationplanner.ProceedingType
|
||||
|
||||
// TriggerEvent is a UPC procedural event that can start one or more deadlines
|
||||
// running. Powers the "Was kommt nach…" Fristenrechner mode (event-driven
|
||||
// lookup, mirrored from youpc data.events).
|
||||
type TriggerEvent struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameDE string `db:"name_de" json:"name_de"`
|
||||
Description string `db:"description" json:"description"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
||||
// whose semantic anchor is an event rather than a parent rule.
|
||||
// Canonical definition lives in pkg/litigationplanner.TriggerEvent.
|
||||
type TriggerEvent = litigationplanner.TriggerEvent
|
||||
|
||||
// EventDeadline is a single deadline that flows from a TriggerEvent. Mirrors
|
||||
// youpc data.deadlines + the trigger half of data.deadline_events.
|
||||
|
||||
@@ -35,10 +35,14 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
||||
choices_offered`
|
||||
choices_offered, applies_to_target`
|
||||
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active`
|
||||
category, default_color, sort_order, is_active,
|
||||
trigger_event_label_de, trigger_event_label_en,
|
||||
appeal_target,
|
||||
role_proactive_label_de, role_proactive_label_en,
|
||||
role_reactive_label_de, role_reactive_label_en`
|
||||
|
||||
// List returns active rules, optionally filtered by proceeding type.
|
||||
// Each row has ConceptDefaultEventTypeID hydrated from
|
||||
@@ -207,6 +211,44 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for the
|
||||
// given id set, keyed by id. Returns nil, nil for an empty input set so
|
||||
// callers can blindly forward whatever they accumulated. Inactive rows
|
||||
// are included — the conditional-label resolution in fristenrechner.go
|
||||
// surfaces the trigger event's display name even when the catalog row
|
||||
// has been retired, which is preferable to silently falling back to
|
||||
// the (wrong) parent_id name.
|
||||
//
|
||||
// Used by FristenrechnerService.Calculate to redirect a conditional
|
||||
// rule's "abhängig von …" chip from parent_id to trigger_event_id —
|
||||
// the actual semantic anchor for rules whose data-model parent is the
|
||||
// proceeding root but whose real trigger sits in the trigger_events
|
||||
// catalog (e.g. R.262(2) Erwiderung auf Vertraulichkeitsantrag → the
|
||||
// opposing party's confidentiality application). See m/paliad#126.
|
||||
func (s *DeadlineRuleService) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT id, code, name, name_de, description, is_active, created_at
|
||||
FROM paliad.trigger_events
|
||||
WHERE id IN (?)`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build trigger_events IN query: %w", err)
|
||||
}
|
||||
query = s.db.Rebind(query)
|
||||
|
||||
var rows []models.TriggerEvent
|
||||
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("load trigger_events by ids %v: %w", ids, err)
|
||||
}
|
||||
out := make(map[int64]models.TriggerEvent, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.ID] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListByTriggerEvent returns active rules scoped to a single trigger
|
||||
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
|
||||
// These rules carry proceeding_type_id IS NULL (event-rooted) and have
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// DeadlineSearchService backs the unified Fristenrechner search bar
|
||||
@@ -921,130 +923,15 @@ func roundScore(v float64) float64 {
|
||||
return float64(int(v*10000+0.5)) / 10000
|
||||
}
|
||||
|
||||
// FormatLegalSourceDisplay renders a structured legal_source code into
|
||||
// the form HLC users read in pleadings:
|
||||
//
|
||||
// UPC.RoP.23.1 → "UPC RoP R.23(1)"
|
||||
// UPC.RoP.139 → "UPC RoP R.139"
|
||||
// DE.PatG.82.1 → "PatG §82(1)"
|
||||
// DE.ZPO.276.1 → "ZPO §276(1)"
|
||||
// EU.EPÜ.108 → "EPÜ Art.108"
|
||||
// EU.EPC-R.79.1 → "EPC R.79(1)"
|
||||
// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
|
||||
//
|
||||
// Returns the empty string for an empty input. Unknown jurisdictions
|
||||
// fall through with the structured form preserved (caller decides
|
||||
// whether to display).
|
||||
// FormatLegalSourceDisplay + BuildLegalSourceURL are canonically
|
||||
// defined in pkg/litigationplanner — kept here as thin re-exports so
|
||||
// the existing in-package + handler call-sites compile unchanged.
|
||||
func FormatLegalSourceDisplay(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
// Malformed — return as-is so the caller still has something.
|
||||
return src
|
||||
}
|
||||
code := parts[1]
|
||||
rest := parts[2:]
|
||||
var prefix string
|
||||
switch code {
|
||||
case "RoP":
|
||||
prefix = "UPC RoP R."
|
||||
case "PatG":
|
||||
prefix = "PatG §"
|
||||
case "ZPO":
|
||||
prefix = "ZPO §"
|
||||
case "EPÜ":
|
||||
prefix = "EPÜ Art."
|
||||
case "EPC-R":
|
||||
prefix = "EPC R."
|
||||
case "RPBA":
|
||||
prefix = "RPBA Art."
|
||||
default:
|
||||
prefix = code + " "
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(prefix) + len(src))
|
||||
b.WriteString(prefix)
|
||||
b.WriteString(rest[0])
|
||||
for _, p := range rest[1:] {
|
||||
b.WriteByte('(')
|
||||
b.WriteString(p)
|
||||
b.WriteByte(')')
|
||||
}
|
||||
return b.String()
|
||||
return lp.FormatLegalSourceDisplay(src)
|
||||
}
|
||||
|
||||
// BuildLegalSourceURL maps a structured legal_source code to a
|
||||
// youpc.org/laws permalink when the cited body is hosted there. Today
|
||||
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
|
||||
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
|
||||
// home yet, so the helper returns the empty string for those and the
|
||||
// caller renders the display string as plain text.
|
||||
//
|
||||
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
|
||||
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
|
||||
// the law-number position are dropped; youpc resolves the page at
|
||||
// <type>.<number> granularity. The law-number is zero-padded to 3
|
||||
// digits to match how youpc stores law_number (laws-data.json carries
|
||||
// "001" / "023" / "220" forms).
|
||||
//
|
||||
// URL shape uses the hash-fragment form that youpc itself emits from
|
||||
// its laws-page redirect (handlers/laws.go:215+229) — the canonical
|
||||
// in-app deep link target. The `/laws/:type/:number` pretty route also
|
||||
// resolves the same page but redirects to the hash form anyway.
|
||||
//
|
||||
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
|
||||
// UPC.RoP.139 → https://youpc.org/laws#UPCRoP.139
|
||||
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
|
||||
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
|
||||
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
|
||||
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
|
||||
func BuildLegalSourceURL(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
var lawType string
|
||||
switch parts[0] + "." + parts[1] {
|
||||
case "UPC.RoP":
|
||||
lawType = "UPCRoP"
|
||||
case "UPC.UPCA":
|
||||
lawType = "UPCA"
|
||||
case "UPC.UPCS":
|
||||
lawType = "UPCS"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
number := padLawNumber(parts[2])
|
||||
if number == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://youpc.org/laws#" + lawType + "." + number
|
||||
}
|
||||
|
||||
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
|
||||
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
|
||||
// 112a) pass through unchanged so the URL still resolves. Empty input
|
||||
// returns the empty string.
|
||||
func padLawNumber(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if len(s) >= 3 {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat("0", 3-len(s)) + s
|
||||
return lp.BuildLegalSourceURL(src)
|
||||
}
|
||||
|
||||
// RefreshSearchView re-populates the materialised view. Safe to call on
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
221
internal/services/fristenrechner_sort_test.go
Normal file
221
internal/services/fristenrechner_sort_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for the trigger-group duration sort introduced
|
||||
// by t-paliad-296 / m/paliad#128. No DB needed — feeds synthetic
|
||||
// UIDeadlines and a ruleByID map directly into the helper.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// makeRule is a tiny constructor for a synthetic rule with just the
|
||||
// fields the sort reads (parent_id, duration_value, duration_unit,
|
||||
// submission_code, trigger_event_id).
|
||||
func makeRule(t *testing.T, parent *uuid.UUID, code string, val int, unit string) (uuid.UUID, models.DeadlineRule) {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
codeCopy := code
|
||||
return id, models.DeadlineRule{
|
||||
ID: id,
|
||||
ParentID: parent,
|
||||
SubmissionCode: &codeCopy,
|
||||
DurationValue: val,
|
||||
DurationUnit: unit,
|
||||
}
|
||||
}
|
||||
|
||||
func makeDeadline(id uuid.UUID, code string) UIDeadline {
|
||||
return UIDeadline{
|
||||
RuleID: id.String(),
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision is the
|
||||
// canonical scenario from m's report — four post-decision optional
|
||||
// events anchored on the same decision must render with 1-month rules
|
||||
// before 2-month rules.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision(t *testing.T) {
|
||||
decisionID := uuid.New()
|
||||
|
||||
// Catalog order matches mig 132 sequence_order: cons_orders(60),
|
||||
// cost_app(70), rectification(70), appeal_spawn(80).
|
||||
consOrdID, consOrdRule := makeRule(t, &decisionID, "upc.inf.cfi.cons_orders", 2, "months")
|
||||
costAppID, costAppRule := makeRule(t, &decisionID, "upc.inf.cfi.cost_app", 1, "months")
|
||||
rectID, rectRule := makeRule(t, &decisionID, "upc.inf.cfi.rectification", 1, "months")
|
||||
appealID, appealRule := makeRule(t, &decisionID, "upc.inf.cfi.appeal_spawn", 2, "months")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
consOrdID: consOrdRule,
|
||||
costAppID: costAppRule,
|
||||
rectID: rectRule,
|
||||
appealID: appealRule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(consOrdID, "upc.inf.cfi.cons_orders"),
|
||||
makeDeadline(costAppID, "upc.inf.cfi.cost_app"),
|
||||
makeDeadline(rectID, "upc.inf.cfi.rectification"),
|
||||
makeDeadline(appealID, "upc.inf.cfi.appeal_spawn"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// 1-month tier first (cost_app, rectification — alphabetical by
|
||||
// submission_code), then 2-month tier (appeal_spawn, cons_orders
|
||||
// — submission_code ASC tiebreak per spec).
|
||||
want := []string{
|
||||
"upc.inf.cfi.cost_app",
|
||||
"upc.inf.cfi.rectification",
|
||||
"upc.inf.cfi.appeal_spawn",
|
||||
"upc.inf.cfi.cons_orders",
|
||||
}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight asserts the
|
||||
// unit-weight ordering: days < weeks < months < years, with shorter
|
||||
// durations of the same unit winning their tier.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight(t *testing.T) {
|
||||
parentID := uuid.New()
|
||||
|
||||
d14ID, d14Rule := makeRule(t, &parentID, "x.14days", 14, "days")
|
||||
d2wID, d2wRule := makeRule(t, &parentID, "x.2weeks", 2, "weeks")
|
||||
d1mID, d1mRule := makeRule(t, &parentID, "x.1month", 1, "months")
|
||||
d6mID, d6mRule := makeRule(t, &parentID, "x.6months", 6, "months")
|
||||
d1yID, d1yRule := makeRule(t, &parentID, "x.1year", 1, "years")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
d14ID: d14Rule, d2wID: d2wRule, d1mID: d1mRule, d6mID: d6mRule, d1yID: d1yRule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(d6mID, "x.6months"),
|
||||
makeDeadline(d1yID, "x.1year"),
|
||||
makeDeadline(d2wID, "x.2weeks"),
|
||||
makeDeadline(d14ID, "x.14days"),
|
||||
makeDeadline(d1mID, "x.1month"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
want := []string{"x.14days", "x.2weeks", "x.1month", "x.6months", "x.1year"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder
|
||||
// guards the hard rule: rules with different parents must keep their
|
||||
// relative position. Sorting only ever permutes adjacent same-parent
|
||||
// rows.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder(t *testing.T) {
|
||||
parentAID := uuid.New()
|
||||
parentBID := uuid.New()
|
||||
|
||||
a3mID, a3mRule := makeRule(t, &parentAID, "ga.3months", 3, "months")
|
||||
b1mID, b1mRule := makeRule(t, &parentBID, "gb.1month", 1, "months")
|
||||
a14dID, a14dRule := makeRule(t, &parentAID, "ga.14days", 14, "days")
|
||||
b2mID, b2mRule := makeRule(t, &parentBID, "gb.2months", 2, "months")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
a3mID: a3mRule, b1mID: b1mRule, a14dID: a14dRule, b2mID: b2mRule,
|
||||
}
|
||||
|
||||
// Interleaved groups: A, B, A, B. Each group has one rule between
|
||||
// each other group's rules — the consecutive-run walk should treat
|
||||
// each as its own one-element run and not reorder anything.
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(a3mID, "ga.3months"),
|
||||
makeDeadline(b1mID, "gb.1month"),
|
||||
makeDeadline(a14dID, "ga.14days"),
|
||||
makeDeadline(b2mID, "gb.2months"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
want := []string{"ga.3months", "gb.1month", "ga.14days", "gb.2months"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q (interleaved groups must not reorder across)", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast asserts
|
||||
// that court-set / conditional rows (no concrete date in the duration
|
||||
// ladder) sort LAST within their group, regardless of their stated
|
||||
// duration value.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast(t *testing.T) {
|
||||
parentID := uuid.New()
|
||||
|
||||
dID, dRule := makeRule(t, &parentID, "x.duration", 2, "months")
|
||||
cID, cRule := makeRule(t, &parentID, "x.conditional", 1, "months")
|
||||
csID, csRule := makeRule(t, &parentID, "x.courtset", 1, "months")
|
||||
d2ID, d2Rule := makeRule(t, &parentID, "x.short", 14, "days")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
dID: dRule, cID: cRule, csID: csRule, d2ID: d2Rule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
{RuleID: cID.String(), Code: "x.conditional", IsConditional: true},
|
||||
{RuleID: dID.String(), Code: "x.duration"},
|
||||
{RuleID: csID.String(), Code: "x.courtset", IsCourtSet: true},
|
||||
{RuleID: d2ID.String(), Code: "x.short"},
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// Concrete rows first (sorted by duration): x.short (14d) then
|
||||
// x.duration (2mo). Then the two no-date rows, tiebroken by code:
|
||||
// x.conditional < x.courtset alphabetically.
|
||||
want := []string{"x.short", "x.duration", "x.conditional", "x.courtset"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged guards
|
||||
// the root-rule exception: top-level rules (parent_id=nil, no
|
||||
// trigger_event_id) must never be sorted against each other — they
|
||||
// represent distinct anchor points (SoC vs oral hearing vs decision)
|
||||
// whose proceeding-sequence order is non-negotiable.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged(t *testing.T) {
|
||||
rootSoCID, rootSoCRule := makeRule(t, nil, "x.soc", 0, "months")
|
||||
rootOralID, rootOralRule := makeRule(t, nil, "x.oral", 0, "months")
|
||||
rootDecID, rootDecRule := makeRule(t, nil, "x.decision", 0, "months")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
rootSoCID: rootSoCRule, rootOralID: rootOralRule, rootDecID: rootDecRule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(rootSoCID, "x.soc"),
|
||||
makeDeadline(rootOralID, "x.oral"),
|
||||
makeDeadline(rootDecID, "x.decision"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// Roots must keep their input order — they're not in the same
|
||||
// trigger group as each other.
|
||||
want := []string{"x.soc", "x.oral", "x.decision"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q (roots must not be sorted against each other)", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,3 +450,182 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
|
||||
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-289: rules anchored on uncertain triggers must render as
|
||||
// conditional (IsConditional=true, empty DueDate, ParentRule* populated)
|
||||
// rather than fabricating a date off the trigger.
|
||||
//
|
||||
// Three pillars from the issue:
|
||||
// - Symptom A: R.109(1) Antrag auf Simultanübersetzung (timing='before',
|
||||
// parent=Mündliche Verhandlung which is court-set). Pre-fix the rule
|
||||
// computed a meaningless "1 month before today" because sequence_order
|
||||
// places translation_request (45) before oral (50), so the parent
|
||||
// hadn't been classified as court-set yet. The new pre-pass in
|
||||
// Calculate seeds courtSet from is_court_set=true on the data, so
|
||||
// order-of-evaluation no longer matters.
|
||||
// - R.118(4) cons_orders (parent=Entscheidung, court-set) — already
|
||||
// worked via the legacy IsCourtSetIndirect path; assertion ensures
|
||||
// the new IsConditional flag rides alongside it.
|
||||
// - Symptom B: R.262(2) confidentiality_response (priority='optional',
|
||||
// primary_party='both', parent=SoC which is the trigger anchor).
|
||||
// The data-model parent is "always certain" but the real triggering
|
||||
// event (opposing party's confidentiality motion) sits outside the
|
||||
// rule data — render conditional until the user anchors the rule.
|
||||
func TestUIDeadline_IsConditional_UncertainAnchors(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()
|
||||
holidays := NewHolidayService(pool)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
svc := NewFristenrechnerService(rules, holidays, courts)
|
||||
|
||||
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := map[string]UIDeadline{}
|
||||
for _, d := range resp.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
code string
|
||||
wantConditional bool
|
||||
wantParentCode string
|
||||
}{
|
||||
// Symptom A — backward-anchored on the court-set oral hearing.
|
||||
// Pre-pass fix: order-of-evaluation no longer matters. These
|
||||
// rules have no trigger_event_id, so ParentRuleCode stays on
|
||||
// the parent_id-derived value.
|
||||
{"upc.inf.cfi.translation_request", true, "upc.inf.cfi.oral"},
|
||||
{"upc.inf.cfi.interpreter_cost", true, "upc.inf.cfi.oral"},
|
||||
// R.118(4) chain — parent=decision (court-set). No trigger_event_id.
|
||||
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
|
||||
// Symptom B — optional + both, data-model parent is SoC but the
|
||||
// real trigger is the opposing party's confidentiality application.
|
||||
// m/paliad#126 / t-paliad-294: ParentRuleCode now reflects the
|
||||
// trigger_events catalog row (id=25), NOT the parent_id chain.
|
||||
{"upc.inf.cfi.confidentiality_response", true, "application_to_request_confidentiality_from_the_public"},
|
||||
// Negative control — mandatory rule anchored on SoC must keep
|
||||
// its concrete date (no IsConditional, real DueDate). No
|
||||
// trigger_event_id, so parent_id-derived code stays.
|
||||
{"upc.inf.cfi.sod", false, "upc.inf.cfi.soc"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.code, func(t *testing.T) {
|
||||
d, ok := byCode[c.code]
|
||||
if !ok {
|
||||
t.Fatalf("rule %s missing from response", c.code)
|
||||
}
|
||||
if d.IsConditional != c.wantConditional {
|
||||
t.Errorf("IsConditional = %v, want %v", d.IsConditional, c.wantConditional)
|
||||
}
|
||||
if c.wantConditional {
|
||||
if d.DueDate != "" {
|
||||
t.Errorf("DueDate = %q, want empty (conditional)", d.DueDate)
|
||||
}
|
||||
if d.ParentRuleCode != c.wantParentCode {
|
||||
t.Errorf("ParentRuleCode = %q, want %q", d.ParentRuleCode, c.wantParentCode)
|
||||
}
|
||||
if d.ParentRuleName == "" {
|
||||
t.Errorf("ParentRuleName empty for conditional rule")
|
||||
}
|
||||
} else {
|
||||
if d.DueDate == "" {
|
||||
t.Errorf("non-conditional rule has empty DueDate")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// m/paliad#126 / t-paliad-294: the conditional chip for R.262(2)
|
||||
// reads from the trigger_events catalog (id=25), so the user sees
|
||||
// the actual semantic anchor instead of the parent_id-derived
|
||||
// "Klageerhebung". Pin the exact DE + EN strings so a future
|
||||
// rename of the catalog row surfaces here.
|
||||
t.Run("R.262(2) conditional label uses trigger_event_id, not parent_id", func(t *testing.T) {
|
||||
d, ok := byCode["upc.inf.cfi.confidentiality_response"]
|
||||
if !ok {
|
||||
t.Fatalf("confidentiality_response missing from response")
|
||||
}
|
||||
const wantNameDE = "Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit"
|
||||
const wantNameEN = "Application to request confidentiality from the public"
|
||||
if d.ParentRuleName != wantNameDE {
|
||||
t.Errorf("ParentRuleName = %q, want %q (trigger_events.name_de for id=25)", d.ParentRuleName, wantNameDE)
|
||||
}
|
||||
if d.ParentRuleNameEN != wantNameEN {
|
||||
t.Errorf("ParentRuleNameEN = %q, want %q (trigger_events.name for id=25)", d.ParentRuleNameEN, wantNameEN)
|
||||
}
|
||||
// Negative guard — neither label should leak the SoC ("Klageerhebung"),
|
||||
// which is the regression the fix exists to prevent.
|
||||
if d.ParentRuleName == "Klageerhebung" || d.ParentRuleNameEN == "Statement of Claim" {
|
||||
t.Errorf("conditional label still resolves via parent_id (SoC); fix regressed")
|
||||
}
|
||||
})
|
||||
|
||||
// Generalisation guard — translations_lodge also carries a real
|
||||
// trigger_event_id (113 = judge-rapporteur's order). Its
|
||||
// conditional chip should reference the order, not its parent_id
|
||||
// (Zwischenverfahren). Locks in the "any rule with trigger_event_id
|
||||
// uses THAT, not parent_id" contract from m/paliad#126.
|
||||
t.Run("translations_lodge conditional label uses trigger_event_id", func(t *testing.T) {
|
||||
d, ok := byCode["upc.inf.cfi.translations_lodge"]
|
||||
if !ok {
|
||||
t.Skip("upc.inf.cfi.translations_lodge missing from response — data drift?")
|
||||
}
|
||||
if !d.IsConditional {
|
||||
t.Skipf("translations_lodge IsConditional=false in current corpus; trigger-event override is only user-visible on conditional rows. Skip but keep the generalisation guard.")
|
||||
}
|
||||
if d.ParentRuleName == "Zwischenverfahren" {
|
||||
t.Errorf("translations_lodge still labelled via parent_id (Zwischenverfahren); should follow trigger_event_id=113")
|
||||
}
|
||||
if d.ParentRuleCode != "order_of_the_judge_rapporteur_to_lodge_translations" {
|
||||
t.Errorf("ParentRuleCode = %q, want trigger_events.code for id=113", d.ParentRuleCode)
|
||||
}
|
||||
})
|
||||
|
||||
// Override path: when the user anchors the oral hearing, the
|
||||
// backward-anchored R.109(1) flips back to a concrete date and
|
||||
// IsConditional clears. This is the click-to-edit unblock.
|
||||
t.Run("override on court-set parent clears IsConditional", func(t *testing.T) {
|
||||
resp2, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{
|
||||
AnchorOverrides: map[string]string{
|
||||
"upc.inf.cfi.oral": "2027-03-01",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate with override: %v", err)
|
||||
}
|
||||
var tr UIDeadline
|
||||
for _, d := range resp2.Deadlines {
|
||||
if d.Code == "upc.inf.cfi.translation_request" {
|
||||
tr = d
|
||||
break
|
||||
}
|
||||
}
|
||||
if tr.IsConditional {
|
||||
t.Errorf("translation_request IsConditional=true after oral override; want false")
|
||||
}
|
||||
if tr.DueDate == "" {
|
||||
t.Errorf("translation_request DueDate empty after oral override")
|
||||
}
|
||||
// 1 month before 2027-03-01 = ~2027-02-01 (with weekend bump).
|
||||
if tr.DueDate < "2027-01-25" || tr.DueDate > "2027-02-05" {
|
||||
t.Errorf("translation_request DueDate=%q not within expected 2027-01-25..2027-02-05 window", tr.DueDate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// Country and regime constants — keep in sync with the paliad.countries
|
||||
@@ -229,38 +231,14 @@ func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, country, regime
|
||||
// Feiertag" — so a 27-day shift across UPC vacation no longer looks like a
|
||||
// math bug. See t-paliad-119.
|
||||
//
|
||||
// Date fields are JSON-serialised as YYYY-MM-DD strings (the same convention
|
||||
// as UIDeadline.DueDate / OriginalDate) so the frontend doesn't need a
|
||||
// separate RFC3339 parser. Holidays carries the same string-date shape.
|
||||
type AdjustmentReason struct {
|
||||
// Kind is the dominant cause; longest cause wins when several apply
|
||||
// (vacation > public_holiday > weekend).
|
||||
Kind string `json:"kind"`
|
||||
// Holidays collects every named holiday encountered while walking past
|
||||
// the non-working run, deduped by (date, name). May be empty when the
|
||||
// only cause is a weekend.
|
||||
Holidays []HolidayDTO `json:"holidays,omitempty"`
|
||||
// VacationName, VacationStart and VacationEnd describe the contiguous
|
||||
// vacation block the original date sits in. Populated only when Kind
|
||||
// == "vacation". Span boundaries are the first/last vacation day in
|
||||
// the block (excludes the weekends that pad it).
|
||||
VacationName string `json:"vacationName,omitempty"`
|
||||
VacationStart string `json:"vacationStart,omitempty"`
|
||||
VacationEnd string `json:"vacationEnd,omitempty"`
|
||||
// OriginalWeekday is the English weekday name of the original date —
|
||||
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
|
||||
// can localise it.
|
||||
OriginalWeekday string `json:"originalWeekday,omitempty"`
|
||||
}
|
||||
|
||||
// HolidayDTO is the JSON shape for a holiday emitted in AdjustmentReason —
|
||||
// distinct from Holiday so dates serialise as YYYY-MM-DD strings.
|
||||
type HolidayDTO struct {
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
IsVacation bool `json:"isVacation,omitempty"`
|
||||
IsClosure bool `json:"isClosure,omitempty"`
|
||||
}
|
||||
// Canonical AdjustmentReason + HolidayDTO definitions live in
|
||||
// pkg/litigationplanner — kept here as type aliases so every existing
|
||||
// reference (HolidayService methods, JSON serialisation, projection
|
||||
// service) continues to compile.
|
||||
type (
|
||||
AdjustmentReason = litigationplanner.AdjustmentReason
|
||||
HolidayDTO = litigationplanner.HolidayDTO
|
||||
)
|
||||
|
||||
// AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an
|
||||
// explanation. Reason is nil when wasAdjusted is false.
|
||||
|
||||
219
internal/services/lookup_events_test.go
Normal file
219
internal/services/lookup_events_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// TestLookupEvents covers the multi-axis catalog query API from Slice
|
||||
// B2 (m/paliad#124 §18.2). Skipped when TEST_DATABASE_URL is unset,
|
||||
// mirroring TestCalculateRule.
|
||||
//
|
||||
// Cases:
|
||||
// - jurisdiction=UPC, depth=all-following → every active+published
|
||||
// UPC rule, anchor depth=1 for all (no parent_id outside the
|
||||
// filtered set lights up depth>1 because the entire UPC subset is
|
||||
// a single anchor cohort).
|
||||
// - proceeding_type_id (upc.inf.cfi) + party=defendant + depth=next
|
||||
// → defendant rules in upc.inf.cfi at depth=1 + direct children
|
||||
// of those at depth=2.
|
||||
// - unknown jurisdiction value → silently ignored, no filter applied.
|
||||
// - empty axes → all rules (no filter on any axis).
|
||||
func TestLookupEvents(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()
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
catalog := &paliadCatalog{rules: rules}
|
||||
|
||||
t.Run("jurisdiction=UPC, all-following returns the UPC corpus", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected non-empty UPC corpus")
|
||||
}
|
||||
// Every match must be a UPC rule.
|
||||
for _, m := range matches {
|
||||
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
|
||||
t.Errorf("non-UPC row leaked into UPC-axis query: code=%s jurisdiction=%v",
|
||||
m.ProceedingType.Code, m.ProceedingType.Jurisdiction)
|
||||
}
|
||||
if m.DepthFromAnchor < 1 {
|
||||
t.Errorf("depth=%d for rule %s, want >= 1", m.DepthFromAnchor, m.Rule.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("party=defendant scopes to defendant rules", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
Party: "defendant",
|
||||
}, lp.EventLookupDepthNext)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected at least one defendant rule across the UPC corpus")
|
||||
}
|
||||
// Anchor matches (depth=1) must be primary_party=defendant.
|
||||
// Depth=2 children appear under EventLookupDepthNext only as
|
||||
// expansion from anchors — they may carry any party.
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
|
||||
t.Errorf("anchor row %s (depth=1) is not defendant: %v",
|
||||
m.Rule.Name, m.Rule.PrimaryParty)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown jurisdiction value silently falls through", func(t *testing.T) {
|
||||
matchesAll, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{},
|
||||
lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents (all): %v", err)
|
||||
}
|
||||
matchesUnknown, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "XX-not-a-real-jurisdiction",
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents (unknown): %v", err)
|
||||
}
|
||||
if len(matchesAll) != len(matchesUnknown) {
|
||||
t.Errorf("unknown jurisdiction should fall through to no-filter; got %d vs all-axes %d",
|
||||
len(matchesUnknown), len(matchesAll))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=endentscheidung returns upc.apl merits rules", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
AppealTarget: lp.AppealTargetEndentscheidung,
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
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).
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl endentscheidung rules after B1 mig")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue // children of anchors may be from other targets
|
||||
}
|
||||
found := false
|
||||
for _, t := range m.Rule.AppliesToTarget {
|
||||
if t == lp.AppealTargetEndentscheidung {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
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",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=schadensbemessung returns upc.apl merits rules (mig 138 backfill)", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
AppealTarget: lp.AppealTargetSchadensbemessung,
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
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.
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl schadensbemessung rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, t := range m.Rule.AppliesToTarget {
|
||||
if t == lp.AppealTargetSchadensbemessung {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
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",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=bucheinsicht returns upc.apl order rules (mig 138 backfill)", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
AppealTarget: lp.AppealTargetBucheinsicht,
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
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.
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl bucheinsicht rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, t := range m.Rule.AppliesToTarget {
|
||||
if t == lp.AppealTargetBucheinsicht {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
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",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -38,6 +38,59 @@ type CreatePartyInput struct {
|
||||
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
|
||||
}
|
||||
|
||||
// PartySearchHit is one row of the cross-project party search — a real
|
||||
// paliad.parties row enriched with the parent project's title and
|
||||
// reference so the picker can render context the lawyer needs to
|
||||
// disambiguate identically-named parties on different cases
|
||||
// (t-paliad-287).
|
||||
type PartySearchHit struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
ProjectTitle string `db:"project_title" json:"project_title"`
|
||||
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Role *string `db:"role" json:"role,omitempty"`
|
||||
Representative *string `db:"representative" json:"representative,omitempty"`
|
||||
}
|
||||
|
||||
// Search returns parties from every project the caller can see, matched
|
||||
// by case-insensitive substring on name OR representative. Empty query
|
||||
// returns the 20 most recently-updated parties so the picker isn't
|
||||
// blank on first open. Capped at 25 rows; the frontend doesn't paginate
|
||||
// (the typical PA looks for one party they remember by name, not browses).
|
||||
//
|
||||
// Visibility is enforced inline via visibilityPredicatePositional —
|
||||
// invisible projects' parties never surface in the result set.
|
||||
func (s *PartyService) Search(ctx context.Context, userID uuid.UUID, query string, limit int) ([]PartySearchHit, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 25
|
||||
}
|
||||
q := strings.TrimSpace(query)
|
||||
args := []any{userID}
|
||||
conds := []string{visibilityPredicatePositional("p", 1)}
|
||||
if q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conds = append(conds,
|
||||
fmt.Sprintf(`(pa.name ILIKE $%d OR COALESCE(pa.representative,'') ILIKE $%d)`,
|
||||
len(args), len(args)))
|
||||
}
|
||||
args = append(args, limit)
|
||||
sqlStr := `
|
||||
SELECT pa.id, pa.project_id, p.title AS project_title,
|
||||
p.reference AS project_reference,
|
||||
pa.name, pa.role, pa.representative
|
||||
FROM paliad.parties pa
|
||||
JOIN paliad.projects p ON p.id = pa.project_id
|
||||
WHERE ` + strings.Join(conds, " AND ") + `
|
||||
ORDER BY pa.updated_at DESC
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args))
|
||||
hits := []PartySearchHit{}
|
||||
if err := s.db.SelectContext(ctx, &hits, sqlStr, args...); err != nil {
|
||||
return nil, fmt.Errorf("search parties: %w", err)
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// ListForProject returns all Parties for the Project, visibility-checked.
|
||||
func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
|
||||
@@ -1,191 +1,63 @@
|
||||
package services
|
||||
|
||||
// proceeding_mapping bridges the two proceeding-type vocabularies in the
|
||||
// codebase: the **litigation** conceptual category (INF / REV / APP /
|
||||
// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding
|
||||
// + Pipeline-A rules, and the **fristenrechner** code category
|
||||
// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator
|
||||
// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects
|
||||
// bind to fristenrechner codes directly, but the litigation→fristenrechner
|
||||
// mapping is still needed for the ~40 Pipeline-A rules that remain on
|
||||
// litigation proceedings and for any other surface that thinks in
|
||||
// litigation terms.
|
||||
//
|
||||
// The mapping table here is the single source of truth — see
|
||||
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
|
||||
// design rationale + ambiguity notes, and
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md for the
|
||||
// lowercase dot-separated naming convention applied by mig 096
|
||||
// (t-paliad-206). **Never silent FK promotion**: every ambiguous case
|
||||
// returns ok=false so callers can degrade gracefully ("no narrowing")
|
||||
// instead of guessing.
|
||||
import lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
|
||||
// Stable code constants — the strings landed by mig 096. Use these
|
||||
// throughout the codebase so a future rename only needs to touch this
|
||||
// file. The id-anchored FKs (deadline_rules.proceeding_type_id,
|
||||
// projects.proceeding_type_id) are unaffected by the rename.
|
||||
// proceeding_mapping bridges the two proceeding-type vocabularies in
|
||||
// the codebase. The canonical implementations now live in
|
||||
// pkg/litigationplanner — this file keeps the existing service-level
|
||||
// names alive as re-exports so the rest of internal/services + tests
|
||||
// compile without an import-rewrite.
|
||||
//
|
||||
// See pkg/litigationplanner/proceeding_mapping.go for the logic +
|
||||
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
|
||||
// design rationale.
|
||||
|
||||
// Stable code constants — re-exported from the package so existing
|
||||
// services / handlers can keep using the bare names.
|
||||
const (
|
||||
CodeUPCInfringement = "upc.inf.cfi"
|
||||
CodeUPCRevocation = "upc.rev.cfi"
|
||||
CodeUPCCounterclaim = "upc.ccr.cfi"
|
||||
CodeUPCPreliminary = "upc.pi.cfi"
|
||||
CodeUPCDamages = "upc.dmgs.cfi"
|
||||
CodeUPCDiscovery = "upc.disc.cfi"
|
||||
CodeUPCAppealMerits = "upc.apl.merits"
|
||||
CodeUPCAppealOrder = "upc.apl.order"
|
||||
CodeUPCAppealCost = "upc.apl.cost"
|
||||
CodeDEInfringementLG = "de.inf.lg"
|
||||
CodeDEInfringementOLG = "de.inf.olg"
|
||||
CodeDEInfringementBGH = "de.inf.bgh"
|
||||
CodeDENullityBPatG = "de.null.bpatg"
|
||||
CodeDENullityBGH = "de.null.bgh"
|
||||
CodeEPAGrant = "epa.grant.exa"
|
||||
CodeEPAOpposition = "epa.opp.opd"
|
||||
CodeEPAOppositionAppeal = "epa.opp.boa"
|
||||
CodeDPMAOpposition = "dpma.opp.dpma"
|
||||
CodeDPMAAppealBPatG = "dpma.appeal.bpatg"
|
||||
CodeDPMAAppealBGH = "dpma.appeal.bgh"
|
||||
CodeUPCInfringement = lp.CodeUPCInfringement
|
||||
CodeUPCRevocation = lp.CodeUPCRevocation
|
||||
CodeUPCCounterclaim = lp.CodeUPCCounterclaim
|
||||
CodeUPCPreliminary = lp.CodeUPCPreliminary
|
||||
CodeUPCDamages = lp.CodeUPCDamages
|
||||
CodeUPCDiscovery = lp.CodeUPCDiscovery
|
||||
CodeUPCAppealMerits = lp.CodeUPCAppealMerits
|
||||
CodeUPCAppealOrder = lp.CodeUPCAppealOrder
|
||||
CodeUPCAppealCost = lp.CodeUPCAppealCost
|
||||
CodeDEInfringementLG = lp.CodeDEInfringementLG
|
||||
CodeDEInfringementOLG = lp.CodeDEInfringementOLG
|
||||
CodeDEInfringementBGH = lp.CodeDEInfringementBGH
|
||||
CodeDENullityBPatG = lp.CodeDENullityBPatG
|
||||
CodeDENullityBGH = lp.CodeDENullityBGH
|
||||
CodeEPAGrant = lp.CodeEPAGrant
|
||||
CodeEPAOpposition = lp.CodeEPAOpposition
|
||||
CodeEPAOppositionAppeal = lp.CodeEPAOppositionAppeal
|
||||
CodeDPMAOpposition = lp.CodeDPMAOpposition
|
||||
CodeDPMAAppealBPatG = lp.CodeDPMAAppealBPatG
|
||||
CodeDPMAAppealBGH = lp.CodeDPMAAppealBGH
|
||||
)
|
||||
|
||||
// MapLitigationToFristenrechner returns the fristenrechner code +
|
||||
// condition flags implied by a (litigationCode, jurisdiction) pair.
|
||||
//
|
||||
// Inputs are case-sensitive — pass the canonical upper-snake form
|
||||
// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous
|
||||
// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero
|
||||
// fristenrechner code; callers should treat that as "no narrowing"
|
||||
// and leave the cascade wide-open rather than auto-pick.
|
||||
//
|
||||
// Condition flags are returned as a slice so callers can apply them
|
||||
// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr,
|
||||
// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag
|
||||
// context applies.
|
||||
// Delegates to litigationplanner.MapLitigationToFristenrechner.
|
||||
func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) {
|
||||
switch litigationCode {
|
||||
case "INF":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, nil, true
|
||||
case "DE":
|
||||
return CodeDEInfringementLG, nil, true
|
||||
}
|
||||
case "REV":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCRevocation, nil, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "CCR":
|
||||
// Counterclaim revocation — UPC fold-in is structural (the
|
||||
// counterclaim lives inside an upc.inf.cfi proceeding with the
|
||||
// with_ccr flag). DE Nichtigkeit is conceptually the same
|
||||
// adversarial-validity test, no separate flag.
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, []string{"with_ccr"}, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "AMD":
|
||||
// Amendment-application bundled into upc.inf.cfi via with_amend.
|
||||
// No DE / EPA / DPMA analogue today.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCInfringement, []string{"with_amend"}, true
|
||||
}
|
||||
case "APP":
|
||||
// Appeal is ambiguous in DE (OLG vs BGH) and the project
|
||||
// model doesn't carry the instance hint we'd need to
|
||||
// disambiguate. UPC is unambiguous — upc.apl.merits covers
|
||||
// the merits appeal track for inf/rev/ccr/damages.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCAppealMerits, nil, true
|
||||
}
|
||||
case "APM":
|
||||
// Preliminary injunction / urgency procedure — UPC-only
|
||||
// concept in the fristenrechner taxonomy.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCPreliminary, nil, true
|
||||
}
|
||||
case "OPP":
|
||||
// Opposition — primarily EPA. DPMA has dpma.opp.dpma but it
|
||||
// doesn't surface from the litigation vocabulary today.
|
||||
if jurisdiction == "EPA" {
|
||||
return CodeEPAOpposition, nil, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
return lp.MapLitigationToFristenrechner(litigationCode, jurisdiction)
|
||||
}
|
||||
|
||||
// ResolveCounterclaimRouting handles the determinator's
|
||||
// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown
|
||||
// for taxonomic completeness, but no rules are attached to it. When the
|
||||
// cascade resolves to upc.ccr.cfi we route the rule lookup back to
|
||||
// upc.inf.cfi with a default with_ccr=true flag — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1.
|
||||
//
|
||||
// `code` is the proceeding code the cascade resolved to. If it's
|
||||
// upc.ccr.cfi, the function returns (CodeUPCInfringement,
|
||||
// []string{"with_ccr"}, true). For any other code the function returns
|
||||
// (code, nil, false) and callers proceed with the code unchanged. The
|
||||
// boolean signals "routing was applied"; the caller can surface the hint
|
||||
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
|
||||
// weiter." in the UI.
|
||||
// ResolveCounterclaimRouting handles the determinator's upc.ccr.cfi
|
||||
// illustrative-peer route. Delegates to
|
||||
// litigationplanner.ResolveCounterclaimRouting.
|
||||
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
|
||||
if route, ok := SubTrackRoutings[code]; ok {
|
||||
return route.ParentCode, route.DefaultFlags, true
|
||||
}
|
||||
return code, nil, false
|
||||
return lp.ResolveCounterclaimRouting(code)
|
||||
}
|
||||
|
||||
// SubTrackRouting describes a proceeding type that has no native rules
|
||||
// of its own and is normally rendered inside a parent proceeding's flow
|
||||
// with one or more condition flags enabled. The Procedure Roadmap
|
||||
// (verfahrensablauf) routes calc requests for these codes to the parent
|
||||
// proceeding + default flags, but preserves the user-picked code/name
|
||||
// in the response identity and surfaces a contextual note explaining
|
||||
// the framing — see m/paliad#58 and the design doc cited above.
|
||||
//
|
||||
// Adding a new sub-track is a data-only change here: extend
|
||||
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
|
||||
// renderer picks it up automatically. The note copy lives in this file
|
||||
// because it's semantic to the routing, not UI chrome.
|
||||
type SubTrackRouting struct {
|
||||
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
|
||||
Code string
|
||||
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
|
||||
ParentCode string
|
||||
// DefaultFlags are merged into the user's flag set so the
|
||||
// gated rules render. Order is preserved.
|
||||
DefaultFlags []string
|
||||
// NoteDE / NoteEN are the contextual banner above the timeline,
|
||||
// explaining that the proceeding type is normally a sub-track.
|
||||
// Plain text — the frontend renders them as a banner.
|
||||
NoteDE string
|
||||
NoteEN string
|
||||
}
|
||||
|
||||
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
|
||||
// The pattern generalises to other "sub-track" proceeding types (e.g.
|
||||
// R.30 application to amend the patent as a standalone roadmap, R.46
|
||||
// preliminary objection) once they have a proceeding-type code of their
|
||||
// own. New entries here are picked up by the spawn-as-standalone
|
||||
// renderer in FristenrechnerService.Calculate without further wiring.
|
||||
var SubTrackRoutings = map[string]SubTrackRouting{
|
||||
CodeUPCCounterclaim: {
|
||||
Code: CodeUPCCounterclaim,
|
||||
ParentCode: CodeUPCInfringement,
|
||||
DefaultFlags: []string{"with_ccr"},
|
||||
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
|
||||
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
|
||||
},
|
||||
}
|
||||
// SubTrackRoutings exposes the sub-track routing registry. SubTrackRouting
|
||||
// is aliased in fristenrechner.go.
|
||||
var SubTrackRoutings = lp.SubTrackRoutings
|
||||
|
||||
// LookupSubTrackRouting returns the sub-track routing for a proceeding
|
||||
// code, or (zero, false) if the code is not a sub-track. Used by the
|
||||
// fristenrechner Calculate path to spawn the parent flow with the sub-
|
||||
// track's default flags.
|
||||
// code, or (zero, false) if the code is not a sub-track. Delegates to
|
||||
// litigationplanner.LookupSubTrackRouting.
|
||||
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
|
||||
r, ok := SubTrackRoutings[code]
|
||||
return r, ok
|
||||
return lp.LookupSubTrackRouting(code)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,58 @@ func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-289: conditional rows (Status="conditional", Date=nil) must
|
||||
// pass through applyLookaheadCap untouched — they're not "future
|
||||
// predicted" rows by either Status or Date semantics, so they belong in
|
||||
// the pass-through bucket alongside court_set / undated rows. The cap
|
||||
// must NOT consume one of its slots for a conditional row, and the
|
||||
// row must survive even when projTotal exceeds the cap.
|
||||
func TestApplyLookaheadCap_ConditionalRowsPassThrough(t *testing.T) {
|
||||
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
rows := []TimelineEvent{
|
||||
// Three predicted future — cap=2 means the third drops.
|
||||
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "f1", Title: "F1"},
|
||||
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "f2", Title: "F2"},
|
||||
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "f3", Title: "F3"},
|
||||
// Two conditional — must survive uncapped, must NOT count
|
||||
// against projTotal / projShown.
|
||||
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c1", Title: "C1",
|
||||
DependsOnRuleCode: "p1", DependsOnRuleName: "Parent 1"},
|
||||
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c2", Title: "C2",
|
||||
DependsOnRuleCode: "p2", DependsOnRuleName: "Parent 2"},
|
||||
}
|
||||
|
||||
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
|
||||
if total != 3 {
|
||||
t.Errorf("ProjectedTotal = %d, want 3 (conditionals must not count)", total)
|
||||
}
|
||||
if shown != 2 {
|
||||
t.Errorf("ProjectedShown = %d, want 2", shown)
|
||||
}
|
||||
if overdue != 0 {
|
||||
t.Errorf("PredictedOverdue = %d, want 0", overdue)
|
||||
}
|
||||
// 2 predicted (capped) + 2 conditional pass-through = 4 rows.
|
||||
if len(kept) != 4 {
|
||||
t.Errorf("kept rows = %d, want 4", len(kept))
|
||||
}
|
||||
keptTitles := map[string]bool{}
|
||||
for _, r := range kept {
|
||||
keptTitles[r.Title] = true
|
||||
}
|
||||
for _, want := range []string{"F1", "F2", "C1", "C2"} {
|
||||
if !keptTitles[want] {
|
||||
t.Errorf("expected kept row %q missing", want)
|
||||
}
|
||||
}
|
||||
if keptTitles["F3"] {
|
||||
t.Errorf("F3 should have been dropped (cap=2)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleAnchorKind(t *testing.T) {
|
||||
hearing := "hearing"
|
||||
decision := "decision"
|
||||
|
||||
@@ -147,6 +147,17 @@ type TimelineEvent struct {
|
||||
// checkbox). At parent-node levels, rows with BubbleUp=true survive
|
||||
// the levelPolicy kind/status filter unconditionally.
|
||||
BubbleUp bool `json:"bubble_up,omitempty"`
|
||||
|
||||
// IsConditional marks projected rows whose anchor is uncertain —
|
||||
// the projection layer mirrors UIDeadline.IsConditional from the
|
||||
// fristenrechner so the SmartTimeline can render an "abhängig von
|
||||
// <parent>" chip in place of the date column. When true, Date is
|
||||
// nil and DependsOnRuleCode / DependsOnRuleName carry the parent
|
||||
// reference (already populated by annotateDependsOn for projected
|
||||
// rows; for conditional rows we additionally fall back to the
|
||||
// UIDeadline-supplied ParentRule* when the parent has no
|
||||
// computed date). Status is set to "conditional". (t-paliad-289)
|
||||
IsConditional bool `json:"is_conditional,omitempty"`
|
||||
}
|
||||
|
||||
// LaneInfo describes one column in the parent-node aggregated view.
|
||||
@@ -933,12 +944,13 @@ func (s *ProjectionService) computeProjections(
|
||||
Title: ruleDisplayName(rule, ui, lang(opts.Lang)),
|
||||
RuleCode: ui.Code,
|
||||
DeadlineRuleParty: ui.Party,
|
||||
IsConditional: ui.IsConditional,
|
||||
}
|
||||
idCopy := ruleID
|
||||
ev.DeadlineRuleID = &idCopy
|
||||
|
||||
// Date — UIDeadline.DueDate is YYYY-MM-DD when set, "" for
|
||||
// court-set rules whose date isn't bound yet.
|
||||
// court-set / conditional rules whose date isn't bound yet.
|
||||
if ui.DueDate != "" {
|
||||
if t, perr := time.Parse("2006-01-02", ui.DueDate); perr == nil {
|
||||
dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
@@ -946,7 +958,38 @@ func (s *ProjectionService) computeProjections(
|
||||
}
|
||||
}
|
||||
|
||||
// Conditional rows from the fristenrechner (t-paliad-289):
|
||||
// pre-stamp the dependency reference here so the row carries
|
||||
// the "abhängig von <parent>" payload even when the parent has
|
||||
// no computed date for annotateDependsOn to pick up later.
|
||||
// annotateDependsOn won't overwrite a non-empty DependsOnRuleCode,
|
||||
// and the parent's actual date (if anchored elsewhere) still
|
||||
// flows into DependsOnDate via the actuals-first preference.
|
||||
if ui.IsConditional && ui.ParentRuleCode != "" {
|
||||
ev.DependsOnRuleCode = ui.ParentRuleCode
|
||||
switch lang(opts.Lang) {
|
||||
case "en":
|
||||
if ui.ParentRuleNameEN != "" {
|
||||
ev.DependsOnRuleName = ui.ParentRuleNameEN
|
||||
} else {
|
||||
ev.DependsOnRuleName = ui.ParentRuleName
|
||||
}
|
||||
default:
|
||||
ev.DependsOnRuleName = ui.ParentRuleName
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case ui.IsConditional:
|
||||
// Anchor uncertain (court-set ancestor without override,
|
||||
// backward-anchor without forward date, or optional event
|
||||
// not recorded). Surface as conditional so the frontend
|
||||
// renders "abhängig von <parent>" in place of a date.
|
||||
// Conditional rows must not carry a date even if the
|
||||
// calculator left one — clear it to match the wire contract.
|
||||
// (t-paliad-289)
|
||||
ev.Date = nil
|
||||
ev.Status = "conditional"
|
||||
case ui.IsCourtSet && ev.Date == nil:
|
||||
// Pure court-set rule — date is bound by the court at
|
||||
// hearing/decision time. Surface as undated court_set.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// RuleEditorService owns the admin-only rule lifecycle for Phase 3
|
||||
@@ -148,6 +149,16 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
if strings.TrimSpace(input.Priority) == "" {
|
||||
input.Priority = "mandatory"
|
||||
}
|
||||
// Slice B3 (m/paliad#124 §18.3, mig 135): canonical four-value
|
||||
// primary_party vocab. Pre-validate so the user gets a
|
||||
// user-friendly error before the DB CHECK fires with the raw
|
||||
// constraint-violation message.
|
||||
if input.PrimaryParty != nil && !lp.IsValidPrimaryParty(*input.PrimaryParty) {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: primary_party=%q is not one of %v",
|
||||
ErrInvalidInput, *input.PrimaryParty, lp.PrimaryParties,
|
||||
)
|
||||
}
|
||||
if err := s.validateSpawnNoCycle(ctx, nil, input.SpawnProceedingTypeID, input.ProceedingTypeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -220,6 +231,19 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
|
||||
ErrInvalidLifecycleState, id, current.LifecycleState)
|
||||
}
|
||||
|
||||
// Slice B3 (m/paliad#124 §18.3, mig 135): pre-validate the
|
||||
// patch's primary_party so the user gets a user-friendly error
|
||||
// before the DB CHECK fires with the raw constraint-violation
|
||||
// message. Patch field is *string — nil means "don't change",
|
||||
// dereferenced empty string means "set to NULL" (handled below
|
||||
// in buildPatchSets).
|
||||
if patch.PrimaryParty != nil && !lp.IsValidPrimaryParty(*patch.PrimaryParty) {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: primary_party=%q is not one of %v",
|
||||
ErrInvalidInput, *patch.PrimaryParty, lp.PrimaryParties,
|
||||
)
|
||||
}
|
||||
|
||||
// Spawn cycle guard: if the patch sets spawn_proceeding_type_id,
|
||||
// validate against the global graph BEFORE the UPDATE so we can
|
||||
// surface the cycle clearly instead of relying on a runtime
|
||||
@@ -604,92 +628,6 @@ func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ExportMigrationsSince returns a SQL blob containing one UPDATE / INSERT
|
||||
// per audited rule change after the given audit row id. Used by the
|
||||
// admin "export changes to a migration file" flow (Q-H-5: pure SQL
|
||||
// format). Returns SQL + count + the latest audit id seen so the
|
||||
// caller can pass it as ?since= on the next call.
|
||||
//
|
||||
// v1 generates one UPDATE per audit row using the after_json snapshot.
|
||||
// Slice 11b will polish the output (re-order so foreign-key edges
|
||||
// resolve, collapse consecutive UPDATEs on the same row, format the
|
||||
// header comment with author + reason). v1 emits one statement per
|
||||
// audit row in chronological order — sufficient for hand-review.
|
||||
type ExportResult struct {
|
||||
MigrationSQL string `json:"migration_sql"`
|
||||
Count int `json:"count"`
|
||||
LatestAuditID string `json:"latest_audit_id"`
|
||||
}
|
||||
|
||||
func (s *RuleEditorService) ExportMigrationsSince(ctx context.Context, sinceAuditID string) (*ExportResult, error) {
|
||||
type auditRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
RuleID uuid.UUID `db:"rule_id"`
|
||||
ChangedAt time.Time `db:"changed_at"`
|
||||
Action string `db:"action"`
|
||||
AfterJSON json.RawMessage `db:"after_json"`
|
||||
Reason string `db:"reason"`
|
||||
}
|
||||
var rows []auditRow
|
||||
q := `SELECT id, rule_id, changed_at, action, after_json, reason
|
||||
FROM paliad.deadline_rule_audit
|
||||
WHERE migration_exported = false`
|
||||
args := []any{}
|
||||
if sinceAuditID != "" {
|
||||
sid, err := uuid.Parse(sinceAuditID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid since= uuid", ErrInvalidInput)
|
||||
}
|
||||
q += ` AND changed_at >= (SELECT changed_at FROM paliad.deadline_rule_audit WHERE id = $1)`
|
||||
args = append(args, sid)
|
||||
}
|
||||
q += ` ORDER BY changed_at ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list audit since: %w", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("-- Auto-generated rule-editor migration export.\n")
|
||||
sb.WriteString("-- Generated at: " + time.Now().UTC().Format(time.RFC3339) + "\n")
|
||||
sb.WriteString("-- Rows: " + fmt.Sprintf("%d", len(rows)) + "\n\n")
|
||||
sb.WriteString("SELECT set_config('paliad.audit_reason',\n")
|
||||
sb.WriteString(" 'rule-editor export: replay of " + fmt.Sprintf("%d", len(rows)) + " edits', true);\n\n")
|
||||
|
||||
latest := ""
|
||||
for _, r := range rows {
|
||||
sb.WriteString("-- audit " + r.ID.String() + " (" + r.Action + " " + r.ChangedAt.Format(time.RFC3339) + "): " + sqlEscape(r.Reason) + "\n")
|
||||
switch r.Action {
|
||||
case "create", "update":
|
||||
if len(r.AfterJSON) == 0 {
|
||||
sb.WriteString("-- (no after_json — skipped)\n\n")
|
||||
continue
|
||||
}
|
||||
sb.WriteString("INSERT INTO paliad.deadline_rules\n")
|
||||
sb.WriteString(" SELECT (jsonb_populate_record(NULL::paliad.deadline_rules, '")
|
||||
sb.WriteString(sqlEscape(string(r.AfterJSON)))
|
||||
sb.WriteString("'::jsonb)).*\n")
|
||||
sb.WriteString("ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, name_en = EXCLUDED.name_en,\n")
|
||||
sb.WriteString(" duration_value = EXCLUDED.duration_value, duration_unit = EXCLUDED.duration_unit,\n")
|
||||
sb.WriteString(" timing = EXCLUDED.timing, priority = EXCLUDED.priority,\n")
|
||||
sb.WriteString(" is_court_set = EXCLUDED.is_court_set,\n")
|
||||
sb.WriteString(" condition_expr = EXCLUDED.condition_expr,\n")
|
||||
sb.WriteString(" lifecycle_state = EXCLUDED.lifecycle_state,\n")
|
||||
sb.WriteString(" updated_at = now();\n\n")
|
||||
case "delete", "archive":
|
||||
sb.WriteString("UPDATE paliad.deadline_rules SET lifecycle_state='archived', updated_at=now() WHERE id='")
|
||||
sb.WriteString(r.RuleID.String())
|
||||
sb.WriteString("';\n\n")
|
||||
}
|
||||
latest = r.ID.String()
|
||||
}
|
||||
|
||||
return &ExportResult{
|
||||
MigrationSQL: sb.String(),
|
||||
Count: len(rows),
|
||||
LatestAuditID: latest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internal helpers
|
||||
// =============================================================================
|
||||
@@ -814,6 +752,3 @@ func nullableJSON(b json.RawMessage) any {
|
||||
return []byte(b)
|
||||
}
|
||||
|
||||
func sqlEscape(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
|
||||
347
internal/services/scenario_service.go
Normal file
347
internal/services/scenario_service.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// ScenarioService reads + writes paliad.scenarios — named compositions
|
||||
// of existing proceedings + flags + per-card choices + anchor dates,
|
||||
// switchable per project or saved as abstract templates on
|
||||
// /tools/verfahrensablauf. Slice D, m/paliad#124 §5, mig 145.
|
||||
//
|
||||
// Visibility:
|
||||
// - Project-scoped scenarios (project_id NOT NULL): require
|
||||
// can_see_project on the bound project (mirrors
|
||||
// EventChoiceService.requireProjectVisible).
|
||||
// - Abstract scenarios (project_id IS NULL): owner-only. Only
|
||||
// created_by can read / mutate.
|
||||
//
|
||||
// The service applies these checks in application code; paliad.scenarios
|
||||
// also has RLS policies (mig 145) as defense-in-depth for callers that
|
||||
// connect through Supabase Auth's auth.uid() session.
|
||||
type ScenarioService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
rules *DeadlineRuleService
|
||||
}
|
||||
|
||||
// NewScenarioService wires the service to its dependencies.
|
||||
func NewScenarioService(db *sqlx.DB, projects *ProjectService, rules *DeadlineRuleService) *ScenarioService {
|
||||
return &ScenarioService{db: db, projects: projects, rules: rules}
|
||||
}
|
||||
|
||||
// Sentinel errors. Mirrors EventChoiceService + the lp package errors
|
||||
// so handlers can map cleanly to HTTP statuses.
|
||||
var (
|
||||
ErrScenarioNotVisible = errors.New("scenario not visible to caller")
|
||||
)
|
||||
|
||||
// CreateScenarioInput is the payload for POST /api/scenarios. project_id
|
||||
// nil = abstract scenario (saved Verfahrensablauf template).
|
||||
type CreateScenarioInput struct {
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
}
|
||||
|
||||
// Create inserts a new scenario after validating the spec.
|
||||
func (s *ScenarioService) Create(ctx context.Context, userID uuid.UUID, input CreateScenarioInput) (*lp.Scenario, error) {
|
||||
if input.Name == "" {
|
||||
return nil, fmt.Errorf("%w: name required", ErrInvalidInput)
|
||||
}
|
||||
if err := s.validateSpec(ctx, input.Spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.ProjectID != nil {
|
||||
if err := s.requireProjectVisible(ctx, userID, *input.ProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var out lp.Scenario
|
||||
err := s.db.GetContext(ctx, &out,
|
||||
`INSERT INTO paliad.scenarios (project_id, name, description, spec, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at`,
|
||||
input.ProjectID, input.Name, input.Description,
|
||||
[]byte(input.Spec), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create scenario: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// Get returns one scenario by id after a visibility check.
|
||||
func (s *ScenarioService) Get(ctx context.Context, userID, scenarioID uuid.UUID) (*lp.Scenario, error) {
|
||||
var sc lp.Scenario
|
||||
err := s.db.GetContext(ctx, &sc,
|
||||
`SELECT id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE id = $1`, scenarioID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, lp.ErrUnknownScenario
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get scenario: %w", err)
|
||||
}
|
||||
if err := s.requireVisible(ctx, userID, &sc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sc, nil
|
||||
}
|
||||
|
||||
// ListForProject returns scenarios attached to one project, ordered by
|
||||
// created_at desc.
|
||||
func (s *ScenarioService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]lp.Scenario, error) {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []lp.Scenario{}
|
||||
err := s.db.SelectContext(ctx, &out,
|
||||
`SELECT id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list scenarios for project: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListAbstractForUser returns the calling user's abstract scenarios.
|
||||
func (s *ScenarioService) ListAbstractForUser(ctx context.Context, userID uuid.UUID) ([]lp.Scenario, error) {
|
||||
out := []lp.Scenario{}
|
||||
err := s.db.SelectContext(ctx, &out,
|
||||
`SELECT id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE project_id IS NULL AND created_by = $1
|
||||
ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list abstract scenarios: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PatchScenarioInput is the payload for PATCH /api/scenarios/{id}. Any
|
||||
// field nil means "don't change". Spec replacement re-runs validation.
|
||||
type PatchScenarioInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Spec json.RawMessage `json:"spec,omitempty"`
|
||||
}
|
||||
|
||||
// Patch updates one or more scenario fields. Visibility check fires
|
||||
// first (the caller must already see the scenario to mutate it).
|
||||
func (s *ScenarioService) Patch(ctx context.Context, userID, scenarioID uuid.UUID, input PatchScenarioInput) (*lp.Scenario, error) {
|
||||
current, err := s.Get(ctx, userID, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(input.Spec) > 0 {
|
||||
if err := s.validateSpec(ctx, input.Spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
sets = append(sets, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if input.Name != nil {
|
||||
add("name = $%d", *input.Name)
|
||||
}
|
||||
if input.Description != nil {
|
||||
add("description = $%d", *input.Description)
|
||||
}
|
||||
if len(input.Spec) > 0 {
|
||||
add("spec = $%d", []byte(input.Spec))
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return current, nil
|
||||
}
|
||||
args = append(args, scenarioID)
|
||||
query := fmt.Sprintf(`UPDATE paliad.scenarios SET %s
|
||||
WHERE id = $%d
|
||||
RETURNING id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at`, joinSets(sets), len(args))
|
||||
var out lp.Scenario
|
||||
if err := s.db.GetContext(ctx, &out, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("patch scenario: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SetActive points a project at one of its scenarios. Pass nil to
|
||||
// clear (revert to ad-hoc per-card choice state).
|
||||
func (s *ScenarioService) SetActive(ctx context.Context, userID, projectID uuid.UUID, scenarioID *uuid.UUID) error {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if scenarioID != nil {
|
||||
// Ensure scenario exists + belongs to this project. A scenario
|
||||
// from a different project (or an abstract one) can't be the
|
||||
// active scenario on this project.
|
||||
sc, err := s.Get(ctx, userID, *scenarioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sc.ProjectID == nil || *sc.ProjectID != projectID {
|
||||
return fmt.Errorf("%w: scenario %s is not attached to project %s",
|
||||
ErrInvalidInput, *scenarioID, projectID)
|
||||
}
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET active_scenario_id = $1 WHERE id = $2`,
|
||||
scenarioID, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set active scenario: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a scenario. Project's active_scenario_id is cleared
|
||||
// automatically via the FK's ON DELETE SET NULL.
|
||||
func (s *ScenarioService) Delete(ctx context.Context, userID, scenarioID uuid.UUID) error {
|
||||
// Visibility check via Get — also resolves the existence question.
|
||||
if _, err := s.Get(ctx, userID, scenarioID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.scenarios WHERE id = $1`, scenarioID); err != nil {
|
||||
return fmt.Errorf("delete scenario: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireVisible enforces the per-row visibility rule:
|
||||
// - project_id NOT NULL → caller must see the project
|
||||
// - project_id IS NULL → caller must be the row's created_by
|
||||
func (s *ScenarioService) requireVisible(ctx context.Context, userID uuid.UUID, sc *lp.Scenario) error {
|
||||
if sc.ProjectID != nil {
|
||||
return s.requireProjectVisible(ctx, userID, *sc.ProjectID)
|
||||
}
|
||||
if sc.CreatedBy == nil || *sc.CreatedBy != userID {
|
||||
return ErrScenarioNotVisible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireProjectVisible mirrors EventChoiceService.requireProjectVisible
|
||||
// (visibility via can_see_project). Cheap re-implementation — keeps the
|
||||
// call-graph small + avoids a cross-service dep.
|
||||
func (s *ScenarioService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
|
||||
var visible bool
|
||||
err := s.db.GetContext(ctx, &visible,
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = $1 AND u.global_role = 'global_admin'
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM paliad.projects p
|
||||
JOIN paliad.project_teams pt ON pt.project_id = ANY(
|
||||
string_to_array(p.path, '.')::uuid[]
|
||||
)
|
||||
WHERE p.id = $2 AND pt.user_id = $1
|
||||
)`, userID, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check project visibility: %w", err)
|
||||
}
|
||||
if !visible {
|
||||
return ErrScenarioNotVisible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSpec checks the jsonb spec is well-formed, has the right
|
||||
// version, and that every referenced proceeding code + submission code
|
||||
// resolves to an active row in the live catalog. Surfaces friendly
|
||||
// errors wrapping ErrInvalidInput so the handler can map to a 400.
|
||||
func (s *ScenarioService) validateSpec(ctx context.Context, raw json.RawMessage) error {
|
||||
if len(raw) == 0 {
|
||||
return fmt.Errorf("%w: spec is required", ErrInvalidInput)
|
||||
}
|
||||
parsed, err := lp.ParseSpec(lp.NullableJSON(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if _, err := parsed.PrimaryProceeding(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if parsed.BaseTriggerDate != "" {
|
||||
if _, err := time.Parse("2006-01-02", parsed.BaseTriggerDate); err != nil {
|
||||
return fmt.Errorf("%w: base_trigger_date %q is not YYYY-MM-DD", ErrInvalidInput, parsed.BaseTriggerDate)
|
||||
}
|
||||
}
|
||||
for i, p := range parsed.Proceedings {
|
||||
if p.Code == "" {
|
||||
return fmt.Errorf("%w: proceedings[%d].code is empty", ErrInvalidInput, i)
|
||||
}
|
||||
if p.Role != lp.ScenarioRolePrimary && p.Role != lp.ScenarioRolePeer {
|
||||
return fmt.Errorf("%w: proceedings[%d].role=%q must be 'primary' or 'peer'",
|
||||
ErrInvalidInput, i, p.Role)
|
||||
}
|
||||
if p.AppealTarget != "" && !lp.IsValidAppealTarget(p.AppealTarget) {
|
||||
return fmt.Errorf("%w: proceedings[%d].appeal_target=%q not in %v",
|
||||
ErrInvalidInput, i, p.AppealTarget, lp.AppealTargets)
|
||||
}
|
||||
if p.TriggerDateOverride != "" {
|
||||
if _, err := time.Parse("2006-01-02", p.TriggerDateOverride); err != nil {
|
||||
return fmt.Errorf("%w: proceedings[%d].trigger_date_override %q is not YYYY-MM-DD",
|
||||
ErrInvalidInput, i, p.TriggerDateOverride)
|
||||
}
|
||||
}
|
||||
for code, dateStr := range p.AnchorOverrides {
|
||||
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
|
||||
return fmt.Errorf("%w: proceedings[%d].anchor_overrides[%q]=%q is not YYYY-MM-DD",
|
||||
ErrInvalidInput, i, code, dateStr)
|
||||
}
|
||||
}
|
||||
// Resolve code against active proceedings.
|
||||
var exists bool
|
||||
if err := s.db.GetContext(ctx, &exists,
|
||||
`SELECT EXISTS(SELECT 1 FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true)`,
|
||||
p.Code); err != nil {
|
||||
return fmt.Errorf("validate spec proceedings[%d]: %w", i, err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%w: proceedings[%d].code=%q is not an active proceeding_type",
|
||||
ErrInvalidInput, i, p.Code)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// joinSets joins SET clauses with ", ". Tiny utility, kept here to
|
||||
// avoid cross-package strings.Join indirection.
|
||||
func joinSets(sets []string) string {
|
||||
out := ""
|
||||
for i, s := range sets {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Suppress unused-import diagnostic when models isn't referenced
|
||||
// (kept for future shape-evolution; canonical scenario row lives in lp).
|
||||
var _ = models.NullableJSON(nil)
|
||||
55
pkg/litigationplanner/appeal_target_label_test.go
Normal file
55
pkg/litigationplanner/appeal_target_label_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package litigationplanner
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestTriggerEventLabelForAppealTarget pins the per-target trigger-
|
||||
// event label matrix (t-paliad-301 / m/paliad#132 Bug B). The 5
|
||||
// canonical AppealTargets each have a DE + EN label; unknown targets
|
||||
// return empty so the caller can fall back to the proceeding's own
|
||||
// trigger_event_label.
|
||||
func TestTriggerEventLabelForAppealTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
target string
|
||||
lang string
|
||||
want string
|
||||
}{
|
||||
{AppealTargetEndentscheidung, "de", "Endentscheidung (R.118)"},
|
||||
{AppealTargetEndentscheidung, "en", "Final decision (R.118)"},
|
||||
{AppealTargetKostenentscheidung, "de", "Kostenentscheidung"},
|
||||
{AppealTargetKostenentscheidung, "en", "Cost decision"},
|
||||
{AppealTargetAnordnung, "de", "Anordnung"},
|
||||
{AppealTargetAnordnung, "en", "Order"},
|
||||
{AppealTargetSchadensbemessung, "de", "Entscheidung im Schadensbemessungsverfahren"},
|
||||
{AppealTargetSchadensbemessung, "en", "Damages-assessment decision"},
|
||||
{AppealTargetBucheinsicht, "de", "Anordnung der Bucheinsicht"},
|
||||
{AppealTargetBucheinsicht, "en", "Book-inspection order"},
|
||||
// Unknown lang falls through to DE so the caller never gets
|
||||
// an empty string for a known target.
|
||||
{AppealTargetEndentscheidung, "fr", "Endentscheidung (R.118)"},
|
||||
// Unknown target → empty so caller falls back to proceeding's
|
||||
// trigger_event_label.
|
||||
{"", "de", ""},
|
||||
{"foo", "en", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := TriggerEventLabelForAppealTarget(c.target, c.lang); got != c.want {
|
||||
t.Errorf("TriggerEventLabelForAppealTarget(%q, %q) = %q, want %q",
|
||||
c.target, c.lang, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppealTargetsCoverage ensures every entry in AppealTargets has
|
||||
// a non-empty label in both languages. Adding a target to the slice
|
||||
// without populating the switch would silently emit empty labels —
|
||||
// this test catches that.
|
||||
func TestAppealTargetsCoverage(t *testing.T) {
|
||||
for _, target := range AppealTargets {
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
if got := TriggerEventLabelForAppealTarget(target, lang); got == "" {
|
||||
t.Errorf("AppealTarget %q has empty label for lang %q — add it to the switch",
|
||||
target, lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
328
pkg/litigationplanner/before_court_set_anchor_test.go
Normal file
328
pkg/litigationplanner/before_court_set_anchor_test.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Regression test for t-paliad-304 / m/paliad#135.
|
||||
//
|
||||
// Reproduces the R.109.1 / R.109.4 anchor bug on upc.inf.cfi:
|
||||
// - Trigger (Klageerhebung): parent_id=nil, duration=0, !IsCourtSet, sequence_order=0
|
||||
// - Translation request: parent_id=oral, duration=1mo before, sequence_order=45
|
||||
// - Interpreter cost: parent_id=oral, duration=2w before, sequence_order=46
|
||||
// - Oral hearing: parent_id=nil, duration=0, IsCourtSet, sequence_order=50
|
||||
//
|
||||
// The "before" children are listed BEFORE the oral hearing in sequence
|
||||
// order (because chronologically they happen before it). The engine walks
|
||||
// rules in sequence_order, so when it processes the translation/
|
||||
// interpreter rows, the oral hearing has not yet been processed →
|
||||
// courtSet[oral.ID] is not yet set → parentIsCourtSet is false → the
|
||||
// engine falls back to the trigger date as the base. Result: the timing=
|
||||
// 'before' arithmetic produces 27.04.2026 (1mo before SoC) instead of
|
||||
// the conditional-no-date treatment that a court-set parent should
|
||||
// trigger.
|
||||
//
|
||||
// Expected post-fix: translation_request + interpreter_cost render as
|
||||
// IsConditional (no concrete date) because their parent's date is
|
||||
// court-set and the proceeding does not yet have an explicit override.
|
||||
|
||||
// stubCatalog implements lp.Catalog backed by an in-memory rule slice.
|
||||
// Only LoadProceeding is needed for the engine path under test; the
|
||||
// other interface methods return errors so an unintended call surfaces
|
||||
// immediately.
|
||||
type stubCatalog struct {
|
||||
pt ProceedingType
|
||||
rules []Rule
|
||||
}
|
||||
|
||||
func (s *stubCatalog) LoadProceeding(_ context.Context, code string, _ ProjectHint) (*ProceedingType, []Rule, error) {
|
||||
if code != s.pt.Code {
|
||||
return nil, nil, ErrUnknownProceedingType
|
||||
}
|
||||
rules := make([]Rule, len(s.rules))
|
||||
copy(rules, s.rules)
|
||||
pt := s.pt
|
||||
return &pt, rules, nil
|
||||
}
|
||||
func (s *stubCatalog) LoadProceedingByID(_ context.Context, _ int) (*ProceedingType, error) {
|
||||
return nil, errors.New("stubCatalog.LoadProceedingByID: not implemented")
|
||||
}
|
||||
func (s *stubCatalog) LoadRuleByID(_ context.Context, _ string) (*Rule, error) {
|
||||
return nil, errors.New("stubCatalog.LoadRuleByID: not implemented")
|
||||
}
|
||||
func (s *stubCatalog) LoadRuleByCode(_ context.Context, _, _ string) (*Rule, *ProceedingType, error) {
|
||||
return nil, nil, errors.New("stubCatalog.LoadRuleByCode: not implemented")
|
||||
}
|
||||
func (s *stubCatalog) LoadRulesByTriggerEvent(_ context.Context, _ int64) ([]Rule, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[int64]TriggerEvent, error) {
|
||||
return map[int64]TriggerEvent{}, nil
|
||||
}
|
||||
func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubCatalog) LoadScenarios(_ context.Context, _ ScenarioFilter) ([]Scenario, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*Scenario, error) {
|
||||
return nil, ErrUnknownScenario
|
||||
}
|
||||
|
||||
// noOpHolidays never adjusts dates — the test fixture doesn't care about
|
||||
// weekends or holidays, only about which base date the engine resolves.
|
||||
type noOpHolidays struct{}
|
||||
|
||||
func (noOpHolidays) IsNonWorkingDay(_ time.Time, _, _ string) bool { return false }
|
||||
func (noOpHolidays) AdjustForNonWorkingDays(d time.Time, _, _ string) (time.Time, time.Time, bool) {
|
||||
return d, d, false
|
||||
}
|
||||
func (noOpHolidays) AdjustForNonWorkingDaysBackward(d time.Time, _, _ string) (time.Time, time.Time, bool) {
|
||||
return d, d, false
|
||||
}
|
||||
func (noOpHolidays) AdjustForNonWorkingDaysWithReason(d time.Time, _, _ string) (time.Time, time.Time, bool, *AdjustmentReason) {
|
||||
return d, d, false, nil
|
||||
}
|
||||
|
||||
type fixedCourts struct{}
|
||||
|
||||
func (fixedCourts) CountryRegime(_, _, _ string) (string, string, error) {
|
||||
return CountryDE, RegimeUPC, nil
|
||||
}
|
||||
|
||||
func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// proceeding metadata
|
||||
jurisdiction := "UPC"
|
||||
procID := 1
|
||||
pt := ProceedingType{
|
||||
ID: procID,
|
||||
Code: "upc.inf.cfi",
|
||||
Name: "Verletzungsverfahren",
|
||||
NameEN: "Infringement",
|
||||
Jurisdiction: &jurisdiction,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
mkID := func() uuid.UUID {
|
||||
id, _ := uuid.NewRandom()
|
||||
return id
|
||||
}
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &procID
|
||||
|
||||
socID := mkID()
|
||||
oralID := mkID()
|
||||
transID := mkID()
|
||||
interpID := mkID()
|
||||
|
||||
socCode := "upc.inf.cfi.soc"
|
||||
oralCode := "upc.inf.cfi.oral"
|
||||
transCode := "upc.inf.cfi.translation_request"
|
||||
interpCode := "upc.inf.cfi.interpreter_cost"
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: socID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &socCode,
|
||||
Name: "Klageerhebung",
|
||||
NameEN: "Statement of Claim",
|
||||
PrimaryParty: str("claimant"),
|
||||
DurationValue: 0,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 0,
|
||||
IsCourtSet: false,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
},
|
||||
// Translation request: sequence_order BEFORE the oral hearing.
|
||||
// Reproduces the real corpus ordering (DB rows 45 < 50).
|
||||
{
|
||||
ID: transID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &oralID,
|
||||
SubmissionCode: &transCode,
|
||||
Name: "Antrag auf Simultanübersetzung",
|
||||
NameEN: "Translation request",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 1,
|
||||
DurationUnit: "months",
|
||||
Timing: str("before"),
|
||||
SequenceOrder: 45,
|
||||
IsCourtSet: false,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "optional",
|
||||
},
|
||||
// Interpreter cost notice: sequence_order BEFORE the oral hearing.
|
||||
{
|
||||
ID: interpID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &oralID,
|
||||
SubmissionCode: &interpCode,
|
||||
Name: "Mitteilung Dolmetscherkosten",
|
||||
NameEN: "Interpreter cost notice",
|
||||
PrimaryParty: str("court"),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: str("before"),
|
||||
SequenceOrder: 46,
|
||||
IsCourtSet: false,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
},
|
||||
// Oral hearing: court-set, no calculable date. Listed AFTER its
|
||||
// "before"-timed children in sequence_order.
|
||||
{
|
||||
ID: oralID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &oralCode,
|
||||
Name: "Mündliche Verhandlung",
|
||||
NameEN: "Oral hearing",
|
||||
PrimaryParty: str("court"),
|
||||
DurationValue: 0,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 50,
|
||||
IsCourtSet: true,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
},
|
||||
}
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
// The trigger event itself is unambiguous.
|
||||
if got := byCode[socCode]; got.DueDate != "2026-05-26" || !got.IsRootEvent {
|
||||
t.Errorf("SoC: DueDate=%q IsRootEvent=%v, want 2026-05-26 + IsRootEvent=true", got.DueDate, got.IsRootEvent)
|
||||
}
|
||||
|
||||
// Oral hearing must surface as IsCourtSet (no date).
|
||||
oral := byCode[oralCode]
|
||||
if oral.DueDate != "" || !oral.IsCourtSet {
|
||||
t.Errorf("oral: DueDate=%q IsCourtSet=%v, want empty + IsCourtSet=true", oral.DueDate, oral.IsCourtSet)
|
||||
}
|
||||
|
||||
// The two "before" children of the court-set oral hearing MUST surface
|
||||
// as conditional rows (no date, no fabricated arithmetic off the
|
||||
// trigger date). The buggy behaviour produces 2026-04-27 and 2026-05-12.
|
||||
trans := byCode[transCode]
|
||||
if trans.DueDate != "" {
|
||||
t.Errorf("translation_request: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", trans.DueDate)
|
||||
}
|
||||
if !trans.IsConditional && !trans.IsCourtSet {
|
||||
t.Errorf("translation_request: IsConditional=%v IsCourtSet=%v, want at least one true", trans.IsConditional, trans.IsCourtSet)
|
||||
}
|
||||
|
||||
interp := byCode[interpCode]
|
||||
if interp.DueDate != "" {
|
||||
t.Errorf("interpreter_cost: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", interp.DueDate)
|
||||
}
|
||||
if !interp.IsConditional && !interp.IsCourtSet {
|
||||
t.Errorf("interpreter_cost: IsConditional=%v IsCourtSet=%v, want at least one true", interp.IsConditional, interp.IsCourtSet)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_BeforeChildOfCourtSetParent_WithOverride pins the
|
||||
// override semantics: when the user supplies an anchor override for
|
||||
// the court-set parent, the "before" children should compute against
|
||||
// that override date instead of remaining conditional.
|
||||
func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
jurisdiction := "UPC"
|
||||
procID := 1
|
||||
pt := ProceedingType{
|
||||
ID: procID,
|
||||
Code: "upc.inf.cfi",
|
||||
Name: "Verletzungsverfahren",
|
||||
Jurisdiction: &jurisdiction,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
mkID := func() uuid.UUID {
|
||||
id, _ := uuid.NewRandom()
|
||||
return id
|
||||
}
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &procID
|
||||
|
||||
socID := mkID()
|
||||
oralID := mkID()
|
||||
transID := mkID()
|
||||
|
||||
socCode := "upc.inf.cfi.soc"
|
||||
oralCode := "upc.inf.cfi.oral"
|
||||
transCode := "upc.inf.cfi.translation_request"
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: socID, ProceedingTypeID: procIDPtr, ParentID: nil,
|
||||
SubmissionCode: &socCode, Name: "Klageerhebung", NameEN: "SoC",
|
||||
PrimaryParty: str("claimant"), DurationValue: 0, DurationUnit: "months", Timing: str("after"),
|
||||
SequenceOrder: 0, IsActive: true, LifecycleState: "published", Priority: "mandatory",
|
||||
},
|
||||
{
|
||||
ID: transID, ProceedingTypeID: procIDPtr, ParentID: &oralID,
|
||||
SubmissionCode: &transCode, Name: "Antrag auf Simultanübersetzung", NameEN: "Translation request",
|
||||
PrimaryParty: str("both"), DurationValue: 1, DurationUnit: "months", Timing: str("before"),
|
||||
SequenceOrder: 45, IsActive: true, LifecycleState: "published", Priority: "optional",
|
||||
},
|
||||
{
|
||||
ID: oralID, ProceedingTypeID: procIDPtr, ParentID: nil,
|
||||
SubmissionCode: &oralCode, Name: "Mündliche Verhandlung", NameEN: "Oral hearing",
|
||||
PrimaryParty: str("court"), DurationValue: 0, DurationUnit: "months", Timing: str("after"),
|
||||
SequenceOrder: 50, IsCourtSet: true, IsActive: true, LifecycleState: "published", Priority: "mandatory",
|
||||
},
|
||||
}
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
// User pins the oral hearing to 2026-10-15.
|
||||
opts := CalcOptions{
|
||||
AnchorOverrides: map[string]string{
|
||||
oralCode: "2026-10-15",
|
||||
},
|
||||
}
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
if got := byCode[oralCode].DueDate; got != "2026-10-15" {
|
||||
t.Errorf("oral: DueDate=%q, want 2026-10-15 (user override)", got)
|
||||
}
|
||||
|
||||
// 1 month before 2026-10-15 = 2026-09-15
|
||||
if got := byCode[transCode].DueDate; got != "2026-09-15" {
|
||||
t.Errorf("translation_request: DueDate=%q, want 2026-09-15 (1 month before oral override)", got)
|
||||
}
|
||||
}
|
||||
79
pkg/litigationplanner/catalog.go
Normal file
79
pkg/litigationplanner/catalog.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Catalog supplies proceeding-type metadata + rules for the calculator.
|
||||
//
|
||||
// Implementations:
|
||||
// - paliad: reads paliad.deadline_rules + paliad.proceeding_types,
|
||||
// filtered to lifecycle_state='published' AND is_active=true.
|
||||
// ProjectHint scopes future per-project rule merges.
|
||||
// - embedded/upc (Slice C): in-memory map keyed by code, populated
|
||||
// once at init from the embedded JSON snapshot.
|
||||
//
|
||||
// All methods return ErrUnknownProceedingType / ErrUnknownRule when the
|
||||
// caller asks for a code/id that doesn't exist in the catalog.
|
||||
type Catalog interface {
|
||||
// LoadProceeding returns the proceeding-type metadata + the full
|
||||
// rule list (sorted by sequence_order). Caller passes the user-
|
||||
// facing proceeding code (e.g. "upc.inf.cfi"). The hint scopes a
|
||||
// future per-project rule merge — implementations that don't
|
||||
// support projects ignore it.
|
||||
LoadProceeding(ctx context.Context, code string, hint ProjectHint) (*ProceedingType, []Rule, error)
|
||||
|
||||
// LoadProceedingByID is the resolver used by CalculateRule when it
|
||||
// has a rule + needs the rule's parent proceeding metadata.
|
||||
LoadProceedingByID(ctx context.Context, id int) (*ProceedingType, error)
|
||||
|
||||
// LoadRuleByID resolves a rule UUID to the rule row. Used by
|
||||
// CalculateRule when the caller supplies CalcRuleParams.RuleID.
|
||||
LoadRuleByID(ctx context.Context, ruleID string) (*Rule, error)
|
||||
|
||||
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode)
|
||||
// + returns the parent proceeding for use in the response identity.
|
||||
// Used by CalculateRule when the caller supplies the (code, local)
|
||||
// pair from a concept-card pill.
|
||||
LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*Rule, *ProceedingType, error)
|
||||
|
||||
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted
|
||||
// rules (rules whose trigger_event_id matches). Used by
|
||||
// EventDeadlineService → Calculate via CalcOptions.TriggerEventIDFilter.
|
||||
LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]Rule, error)
|
||||
|
||||
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows
|
||||
// for the conditional-label override (t-paliad-294 /
|
||||
// m/paliad#126). Returns a map keyed by event id; missing ids
|
||||
// are simply absent (caller treats absence as "no override").
|
||||
// Empty input returns an empty map without a DB roundtrip.
|
||||
LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]TriggerEvent, error)
|
||||
|
||||
// LookupEvents returns deadline rules matching any subset of the
|
||||
// requested axes, at the requested sequence depth (Slice B2,
|
||||
// m/paliad#124 §18.2). Used by the Determinator cascade, the
|
||||
// scenarios surface (Slice D), and any future "show me events
|
||||
// matching X" query. Empty result is NOT an error.
|
||||
//
|
||||
// Implementations must respect the catalog's "published + active"
|
||||
// rule gate (rules with lifecycle_state='draft' or is_active=false
|
||||
// must NEVER appear in the result). Sort order is
|
||||
// (proceeding_type_id, sequence_order) so the frontend can render
|
||||
// without re-sorting.
|
||||
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
|
||||
|
||||
// LoadScenarios lists scenarios visible to the caller, narrowed by
|
||||
// the filter (Slice D, m/paliad#124 §5). Returns an empty slice
|
||||
// (NOT an error) when no scenarios match. paliad-side impl applies
|
||||
// RLS (paliad.can_see_project for project-scoped, created_by for
|
||||
// abstract); snapshot-backed catalogs return an empty list.
|
||||
LoadScenarios(ctx context.Context, filter ScenarioFilter) ([]Scenario, error)
|
||||
|
||||
// MatchScenario returns the scenario with the given id, or
|
||||
// ErrUnknownScenario if not found / not visible. The engine adapter
|
||||
// (CalculateFromScenario) calls this to fetch a scenario by id and
|
||||
// then unpacks its spec via ParseSpec.
|
||||
MatchScenario(ctx context.Context, id uuid.UUID) (*Scenario, error)
|
||||
}
|
||||
49
pkg/litigationplanner/courts.go
Normal file
49
pkg/litigationplanner/courts.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package litigationplanner
|
||||
|
||||
// CourtRegistry maps a court id (e.g. "upc-ld-paris", "de-bgh") to its
|
||||
// (country, regime) tuple, which drives non-working-day adjustment.
|
||||
//
|
||||
// Implementations:
|
||||
// - paliad: reads paliad.courts (CourtService.CountryRegime).
|
||||
// - embedded/upc (Slice C): in-memory map populated from the embedded
|
||||
// JSON snapshot.
|
||||
//
|
||||
// Empty courtID falls back to (defaultCountry, defaultRegime) so callers
|
||||
// without a court_id (the abstract Verfahrensablauf path) still get
|
||||
// sensible behaviour. Returns an error when courtID is non-empty and
|
||||
// not in the registry.
|
||||
type CourtRegistry interface {
|
||||
CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error)
|
||||
}
|
||||
|
||||
// Country and regime constants — keep in sync with the paliad.countries
|
||||
// seed list and the holidays_regime_chk / courts_regime_chk constraints.
|
||||
const (
|
||||
CountryDE = "DE"
|
||||
RegimeUPC = "UPC"
|
||||
RegimeEPO = "EPO"
|
||||
)
|
||||
|
||||
// DefaultsForJurisdiction maps the proceeding-type jurisdiction text
|
||||
// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple
|
||||
// a holiday lookup should default to when the caller didn't pass an
|
||||
// explicit CourtID. UPC proceedings get DE+UPC (München LD is HLC's
|
||||
// most common venue, German federal holidays plus UPC vacations apply);
|
||||
// DE / DPMA / EPA get DE-only (German federal). Future EPA-specific
|
||||
// closures will require callers to pick an EPA court explicitly so the
|
||||
// EPO regime kicks in.
|
||||
//
|
||||
// Helper kept tiny and stateless — when a caller passes a real CourtID,
|
||||
// these defaults are bypassed entirely and the court's actual country +
|
||||
// regime are used.
|
||||
func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) {
|
||||
if jurisdiction == nil {
|
||||
return CountryDE, ""
|
||||
}
|
||||
switch *jurisdiction {
|
||||
case "UPC":
|
||||
return CountryDE, RegimeUPC
|
||||
default:
|
||||
return CountryDE, ""
|
||||
}
|
||||
}
|
||||
17
pkg/litigationplanner/doc.go
Normal file
17
pkg/litigationplanner/doc.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Package litigationplanner is the canonical Fristen / Verfahrensablauf
|
||||
// compute engine — the deadline-rule model, the calendar arithmetic, the
|
||||
// condition-expression gate, the sub-track routing, and the timeline
|
||||
// composer that drives Paliad's /tools/fristenrechner,
|
||||
// /tools/verfahrensablauf, and the per-project SmartTimeline.
|
||||
//
|
||||
// The package owns its types (Rule, ProceedingType, Timeline,
|
||||
// TimelineEntry, CalcOptions, …) and exposes three interfaces for the
|
||||
// stateful inputs: Catalog (proceeding + rule lookup), HolidayCalendar
|
||||
// (non-working-day adjustment), and CourtRegistry (court → country/regime
|
||||
// resolution). Paliad implements them against its Postgres database;
|
||||
// downstream consumers (youpc.org) implement them against an embedded
|
||||
// JSON snapshot of the UPC subset.
|
||||
//
|
||||
// See docs/design-litigation-planner-2026-05-26.md (t-paliad-292 /
|
||||
// m/paliad#124) for the full design.
|
||||
package litigationplanner
|
||||
76
pkg/litigationplanner/durations.go
Normal file
76
pkg/litigationplanner/durations.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package litigationplanner
|
||||
|
||||
import "time"
|
||||
|
||||
// ApplyDuration is the unified date-arithmetic helper used by every
|
||||
// calculator path (proceeding-tree, trigger-event, CalculateRule single-
|
||||
// rule). Phase 3 Slice 4 (t-paliad-185) replaced the prior split
|
||||
// between addDuration (proceeding-tree, no timing / working_days) and
|
||||
// ApplyDurationOnCalendar (Pipeline-C, full support) with this single
|
||||
// helper.
|
||||
//
|
||||
// Returns (raw, adjusted, didAdjust, reason):
|
||||
//
|
||||
// - raw: the date strictly implied by the rule before rollover.
|
||||
// - adjusted: post-rollover for calendar units. 'working_days' lands
|
||||
// on a working day by construction so raw == adjusted there.
|
||||
// - didAdjust: true iff rollover moved the date.
|
||||
// - reason: populated when didAdjust is true; nil otherwise.
|
||||
//
|
||||
// timing='before' negates the sign. timing='after' (or any other value
|
||||
// including the empty string) keeps it positive — preserves the pre-
|
||||
// Slice-4 behaviour for proceeding-tree rules whose Timing field is
|
||||
// sometimes NULL (mig 003 defaults to 'after' but legacy callers pass
|
||||
// r.Timing dereferenced).
|
||||
func ApplyDuration(
|
||||
base time.Time, value int, unit, timing, country, regime string, holidays HolidayCalendar,
|
||||
) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) {
|
||||
sign := 1
|
||||
if timing == "before" {
|
||||
sign = -1
|
||||
}
|
||||
switch unit {
|
||||
case "days":
|
||||
raw = base.AddDate(0, 0, sign*value)
|
||||
case "weeks":
|
||||
raw = base.AddDate(0, 0, sign*value*7)
|
||||
case "months":
|
||||
raw = base.AddDate(0, sign*value, 0)
|
||||
case "working_days":
|
||||
raw = AddWorkingDays(base, sign*value, country, regime, holidays)
|
||||
// Working-day arithmetic lands on a working day by construction
|
||||
// — the per-step skip loop in AddWorkingDays already passes over
|
||||
// weekends and holidays. No post-rollover required.
|
||||
return raw, raw, false, nil
|
||||
default:
|
||||
raw = base
|
||||
}
|
||||
adjusted, _, didAdjust, reason = holidays.AdjustForNonWorkingDaysWithReason(raw, country, regime)
|
||||
return raw, adjusted, didAdjust, reason
|
||||
}
|
||||
|
||||
// AddWorkingDays advances from `from` by `n` working days, skipping
|
||||
// weekends and holidays applicable to the given country/regime. Negative
|
||||
// n walks backward. n=0 keeps the input date as-is (caller decides
|
||||
// whether to roll forward via AdjustForNonWorkingDays).
|
||||
//
|
||||
// Bounded by an inner 30-step skip per advance — vacation runs in our
|
||||
// holiday tables are < 14 consecutive days, so 30 is a safety margin.
|
||||
func AddWorkingDays(from time.Time, n int, country, regime string, holidays HolidayCalendar) time.Time {
|
||||
if n == 0 {
|
||||
return from
|
||||
}
|
||||
step := 1
|
||||
if n < 0 {
|
||||
step = -1
|
||||
n = -n
|
||||
}
|
||||
cur := from
|
||||
for i := 0; i < n; i++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
66
pkg/litigationplanner/embedded/upc/courts.go
Normal file
66
pkg/litigationplanner/embedded/upc/courts.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package upc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// SnapshotCourt is the embedded court row shape. Mirrors paliad.courts.
|
||||
type SnapshotCourt struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
NameDE string `json:"name_de"`
|
||||
NameEN string `json:"name_en"`
|
||||
Country string `json:"country"`
|
||||
Regime *string `json:"regime,omitempty"`
|
||||
CourtType string `json:"court_type"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// SnapshotCourtRegistry serves CourtRegistry against the embedded
|
||||
// court slice. UPC subset only (DE / EPA / DPMA courts are NOT in
|
||||
// the snapshot — youpc.org has no need for them, and a request for
|
||||
// a non-UPC court id falls through to default country/regime per the
|
||||
// CountryRegime contract).
|
||||
type SnapshotCourtRegistry struct {
|
||||
byID map[string]SnapshotCourt
|
||||
}
|
||||
|
||||
// NewCourtRegistry parses the embedded courts.json and returns a
|
||||
// ready-to-use registry.
|
||||
func NewCourtRegistry() (*SnapshotCourtRegistry, error) {
|
||||
var courts []SnapshotCourt
|
||||
if err := readJSON("courts.json", &courts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &SnapshotCourtRegistry{byID: make(map[string]SnapshotCourt, len(courts))}
|
||||
for _, c := range courts {
|
||||
r.byID[c.ID] = c
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CountryRegime resolves a court ID to its (country, regime) tuple.
|
||||
// Empty courtID falls back to (defaultCountry, defaultRegime) per the
|
||||
// interface contract. ErrUnknownCourt-equivalent (a plain error here)
|
||||
// when courtID is non-empty but absent from the snapshot.
|
||||
func (r *SnapshotCourtRegistry) CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error) {
|
||||
if courtID == "" {
|
||||
return defaultCountry, defaultRegime, nil
|
||||
}
|
||||
c, ok := r.byID[courtID]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("upc snapshot: unknown court id %q", courtID)
|
||||
}
|
||||
reg := ""
|
||||
if c.Regime != nil {
|
||||
reg = *c.Regime
|
||||
}
|
||||
return c.Country, reg, nil
|
||||
}
|
||||
|
||||
// Compile-time assertion that SnapshotCourtRegistry satisfies
|
||||
// lp.CourtRegistry.
|
||||
var _ lp.CourtRegistry = (*SnapshotCourtRegistry)(nil)
|
||||
22
pkg/litigationplanner/embedded/upc/courts.json
Normal file
22
pkg/litigationplanner/embedded/upc/courts.json
Normal file
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"id": "upc-ld-munich",
|
||||
"code": "upc-ld-munich",
|
||||
"name_de": "UPC Lokalkammer München",
|
||||
"name_en": "UPC Local Division Munich",
|
||||
"country": "DE",
|
||||
"regime": "UPC",
|
||||
"court_type": "upc-ld",
|
||||
"sort_order": 10
|
||||
},
|
||||
{
|
||||
"id": "upc-coa",
|
||||
"code": "upc-coa",
|
||||
"name_de": "UPC Berufungsgericht",
|
||||
"name_en": "UPC Court of Appeal",
|
||||
"country": "LU",
|
||||
"regime": "UPC",
|
||||
"court_type": "upc-coa",
|
||||
"sort_order": 100
|
||||
}
|
||||
]
|
||||
80
pkg/litigationplanner/embedded/upc/embed.go
Normal file
80
pkg/litigationplanner/embedded/upc/embed.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Package upc provides an embedded, DB-free implementation of the
|
||||
// litigationplanner Catalog / HolidayCalendar / CourtRegistry
|
||||
// interfaces, populated from a JSON snapshot of paliad's UPC rule
|
||||
// corpus.
|
||||
//
|
||||
// Slice C of the litigation-planner extraction (m/paliad#124 §19).
|
||||
//
|
||||
// Consumers (today: youpc.org; future: any third-party UPC tool) wire
|
||||
// the engine like this:
|
||||
//
|
||||
// import (
|
||||
// lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
// upc "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc"
|
||||
// )
|
||||
//
|
||||
// cat, _ := upc.NewCatalog()
|
||||
// hc, _ := upc.NewHolidayCalendar()
|
||||
// cr, _ := upc.NewCourtRegistry()
|
||||
//
|
||||
// timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26",
|
||||
// lp.CalcOptions{}, cat, hc, cr)
|
||||
//
|
||||
// Regenerating the snapshot: see cmd/gen-upc-snapshot/README.md.
|
||||
//
|
||||
//go:generate sh -c "echo 'snapshot is regenerated via the gen-upc-snapshot binary — see cmd/gen-upc-snapshot/README.md'"
|
||||
package upc
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rawFS holds the snapshot JSON files. The data files are produced by
|
||||
// cmd/gen-upc-snapshot from a paliad live DB.
|
||||
//
|
||||
//go:embed *.json
|
||||
var rawFS embed.FS
|
||||
|
||||
// Meta is the version block from meta.json.
|
||||
type Meta struct {
|
||||
Version string `json:"version"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
PaliadCommit string `json:"paliad_commit,omitempty"`
|
||||
SourceDBLabel string `json:"source_db_label,omitempty"`
|
||||
RuleCount int `json:"rule_count"`
|
||||
ProceedingCount int `json:"proceeding_count"`
|
||||
TriggerEventCount int `json:"trigger_event_count"`
|
||||
HolidayCount int `json:"holiday_count"`
|
||||
CourtCount int `json:"court_count"`
|
||||
}
|
||||
|
||||
// LoadMeta parses meta.json from the embedded snapshot. Returns an
|
||||
// error when the snapshot hasn't been generated yet (meta.json
|
||||
// missing or empty).
|
||||
func LoadMeta() (Meta, error) {
|
||||
var m Meta
|
||||
buf, err := rawFS.ReadFile("meta.json")
|
||||
if err != nil {
|
||||
return Meta{}, fmt.Errorf("read meta.json: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(buf, &m); err != nil {
|
||||
return Meta{}, fmt.Errorf("decode meta.json: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// readJSON is a tiny helper that decodes one of the embedded files
|
||||
// into a destination value.
|
||||
func readJSON(name string, dst any) error {
|
||||
buf, err := rawFS.ReadFile(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
if err := json.Unmarshal(buf, dst); err != nil {
|
||||
return fmt.Errorf("decode %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
216
pkg/litigationplanner/embedded/upc/holidays.go
Normal file
216
pkg/litigationplanner/embedded/upc/holidays.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package upc
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// SnapshotHoliday is the embedded holiday row shape. Mirrors
|
||||
// paliad.holidays + the generator's output. Country and Regime are
|
||||
// optional pointers — at least one of them is non-empty on every
|
||||
// row (matches paliad's CHECK).
|
||||
type SnapshotHoliday struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
Name string `json:"name"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
Regime *string `json:"regime,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
HolidayType string `json:"holiday_type"`
|
||||
}
|
||||
|
||||
func (h SnapshotHoliday) appliesTo(country, regime string) bool {
|
||||
if h.Country != nil && country != "" && *h.Country == country {
|
||||
return true
|
||||
}
|
||||
if h.Regime != nil && regime != "" && *h.Regime == regime {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h SnapshotHoliday) isVacation() bool { return h.HolidayType == "vacation" }
|
||||
func (h SnapshotHoliday) isClosure() bool { return h.HolidayType == "closure" }
|
||||
|
||||
// SnapshotHolidayCalendar serves HolidayCalendar against the embedded
|
||||
// holiday slice. The semantics mirror paliad's HolidayService:
|
||||
//
|
||||
// - IsNonWorkingDay = weekend OR a closure/vacation row matching
|
||||
// the (country, regime) pair
|
||||
// - AdjustForNonWorkingDays = walk forward day-by-day until
|
||||
// IsNonWorkingDay returns false (bounded at 60 iters)
|
||||
// - AdjustForNonWorkingDaysBackward = same but stepping -1 day
|
||||
// - AdjustForNonWorkingDaysWithReason = forward walk + structured
|
||||
// reason payload (vacation > public_holiday > weekend)
|
||||
type SnapshotHolidayCalendar struct {
|
||||
byDate map[string][]SnapshotHoliday // keyed by YYYY-MM-DD
|
||||
}
|
||||
|
||||
// NewHolidayCalendar parses the embedded holidays.json and returns a
|
||||
// ready-to-use calendar.
|
||||
func NewHolidayCalendar() (*SnapshotHolidayCalendar, error) {
|
||||
var holidays []SnapshotHoliday
|
||||
if err := readJSON("holidays.json", &holidays); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cal := &SnapshotHolidayCalendar{byDate: make(map[string][]SnapshotHoliday, len(holidays))}
|
||||
for _, h := range holidays {
|
||||
cal.byDate[h.Date] = append(cal.byDate[h.Date], h)
|
||||
}
|
||||
return cal, nil
|
||||
}
|
||||
|
||||
// IsNonWorkingDay returns true on weekends or closure/vacation
|
||||
// holidays applicable to the given country/regime.
|
||||
func (c *SnapshotHolidayCalendar) IsNonWorkingDay(date time.Time, country, regime string) bool {
|
||||
if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
|
||||
return true
|
||||
}
|
||||
key := date.Format("2006-01-02")
|
||||
for _, h := range c.byDate[key] {
|
||||
if !h.appliesTo(country, regime) {
|
||||
continue
|
||||
}
|
||||
if h.isClosure() || h.isVacation() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *SnapshotHolidayCalendar) holidayMatch(date time.Time, country, regime string) *SnapshotHoliday {
|
||||
key := date.Format("2006-01-02")
|
||||
for _, h := range c.byDate[key] {
|
||||
if !h.appliesTo(country, regime) {
|
||||
continue
|
||||
}
|
||||
hh := h
|
||||
return &hh
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDays walks forward until the date lands on a
|
||||
// working day. Bound = 60 iters (same as paliad — generous safety
|
||||
// margin past any vacation run).
|
||||
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
|
||||
original = date
|
||||
adjusted = date
|
||||
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
|
||||
adjusted = adjusted.AddDate(0, 0, 1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
return adjusted, original, wasAdjusted
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDaysBackward walks backward until the date lands
|
||||
// on a working day. Same bound.
|
||||
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
|
||||
original = date
|
||||
adjusted = date
|
||||
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
|
||||
adjusted = adjusted.AddDate(0, 0, -1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
return adjusted, original, wasAdjusted
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDaysWithReason is the structured-explanation
|
||||
// counterpart to AdjustForNonWorkingDays. Reason kind precedence
|
||||
// (longest cause wins): vacation > public_holiday > weekend. Reason
|
||||
// is nil when wasAdjusted is false.
|
||||
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool, reason *lp.AdjustmentReason) {
|
||||
original = date
|
||||
adjusted = date
|
||||
|
||||
var holidaysHit []lp.HolidayDTO
|
||||
seen := map[string]bool{}
|
||||
var sawWeekend, sawVacation, sawPublicHoliday bool
|
||||
var vacationName string
|
||||
|
||||
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
|
||||
if wd := adjusted.Weekday(); wd == time.Saturday || wd == time.Sunday {
|
||||
sawWeekend = true
|
||||
}
|
||||
if h := c.holidayMatch(adjusted, country, regime); h != nil {
|
||||
if h.isVacation() {
|
||||
sawVacation = true
|
||||
if vacationName == "" {
|
||||
vacationName = h.Name
|
||||
}
|
||||
} else if h.isClosure() {
|
||||
sawPublicHoliday = true
|
||||
}
|
||||
key := h.Date + "|" + h.Name
|
||||
if !seen[key] {
|
||||
holidaysHit = append(holidaysHit, lp.HolidayDTO{
|
||||
Date: h.Date,
|
||||
Name: h.Name,
|
||||
IsVacation: h.isVacation(),
|
||||
IsClosure: h.isClosure(),
|
||||
})
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
adjusted = adjusted.AddDate(0, 0, 1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
if !wasAdjusted {
|
||||
return adjusted, original, false, nil
|
||||
}
|
||||
r := &lp.AdjustmentReason{Holidays: holidaysHit}
|
||||
switch {
|
||||
case sawVacation:
|
||||
r.Kind = "vacation"
|
||||
r.VacationName = vacationName
|
||||
if vs, ve, ok := c.findVacationBlock(original, country, regime); ok {
|
||||
r.VacationStart = vs.Format("2006-01-02")
|
||||
r.VacationEnd = ve.Format("2006-01-02")
|
||||
}
|
||||
case sawPublicHoliday:
|
||||
r.Kind = "public_holiday"
|
||||
default:
|
||||
r.Kind = "weekend"
|
||||
}
|
||||
if sawWeekend && r.Kind == "weekend" {
|
||||
r.OriginalWeekday = original.Weekday().String()
|
||||
}
|
||||
return adjusted, original, true, r
|
||||
}
|
||||
|
||||
// findVacationBlock scans outward from date through non-working days
|
||||
// to locate the first/last IsVacation entries. Weekends inside the
|
||||
// run are traversed but don't extend the reported span — start/end
|
||||
// are always real vacation entries.
|
||||
func (c *SnapshotHolidayCalendar) findVacationBlock(date time.Time, country, regime string) (start, end time.Time, ok bool) {
|
||||
cur := date
|
||||
for i := 0; i < 60; i++ {
|
||||
if !c.IsNonWorkingDay(cur, country, regime) {
|
||||
break
|
||||
}
|
||||
if h := c.holidayMatch(cur, country, regime); h != nil && h.isVacation() {
|
||||
start = cur
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
cur = cur.AddDate(0, 0, -1)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cur = date
|
||||
for i := 0; i < 60; i++ {
|
||||
if !c.IsNonWorkingDay(cur, country, regime) {
|
||||
break
|
||||
}
|
||||
if h := c.holidayMatch(cur, country, regime); h != nil && h.isVacation() {
|
||||
end = cur
|
||||
}
|
||||
cur = cur.AddDate(0, 0, 1)
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// Compile-time assertion that SnapshotHolidayCalendar satisfies
|
||||
// lp.HolidayCalendar.
|
||||
var _ lp.HolidayCalendar = (*SnapshotHolidayCalendar)(nil)
|
||||
32
pkg/litigationplanner/embedded/upc/holidays.json
Normal file
32
pkg/litigationplanner/embedded/upc/holidays.json
Normal file
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-01-01",
|
||||
"name": "Neujahr",
|
||||
"country": "DE",
|
||||
"holiday_type": "closure"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"name": "Tag der Arbeit",
|
||||
"country": "DE",
|
||||
"holiday_type": "closure"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"name": "UPC Sommerpause",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-25",
|
||||
"name": "UPC Sommerpause",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"name": "UPC Sommerpause",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
}
|
||||
]
|
||||
11
pkg/litigationplanner/embedded/upc/meta.json
Normal file
11
pkg/litigationplanner/embedded/upc/meta.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "2026-05-26-1-placeholder",
|
||||
"generated_at": "2026-05-26T15:00:00Z",
|
||||
"paliad_commit": "",
|
||||
"source_db_label": "placeholder — operator must run `make snapshot-upc` against prod once mig 134/135 are applied",
|
||||
"rule_count": 2,
|
||||
"proceeding_count": 2,
|
||||
"trigger_event_count": 0,
|
||||
"holiday_count": 5,
|
||||
"court_count": 2
|
||||
}
|
||||
32
pkg/litigationplanner/embedded/upc/proceeding_types.json
Normal file
32
pkg/litigationplanner/embedded/upc/proceeding_types.json
Normal file
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"id": 8,
|
||||
"code": "upc.inf.cfi",
|
||||
"name": "Verletzungsverfahren",
|
||||
"name_en": "Infringement Action",
|
||||
"description": "UPC infringement proceedings at first instance.",
|
||||
"jurisdiction": "UPC",
|
||||
"category": "fristenrechner",
|
||||
"default_color": "#3b82f6",
|
||||
"sort_order": 10,
|
||||
"is_active": true,
|
||||
"trigger_event_label_de": null,
|
||||
"trigger_event_label_en": null,
|
||||
"appeal_target": null
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"code": "upc.rev.cfi",
|
||||
"name": "Nichtigkeitsverfahren",
|
||||
"name_en": "Revocation Action",
|
||||
"description": "UPC revocation proceedings at first instance.",
|
||||
"jurisdiction": "UPC",
|
||||
"category": "fristenrechner",
|
||||
"default_color": "#f59e0b",
|
||||
"sort_order": 20,
|
||||
"is_active": true,
|
||||
"trigger_event_label_de": null,
|
||||
"trigger_event_label_en": null,
|
||||
"appeal_target": null
|
||||
}
|
||||
]
|
||||
43
pkg/litigationplanner/embedded/upc/rules.json
Normal file
43
pkg/litigationplanner/embedded/upc/rules.json
Normal file
@@ -0,0 +1,43 @@
|
||||
[
|
||||
{
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"proceeding_type_id": 8,
|
||||
"submission_code": "upc.inf.cfi.soc",
|
||||
"name": "Klageerhebung",
|
||||
"name_en": "Statement of Claim",
|
||||
"duration_value": 0,
|
||||
"duration_unit": "months",
|
||||
"sequence_order": 1,
|
||||
"is_spawn": false,
|
||||
"is_active": true,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"priority": "mandatory",
|
||||
"is_court_set": false,
|
||||
"is_bilateral": false,
|
||||
"lifecycle_state": "published"
|
||||
},
|
||||
{
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"proceeding_type_id": 8,
|
||||
"parent_id": "11111111-1111-1111-1111-111111111111",
|
||||
"submission_code": "upc.inf.cfi.sod",
|
||||
"name": "Klageerwiderung",
|
||||
"name_en": "Statement of Defence",
|
||||
"primary_party": "defendant",
|
||||
"duration_value": 3,
|
||||
"duration_unit": "months",
|
||||
"timing": "after",
|
||||
"rule_code": "UPC.RoP.23.1",
|
||||
"legal_source": "UPC.RoP.23.1",
|
||||
"sequence_order": 2,
|
||||
"is_spawn": false,
|
||||
"is_active": true,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"priority": "mandatory",
|
||||
"is_court_set": false,
|
||||
"is_bilateral": false,
|
||||
"lifecycle_state": "published"
|
||||
}
|
||||
]
|
||||
315
pkg/litigationplanner/embedded/upc/snapshot.go
Normal file
315
pkg/litigationplanner/embedded/upc/snapshot.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package upc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// SnapshotCatalog is the embedded-JSON implementation of lp.Catalog.
|
||||
// All lookups are O(1) on indexed in-memory maps; LookupEvents does a
|
||||
// linear scan of the rule slice (< 100 rows in the UPC corpus, no
|
||||
// index needed).
|
||||
//
|
||||
// ProjectHint is ignored — the snapshot has no project-scoped rules.
|
||||
// applies_to_target (B1) and condition_expr (Phase 2) ride along on
|
||||
// each Rule as ordinary fields; the engine consumes them identically
|
||||
// whether the catalog is paliad-backed or snapshot-backed.
|
||||
type SnapshotCatalog struct {
|
||||
procs []lp.ProceedingType
|
||||
rules []lp.Rule
|
||||
triggerByID map[int64]lp.TriggerEvent
|
||||
rulesByProc map[int][]lp.Rule
|
||||
ruleByID map[uuid.UUID]lp.Rule
|
||||
procByID map[int]lp.ProceedingType
|
||||
procByCode map[string]lp.ProceedingType
|
||||
rulesByTriggr map[int64][]lp.Rule
|
||||
}
|
||||
|
||||
// NewCatalog parses the embedded snapshot and returns a ready-to-use
|
||||
// Catalog. Returns an error when the JSON is missing or malformed
|
||||
// (e.g. snapshot never generated, or stale relative to the package
|
||||
// types).
|
||||
func NewCatalog() (*SnapshotCatalog, error) {
|
||||
var procs []lp.ProceedingType
|
||||
if err := readJSON("proceeding_types.json", &procs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rules []lp.Rule
|
||||
if err := readJSON("rules.json", &rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var triggers []lp.TriggerEvent
|
||||
if err := readJSON("trigger_events.json", &triggers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &SnapshotCatalog{
|
||||
procs: procs,
|
||||
rules: rules,
|
||||
triggerByID: make(map[int64]lp.TriggerEvent, len(triggers)),
|
||||
rulesByProc: make(map[int][]lp.Rule),
|
||||
ruleByID: make(map[uuid.UUID]lp.Rule, len(rules)),
|
||||
procByID: make(map[int]lp.ProceedingType, len(procs)),
|
||||
procByCode: make(map[string]lp.ProceedingType, len(procs)),
|
||||
rulesByTriggr: make(map[int64][]lp.Rule),
|
||||
}
|
||||
for _, p := range procs {
|
||||
c.procByID[p.ID] = p
|
||||
c.procByCode[p.Code] = p
|
||||
}
|
||||
for _, r := range rules {
|
||||
c.ruleByID[r.ID] = r
|
||||
if r.ProceedingTypeID != nil {
|
||||
c.rulesByProc[*r.ProceedingTypeID] = append(c.rulesByProc[*r.ProceedingTypeID], r)
|
||||
}
|
||||
if r.TriggerEventID != nil {
|
||||
c.rulesByTriggr[*r.TriggerEventID] = append(c.rulesByTriggr[*r.TriggerEventID], r)
|
||||
}
|
||||
}
|
||||
for _, t := range triggers {
|
||||
c.triggerByID[t.ID] = t
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// LoadProceeding returns the proceeding-type metadata + rules. The
|
||||
// ProjectHint is ignored on the snapshot side (no projects).
|
||||
func (c *SnapshotCatalog) LoadProceeding(_ context.Context, code string, _ lp.ProjectHint) (*lp.ProceedingType, []lp.Rule, error) {
|
||||
p, ok := c.procByCode[code]
|
||||
if !ok {
|
||||
return nil, nil, lp.ErrUnknownProceedingType
|
||||
}
|
||||
// Return a defensive copy of the rule slice so callers can sort /
|
||||
// mutate without leaking back into the cache.
|
||||
src := c.rulesByProc[p.ID]
|
||||
dst := make([]lp.Rule, len(src))
|
||||
copy(dst, src)
|
||||
return &p, dst, nil
|
||||
}
|
||||
|
||||
// LoadProceedingByID is the resolver used by CalculateRule.
|
||||
func (c *SnapshotCatalog) LoadProceedingByID(_ context.Context, id int) (*lp.ProceedingType, error) {
|
||||
p, ok := c.procByID[id]
|
||||
if !ok {
|
||||
return nil, lp.ErrUnknownProceedingType
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// LoadRuleByID resolves a rule UUID to the rule row.
|
||||
func (c *SnapshotCatalog) LoadRuleByID(_ context.Context, ruleID string) (*lp.Rule, error) {
|
||||
id, err := uuid.Parse(ruleID)
|
||||
if err != nil {
|
||||
return nil, lp.ErrUnknownRule
|
||||
}
|
||||
r, ok := c.ruleByID[id]
|
||||
if !ok {
|
||||
return nil, lp.ErrUnknownRule
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode).
|
||||
func (c *SnapshotCatalog) LoadRuleByCode(_ context.Context, proceedingCode, submissionCode string) (*lp.Rule, *lp.ProceedingType, error) {
|
||||
p, ok := c.procByCode[proceedingCode]
|
||||
if !ok {
|
||||
return nil, nil, lp.ErrUnknownProceedingType
|
||||
}
|
||||
for _, r := range c.rulesByProc[p.ID] {
|
||||
if r.SubmissionCode != nil && *r.SubmissionCode == submissionCode {
|
||||
rr := r
|
||||
pp := p
|
||||
return &rr, &pp, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, lp.ErrUnknownRule
|
||||
}
|
||||
|
||||
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted rules.
|
||||
func (c *SnapshotCatalog) LoadRulesByTriggerEvent(_ context.Context, triggerEventID int64) ([]lp.Rule, error) {
|
||||
src := c.rulesByTriggr[triggerEventID]
|
||||
dst := make([]lp.Rule, len(src))
|
||||
copy(dst, src)
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// LoadTriggerEventsByIDs returns trigger-event rows for the given IDs.
|
||||
func (c *SnapshotCatalog) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]lp.TriggerEvent, error) {
|
||||
out := make(map[int64]lp.TriggerEvent, len(ids))
|
||||
for _, id := range ids {
|
||||
if t, ok := c.triggerByID[id]; ok {
|
||||
out[id] = t
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LookupEvents runs the multi-axis filter + depth walk against the
|
||||
// in-memory rule slice. Mirrors the paliad-side semantics: unknown
|
||||
// axis values fall through as "no filter on this axis"; anchors are
|
||||
// depth=1, walked-in children are depth=2+; results ordered by
|
||||
// (proceeding_type_id, sequence_order).
|
||||
func (c *SnapshotCatalog) LookupEvents(_ context.Context, axes lp.EventLookupAxes, depth lp.EventLookupDepth) ([]lp.EventMatch, error) {
|
||||
// Validate axes; unknown values reset to empty (no filter).
|
||||
jurisdiction := axes.Jurisdiction
|
||||
if jurisdiction != "" && jurisdiction != "UPC" && jurisdiction != "DE" &&
|
||||
jurisdiction != "EPA" && jurisdiction != "DPMA" {
|
||||
jurisdiction = ""
|
||||
}
|
||||
party := axes.Party
|
||||
if party != "" && !lp.IsValidPrimaryParty(party) {
|
||||
party = ""
|
||||
}
|
||||
appealTarget := axes.AppealTarget
|
||||
if appealTarget != "" && !lp.IsValidAppealTarget(appealTarget) {
|
||||
appealTarget = ""
|
||||
}
|
||||
|
||||
// First pass: find anchor matches (rules that satisfy every
|
||||
// non-zero axis directly).
|
||||
anchors := make(map[uuid.UUID]bool, len(c.rules))
|
||||
for _, r := range c.rules {
|
||||
if r.ProceedingTypeID == nil {
|
||||
continue
|
||||
}
|
||||
p := c.procByID[*r.ProceedingTypeID]
|
||||
if jurisdiction != "" && (p.Jurisdiction == nil || *p.Jurisdiction != jurisdiction) {
|
||||
continue
|
||||
}
|
||||
if axes.ProceedingTypeID != nil && *r.ProceedingTypeID != *axes.ProceedingTypeID {
|
||||
continue
|
||||
}
|
||||
if party != "" && (r.PrimaryParty == nil || *r.PrimaryParty != party) {
|
||||
continue
|
||||
}
|
||||
// EventCategoryID axis: the embedded snapshot doesn't carry
|
||||
// the deadline_concept_event_types junction (only paliad has
|
||||
// it). When EventCategoryID is set, we conservatively return
|
||||
// no matches — youpc.org doesn't use this axis today. Future
|
||||
// snapshot generations can add a concept→category index if
|
||||
// needed.
|
||||
if axes.EventCategoryID != nil {
|
||||
continue
|
||||
}
|
||||
if appealTarget != "" {
|
||||
found := false
|
||||
for _, t := range r.AppliesToTarget {
|
||||
if t == appealTarget {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
anchors[r.ID] = true
|
||||
}
|
||||
|
||||
// Second pass: depth walk. Expand anchors → their immediate
|
||||
// children (parent_id ∈ matched). Iterate to fixpoint for
|
||||
// EventLookupDepthAllFollowing; stop after one pass for
|
||||
// EventLookupDepthNext.
|
||||
matched := make(map[uuid.UUID]bool, len(anchors))
|
||||
for id := range anchors {
|
||||
matched[id] = true
|
||||
}
|
||||
if depth == lp.EventLookupDepthNext || depth == lp.EventLookupDepthAllFollowing {
|
||||
for {
|
||||
grew := false
|
||||
for _, r := range c.rules {
|
||||
if matched[r.ID] {
|
||||
continue
|
||||
}
|
||||
if r.ParentID == nil {
|
||||
continue
|
||||
}
|
||||
if matched[*r.ParentID] {
|
||||
matched[r.ID] = true
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
if !grew || depth == lp.EventLookupDepthNext {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute depth from anchor: walk parent_id chain until we hit
|
||||
// an anchor.
|
||||
depths := make(map[uuid.UUID]int, len(matched))
|
||||
for id := range matched {
|
||||
if anchors[id] {
|
||||
depths[id] = 1
|
||||
continue
|
||||
}
|
||||
// Walk up.
|
||||
d := 1
|
||||
cur := id
|
||||
maxIter := len(matched) + 1
|
||||
for i := 0; i < maxIter; i++ {
|
||||
r, ok := c.ruleByID[cur]
|
||||
if !ok || r.ParentID == nil {
|
||||
break
|
||||
}
|
||||
d++
|
||||
cur = *r.ParentID
|
||||
if anchors[cur] {
|
||||
break
|
||||
}
|
||||
}
|
||||
depths[id] = d
|
||||
}
|
||||
|
||||
// Compose output, ordered by (proceeding_type_id, sequence_order)
|
||||
// via the catalog's rule slice ordering.
|
||||
out := make([]lp.EventMatch, 0, len(matched))
|
||||
for _, r := range c.rules {
|
||||
if !matched[r.ID] {
|
||||
continue
|
||||
}
|
||||
var parentRuleID *uuid.UUID
|
||||
if r.ParentID != nil && matched[*r.ParentID] {
|
||||
p := *r.ParentID
|
||||
parentRuleID = &p
|
||||
}
|
||||
proc := lp.ProceedingType{}
|
||||
if r.ProceedingTypeID != nil {
|
||||
proc = c.procByID[*r.ProceedingTypeID]
|
||||
}
|
||||
out = append(out, lp.EventMatch{
|
||||
Rule: r,
|
||||
ProceedingType: proc,
|
||||
Priority: r.Priority,
|
||||
DepthFromAnchor: depths[r.ID],
|
||||
ParentRuleID: parentRuleID,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LoadScenarios returns an empty slice. The snapshot catalog has no
|
||||
// scenarios — youpc.org (the consumer today) doesn't carry a project /
|
||||
// user model. Future snapshot variants could ship demo scenarios, but
|
||||
// v1 returns nothing.
|
||||
func (c *SnapshotCatalog) LoadScenarios(_ context.Context, _ lp.ScenarioFilter) ([]lp.Scenario, error) {
|
||||
return []lp.Scenario{}, nil
|
||||
}
|
||||
|
||||
// MatchScenario always returns ErrUnknownScenario — the snapshot has
|
||||
// no scenarios to match against.
|
||||
func (c *SnapshotCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*lp.Scenario, error) {
|
||||
return nil, lp.ErrUnknownScenario
|
||||
}
|
||||
|
||||
// Compile-time assertion that SnapshotCatalog satisfies lp.Catalog.
|
||||
var _ lp.Catalog = (*SnapshotCatalog)(nil)
|
||||
|
||||
// ErrSnapshotEmpty is returned by NewCatalog when the embedded files
|
||||
// parse but the corpus is empty (zero proceedings) — almost always a
|
||||
// sign that the snapshot has never been generated.
|
||||
var ErrSnapshotEmpty = fmt.Errorf("upc snapshot is empty — run cmd/gen-upc-snapshot")
|
||||
215
pkg/litigationplanner/embedded/upc/snapshot_test.go
Normal file
215
pkg/litigationplanner/embedded/upc/snapshot_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package upc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// TestSnapshotMeta loads + parses meta.json and asserts the version
|
||||
// + non-zero counts. Until the operator regenerates the snapshot the
|
||||
// placeholder shipped with Slice C must still parse cleanly.
|
||||
func TestSnapshotMeta(t *testing.T) {
|
||||
meta, err := LoadMeta()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMeta: %v", err)
|
||||
}
|
||||
if meta.Version == "" {
|
||||
t.Error("meta.Version is empty")
|
||||
}
|
||||
if meta.ProceedingCount <= 0 {
|
||||
t.Errorf("meta.ProceedingCount = %d, want > 0", meta.ProceedingCount)
|
||||
}
|
||||
if meta.RuleCount <= 0 {
|
||||
t.Errorf("meta.RuleCount = %d, want > 0", meta.RuleCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCatalog smoke-tests the embedded catalog's lookups
|
||||
// against the shipped placeholder. After operator regeneration the
|
||||
// asserts on per-row content still hold because they pin the wire
|
||||
// shape (proceedingType.Code, rule resolution by code, lookup-events
|
||||
// jurisdiction filter).
|
||||
func TestSnapshotCatalog(t *testing.T) {
|
||||
cat, err := NewCatalog()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCatalog: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("LoadProceeding upc.inf.cfi", func(t *testing.T) {
|
||||
pt, rules, err := cat.LoadProceeding(ctx, "upc.inf.cfi", lp.ProjectHint{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProceeding: %v", err)
|
||||
}
|
||||
if pt.Code != "upc.inf.cfi" {
|
||||
t.Errorf("pt.Code = %q, want upc.inf.cfi", pt.Code)
|
||||
}
|
||||
if pt.Jurisdiction == nil || *pt.Jurisdiction != "UPC" {
|
||||
t.Errorf("pt.Jurisdiction = %v, want UPC", pt.Jurisdiction)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
t.Error("LoadProceeding returned zero rules — snapshot empty?")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LoadProceeding unknown code returns ErrUnknownProceedingType", func(t *testing.T) {
|
||||
_, _, err := cat.LoadProceeding(ctx, "no.such.code", lp.ProjectHint{})
|
||||
if err != lp.ErrUnknownProceedingType {
|
||||
t.Errorf("got %v, want ErrUnknownProceedingType", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LookupEvents UPC all-following returns the whole UPC corpus", func(t *testing.T) {
|
||||
matches, err := cat.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected non-empty UPC corpus")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
|
||||
t.Errorf("non-UPC row leaked: %v", m.ProceedingType.Code)
|
||||
}
|
||||
if m.DepthFromAnchor < 1 {
|
||||
t.Errorf("depth = %d, want >= 1", m.DepthFromAnchor)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LookupEvents party=defendant scopes anchors", func(t *testing.T) {
|
||||
matches, err := cat.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
Party: "defendant",
|
||||
}, lp.EventLookupDepthNext)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// Anchor rows (depth=1) must all be defendant.
|
||||
anyDefendant := false
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
|
||||
t.Errorf("anchor row %s is not defendant: %v", m.Rule.Name, m.Rule.PrimaryParty)
|
||||
}
|
||||
anyDefendant = true
|
||||
}
|
||||
if !anyDefendant {
|
||||
t.Log("no defendant rules in the placeholder corpus — operator should regenerate the snapshot")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSnapshotEngineCompute runs the litigationplanner engine against
|
||||
// the embedded snapshot end-to-end. Ensures the wiring between the
|
||||
// snapshot Catalog / HolidayCalendar / CourtRegistry + the engine
|
||||
// produces a non-empty timeline.
|
||||
func TestSnapshotEngineCompute(t *testing.T) {
|
||||
cat, err := NewCatalog()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCatalog: %v", err)
|
||||
}
|
||||
hc, err := NewHolidayCalendar()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHolidayCalendar: %v", err)
|
||||
}
|
||||
cr, err := NewCourtRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCourtRegistry: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-01-15", lp.CalcOptions{}, cat, hc, cr)
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
if timeline == nil {
|
||||
t.Fatal("Calculate returned nil timeline")
|
||||
}
|
||||
if timeline.ProceedingType != "upc.inf.cfi" {
|
||||
t.Errorf("timeline.ProceedingType = %q, want upc.inf.cfi", timeline.ProceedingType)
|
||||
}
|
||||
if len(timeline.Deadlines) == 0 {
|
||||
t.Error("timeline has zero deadlines — snapshot empty?")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotHolidayCalendar smoke-tests the embedded calendar.
|
||||
// Pins core semantics: weekends are non-working; holidays at
|
||||
// matching country/regime are non-working; mismatches don't fire.
|
||||
func TestSnapshotHolidayCalendar(t *testing.T) {
|
||||
hc, err := NewHolidayCalendar()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHolidayCalendar: %v", err)
|
||||
}
|
||||
|
||||
// 2026-01-03 is a Saturday — weekend, non-working regardless of
|
||||
// country/regime.
|
||||
sat := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)
|
||||
if !hc.IsNonWorkingDay(sat, "DE", "UPC") {
|
||||
t.Error("Saturday should be non-working")
|
||||
}
|
||||
|
||||
// 2026-01-01 is Neujahr (DE closure) — non-working when country=DE.
|
||||
newYear := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
if !hc.IsNonWorkingDay(newYear, "DE", "UPC") {
|
||||
t.Error("Neujahr should be non-working for DE")
|
||||
}
|
||||
|
||||
// 2026-01-05 is a Monday — working (not in holidays, not weekend).
|
||||
mon := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
|
||||
if hc.IsNonWorkingDay(mon, "DE", "UPC") {
|
||||
t.Error("Monday 2026-01-05 should be working")
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDays from a Saturday should land on Monday.
|
||||
adj, _, was := hc.AdjustForNonWorkingDays(sat, "DE", "UPC")
|
||||
if !was {
|
||||
t.Error("expected adjustment for Saturday")
|
||||
}
|
||||
if adj.Weekday() != time.Monday {
|
||||
t.Errorf("adjusted weekday = %v, want Monday", adj.Weekday())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCourtRegistry pins (country, regime) resolution.
|
||||
func TestSnapshotCourtRegistry(t *testing.T) {
|
||||
cr, err := NewCourtRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCourtRegistry: %v", err)
|
||||
}
|
||||
|
||||
t.Run("empty courtID falls back to defaults", func(t *testing.T) {
|
||||
c, r, err := cr.CountryRegime("", "DE", "UPC")
|
||||
if err != nil {
|
||||
t.Fatalf("CountryRegime: %v", err)
|
||||
}
|
||||
if c != "DE" || r != "UPC" {
|
||||
t.Errorf("got (%q, %q), want (DE, UPC)", c, r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("known UPC court resolves", func(t *testing.T) {
|
||||
c, r, err := cr.CountryRegime("upc-ld-munich", "DE", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CountryRegime: %v", err)
|
||||
}
|
||||
if c != "DE" || r != "UPC" {
|
||||
t.Errorf("got (%q, %q), want (DE, UPC)", c, r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown court returns error", func(t *testing.T) {
|
||||
_, _, err := cr.CountryRegime("not-a-court", "DE", "UPC")
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown court")
|
||||
}
|
||||
})
|
||||
}
|
||||
1
pkg/litigationplanner/embedded/upc/trigger_events.json
Normal file
1
pkg/litigationplanner/embedded/upc/trigger_events.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
1041
pkg/litigationplanner/engine.go
Normal file
1041
pkg/litigationplanner/engine.go
Normal file
File diff suppressed because it is too large
Load Diff
145
pkg/litigationplanner/expr.go
Normal file
145
pkg/litigationplanner/expr.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package litigationplanner
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// allFlagsSet returns true when every element of `required` is present in
|
||||
// `set`. Empty `required` returns true (no condition). Retained as the
|
||||
// fallback predicate used by EvalConditionExpr when condition_expr is
|
||||
// NULL but the legacy condition_flag text[] is set — preserves
|
||||
// transition-window behaviour for any row Slice 2 missed (it shouldn't,
|
||||
// but defensive).
|
||||
func allFlagsSet(required []string, set map[string]struct{}) bool {
|
||||
for _, f := range required {
|
||||
if _, ok := set[f]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// EvalConditionExpr returns true iff the rule's gate predicate is
|
||||
// satisfied for the caller's flag set. Drives flag-conditional rendering
|
||||
// + flag-conditional alt-swap throughout the calculator.
|
||||
//
|
||||
// Grammar (design §2.4 long form, mig 084 backfill):
|
||||
//
|
||||
// {"flag": "<name>"} — leaf: true iff <name> ∈ flags
|
||||
// {"op": "and", "args": [<n>...]} — true iff every arg evaluates true
|
||||
// {"op": "or", "args": [<n>...]} — true iff any arg evaluates true
|
||||
// {"op": "not", "args": [<one>]} — true iff the single arg is false
|
||||
//
|
||||
// NULL / empty / "null" expression → true (unconditional). Malformed
|
||||
// JSON → true (defensive: the rule still renders, the lawyer sees
|
||||
// it even if the gate is broken).
|
||||
//
|
||||
// Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag
|
||||
// text[] column; the fallback that AND'd over it is gone. Any future
|
||||
// row needing array-of-flags semantics writes the equivalent
|
||||
// {"op":"and","args":[{"flag":"<a>"},...]} jsonb directly.
|
||||
func EvalConditionExpr(expr []byte, flags map[string]struct{}) bool {
|
||||
if len(expr) == 0 || string(expr) == "null" {
|
||||
return true
|
||||
}
|
||||
return EvalConditionExprNode(expr, flags)
|
||||
}
|
||||
|
||||
// EvalConditionExprNode walks one node of the condition_expr jsonb
|
||||
// tree. Recursion depth is bounded by the editor (Slice 11 caps tree
|
||||
// depth + arg count); pre-Slice-11 backfilled rows have at most a
|
||||
// 2-arg AND (mig 084).
|
||||
func EvalConditionExprNode(raw []byte, flags map[string]struct{}) bool {
|
||||
var node struct {
|
||||
Flag string `json:"flag"`
|
||||
Op string `json:"op"`
|
||||
Args []json.RawMessage `json:"args"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
// Malformed → unconditional. The Slice 11 editor's validation
|
||||
// will block such writes; in the live corpus today mig 084's
|
||||
// jsonb_build_object output is well-formed by construction.
|
||||
return true
|
||||
}
|
||||
if node.Flag != "" {
|
||||
_, ok := flags[node.Flag]
|
||||
return ok
|
||||
}
|
||||
switch node.Op {
|
||||
case "and":
|
||||
for _, a := range node.Args {
|
||||
if !EvalConditionExprNode(a, flags) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "or":
|
||||
for _, a := range node.Args {
|
||||
if EvalConditionExprNode(a, flags) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "not":
|
||||
if len(node.Args) != 1 {
|
||||
// Malformed NOT — fall through to unconditional rather
|
||||
// than risk suppressing a rule the lawyer expects to see.
|
||||
return true
|
||||
}
|
||||
return !EvalConditionExprNode(node.Args[0], flags)
|
||||
}
|
||||
// Unknown op (forward-compat with editor extensions): treat as
|
||||
// unconditional so the rule still renders.
|
||||
return true
|
||||
}
|
||||
|
||||
// HasConditionExpr returns true when the rule carries a non-empty,
|
||||
// non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the
|
||||
// pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the
|
||||
// flag-keyed alt-swap branch. Same intent: "this rule has a gate;
|
||||
// when the gate flips to met, swap to alt".
|
||||
func HasConditionExpr(expr NullableJSON) bool {
|
||||
if len(expr) == 0 {
|
||||
return false
|
||||
}
|
||||
s := string(expr)
|
||||
return s != "null" && s != "{}"
|
||||
}
|
||||
|
||||
// ExtractFlagsFromExpr walks the jsonb gate and returns the unique
|
||||
// flag names referenced as {"flag":"<name>"} leaves. Used by
|
||||
// CalculateRule's response (FlagsRequired) so the result-card calc
|
||||
// panel can render flag checkboxes for each gate input. Replaces the
|
||||
// dropped condition_flag text[] enumeration. Returns nil on a NULL
|
||||
// expression or one that contains no flag leaves.
|
||||
func ExtractFlagsFromExpr(expr NullableJSON) []string {
|
||||
if !HasConditionExpr(expr) {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
walkFlagLeaves([]byte(expr), seen)
|
||||
if len(seen) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for f := range seen {
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func walkFlagLeaves(raw []byte, into map[string]struct{}) {
|
||||
var node struct {
|
||||
Flag string `json:"flag"`
|
||||
Op string `json:"op"`
|
||||
Args []json.RawMessage `json:"args"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
return
|
||||
}
|
||||
if node.Flag != "" {
|
||||
into[node.Flag] = struct{}{}
|
||||
return
|
||||
}
|
||||
for _, a := range node.Args {
|
||||
walkFlagLeaves(a, into)
|
||||
}
|
||||
}
|
||||
25
pkg/litigationplanner/holidays.go
Normal file
25
pkg/litigationplanner/holidays.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package litigationplanner
|
||||
|
||||
import "time"
|
||||
|
||||
// HolidayCalendar adjusts dates onto working days for a given
|
||||
// (country, regime) pair. The calculator only needs three primitives:
|
||||
//
|
||||
// - IsNonWorkingDay — used by the addWorkingDays walker
|
||||
// - AdjustForNonWorkingDays — forward snap (timing='after')
|
||||
// - AdjustForNonWorkingDaysBackward — backward snap (timing='before')
|
||||
// - AdjustForNonWorkingDaysWithReason — like the forward snap but
|
||||
// also returns *AdjustmentReason so the timeline can render the
|
||||
// "rolled past holiday X" footer in TimelineEntry.AdjustmentReason.
|
||||
//
|
||||
// Implementations:
|
||||
// - paliad: reads paliad.holidays, caches per-year, merges DE
|
||||
// federal fallback.
|
||||
// - embedded/upc (Slice C): in-memory year-keyed map populated from
|
||||
// the embedded JSON snapshot.
|
||||
type HolidayCalendar interface {
|
||||
IsNonWorkingDay(date time.Time, country, regime string) bool
|
||||
AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool)
|
||||
AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool)
|
||||
AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool, reason *AdjustmentReason)
|
||||
}
|
||||
123
pkg/litigationplanner/legal_source.go
Normal file
123
pkg/litigationplanner/legal_source.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package litigationplanner
|
||||
|
||||
import "strings"
|
||||
|
||||
// FormatLegalSourceDisplay renders a structured legal_source code into
|
||||
// the form HLC users read in pleadings:
|
||||
//
|
||||
// UPC.RoP.23.1 → "UPC RoP R.23(1)"
|
||||
// UPC.RoP.139 → "UPC RoP R.139"
|
||||
// DE.PatG.82.1 → "PatG §82(1)"
|
||||
// DE.ZPO.276.1 → "ZPO §276(1)"
|
||||
// EU.EPÜ.108 → "EPÜ Art.108"
|
||||
// EU.EPC-R.79.1 → "EPC R.79(1)"
|
||||
// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
|
||||
//
|
||||
// Returns the empty string for an empty input. Unknown jurisdictions
|
||||
// fall through with the structured form preserved (caller decides
|
||||
// whether to display).
|
||||
func FormatLegalSourceDisplay(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
// Malformed — return as-is so the caller still has something.
|
||||
return src
|
||||
}
|
||||
code := parts[1]
|
||||
rest := parts[2:]
|
||||
var prefix string
|
||||
switch code {
|
||||
case "RoP":
|
||||
prefix = "UPC RoP R."
|
||||
case "PatG":
|
||||
prefix = "PatG §"
|
||||
case "ZPO":
|
||||
prefix = "ZPO §"
|
||||
case "EPÜ":
|
||||
prefix = "EPÜ Art."
|
||||
case "EPC-R":
|
||||
prefix = "EPC R."
|
||||
case "RPBA":
|
||||
prefix = "RPBA Art."
|
||||
default:
|
||||
prefix = code + " "
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(prefix) + len(src))
|
||||
b.WriteString(prefix)
|
||||
b.WriteString(rest[0])
|
||||
for _, p := range rest[1:] {
|
||||
b.WriteByte('(')
|
||||
b.WriteString(p)
|
||||
b.WriteByte(')')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// BuildLegalSourceURL maps a structured legal_source code to a
|
||||
// youpc.org/laws permalink when the cited body is hosted there. Today
|
||||
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
|
||||
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
|
||||
// home yet, so the helper returns the empty string for those and the
|
||||
// caller renders the display string as plain text.
|
||||
//
|
||||
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
|
||||
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
|
||||
// the law-number position are dropped; youpc resolves the page at
|
||||
// <type>.<number> granularity. The law-number is zero-padded to 3
|
||||
// digits to match how youpc stores law_number (laws-data.json carries
|
||||
// "001" / "023" / "220" forms).
|
||||
//
|
||||
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
|
||||
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
|
||||
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
|
||||
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
|
||||
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
|
||||
func BuildLegalSourceURL(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
var lawType string
|
||||
switch parts[0] + "." + parts[1] {
|
||||
case "UPC.RoP":
|
||||
lawType = "UPCRoP"
|
||||
case "UPC.UPCA":
|
||||
lawType = "UPCA"
|
||||
case "UPC.UPCS":
|
||||
lawType = "UPCS"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
number := padLawNumber(parts[2])
|
||||
if number == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://youpc.org/laws#" + lawType + "." + number
|
||||
}
|
||||
|
||||
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
|
||||
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
|
||||
// 112a) pass through unchanged so the URL still resolves. Empty input
|
||||
// returns the empty string.
|
||||
func padLawNumber(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if len(s) >= 3 {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat("0", 3-len(s)) + s
|
||||
}
|
||||
68
pkg/litigationplanner/primary_party_test.go
Normal file
68
pkg/litigationplanner/primary_party_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package litigationplanner
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsValidPrimaryParty pins the four-value vocab + NULL-equivalent
|
||||
// behaviour the rule-editor's B3 validation hook depends on. Empty
|
||||
// string is "no value supplied" = valid (NULL maps to empty on the
|
||||
// wire). Non-empty must match one of the four canonical values.
|
||||
func TestIsValidPrimaryParty(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"", true},
|
||||
{"claimant", true},
|
||||
{"defendant", true},
|
||||
{"court", true},
|
||||
{"both", true},
|
||||
{"Claimant", false}, // case-sensitive
|
||||
{"clamant", false}, // typo
|
||||
{"applicant", false}, // not in vocab
|
||||
{"foo", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsValidPrimaryParty(c.in); got != c.want {
|
||||
t.Errorf("IsValidPrimaryParty(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrimaryPartiesOrder pins the canonical chip order (admin UI
|
||||
// renders these as a select; reordering would break user muscle
|
||||
// memory). Update both the slice + this test together if the order
|
||||
// genuinely needs to change.
|
||||
func TestPrimaryPartiesOrder(t *testing.T) {
|
||||
want := []string{"claimant", "defendant", "court", "both"}
|
||||
if len(PrimaryParties) != len(want) {
|
||||
t.Fatalf("PrimaryParties has %d entries, want %d", len(PrimaryParties), len(want))
|
||||
}
|
||||
for i, p := range PrimaryParties {
|
||||
if p != want[i] {
|
||||
t.Errorf("PrimaryParties[%d] = %q, want %q", i, p, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsValidAppealTarget is sibling-of: same shape, ensures the B1
|
||||
// helper has the same NULL-equivalent semantic.
|
||||
func TestIsValidAppealTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"", true},
|
||||
{"endentscheidung", true},
|
||||
{"kostenentscheidung", true},
|
||||
{"anordnung", true},
|
||||
{"schadensbemessung", true},
|
||||
{"bucheinsicht", true},
|
||||
{"foo", false},
|
||||
{"Endentscheidung", false}, // case-sensitive
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsValidAppealTarget(c.in); got != c.want {
|
||||
t.Errorf("IsValidAppealTarget(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
139
pkg/litigationplanner/proceeding_mapping.go
Normal file
139
pkg/litigationplanner/proceeding_mapping.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package litigationplanner
|
||||
|
||||
// proceeding_mapping bridges the two proceeding-type vocabularies in the
|
||||
// codebase: the **litigation** conceptual category (INF / REV / APP /
|
||||
// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding
|
||||
// + Pipeline-A rules, and the **fristenrechner** code category
|
||||
// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator
|
||||
// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects
|
||||
// bind to fristenrechner codes directly, but the litigation→fristenrechner
|
||||
// mapping is still needed for the ~40 Pipeline-A rules that remain on
|
||||
// litigation proceedings and for any other surface that thinks in
|
||||
// litigation terms.
|
||||
//
|
||||
// The mapping table here is the single source of truth — see
|
||||
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
|
||||
// design rationale + ambiguity notes, and
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md for the
|
||||
// lowercase dot-separated naming convention applied by mig 096
|
||||
// (t-paliad-206). **Never silent FK promotion**: every ambiguous case
|
||||
// returns ok=false so callers can degrade gracefully ("no narrowing")
|
||||
// instead of guessing.
|
||||
|
||||
// Stable code constants — the strings landed by mig 096. Use these
|
||||
// throughout the codebase so a future rename only needs to touch this
|
||||
// file. The id-anchored FKs (deadline_rules.proceeding_type_id,
|
||||
// projects.proceeding_type_id) are unaffected by the rename.
|
||||
const (
|
||||
CodeUPCInfringement = "upc.inf.cfi"
|
||||
CodeUPCRevocation = "upc.rev.cfi"
|
||||
CodeUPCCounterclaim = "upc.ccr.cfi"
|
||||
CodeUPCPreliminary = "upc.pi.cfi"
|
||||
CodeUPCDamages = "upc.dmgs.cfi"
|
||||
CodeUPCDiscovery = "upc.disc.cfi"
|
||||
CodeUPCAppealMerits = "upc.apl.merits"
|
||||
CodeUPCAppealOrder = "upc.apl.order"
|
||||
CodeUPCAppealCost = "upc.apl.cost"
|
||||
CodeDEInfringementLG = "de.inf.lg"
|
||||
CodeDEInfringementOLG = "de.inf.olg"
|
||||
CodeDEInfringementBGH = "de.inf.bgh"
|
||||
CodeDENullityBPatG = "de.null.bpatg"
|
||||
CodeDENullityBGH = "de.null.bgh"
|
||||
CodeEPAGrant = "epa.grant.exa"
|
||||
CodeEPAOpposition = "epa.opp.opd"
|
||||
CodeEPAOppositionAppeal = "epa.opp.boa"
|
||||
CodeDPMAOpposition = "dpma.opp.dpma"
|
||||
CodeDPMAAppealBPatG = "dpma.appeal.bpatg"
|
||||
CodeDPMAAppealBGH = "dpma.appeal.bgh"
|
||||
)
|
||||
|
||||
// MapLitigationToFristenrechner returns the fristenrechner code +
|
||||
// condition flags implied by a (litigationCode, jurisdiction) pair.
|
||||
//
|
||||
// Inputs are case-sensitive — pass the canonical upper-snake form
|
||||
// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous
|
||||
// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero
|
||||
// fristenrechner code; callers should treat that as "no narrowing"
|
||||
// and leave the cascade wide-open rather than auto-pick.
|
||||
//
|
||||
// Condition flags are returned as a slice so callers can apply them
|
||||
// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr,
|
||||
// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag
|
||||
// context applies.
|
||||
func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) {
|
||||
switch litigationCode {
|
||||
case "INF":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, nil, true
|
||||
case "DE":
|
||||
return CodeDEInfringementLG, nil, true
|
||||
}
|
||||
case "REV":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCRevocation, nil, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "CCR":
|
||||
// Counterclaim revocation — UPC fold-in is structural (the
|
||||
// counterclaim lives inside an upc.inf.cfi proceeding with the
|
||||
// with_ccr flag). DE Nichtigkeit is conceptually the same
|
||||
// adversarial-validity test, no separate flag.
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, []string{"with_ccr"}, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "AMD":
|
||||
// Amendment-application bundled into upc.inf.cfi via with_amend.
|
||||
// No DE / EPA / DPMA analogue today.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCInfringement, []string{"with_amend"}, true
|
||||
}
|
||||
case "APP":
|
||||
// Appeal is ambiguous in DE (OLG vs BGH) and the project
|
||||
// model doesn't carry the instance hint we'd need to
|
||||
// disambiguate. UPC is unambiguous — upc.apl.merits covers
|
||||
// the merits appeal track for inf/rev/ccr/damages.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCAppealMerits, nil, true
|
||||
}
|
||||
case "APM":
|
||||
// Preliminary injunction / urgency procedure — UPC-only
|
||||
// concept in the fristenrechner taxonomy.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCPreliminary, nil, true
|
||||
}
|
||||
case "OPP":
|
||||
// Opposition — primarily EPA. DPMA has dpma.opp.dpma but it
|
||||
// doesn't surface from the litigation vocabulary today.
|
||||
if jurisdiction == "EPA" {
|
||||
return CodeEPAOpposition, nil, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// ResolveCounterclaimRouting handles the determinator's
|
||||
// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown
|
||||
// for taxonomic completeness, but no rules are attached to it. When the
|
||||
// cascade resolves to upc.ccr.cfi we route the rule lookup back to
|
||||
// upc.inf.cfi with a default with_ccr=true flag — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1.
|
||||
//
|
||||
// `code` is the proceeding code the cascade resolved to. If it's
|
||||
// upc.ccr.cfi, the function returns (CodeUPCInfringement,
|
||||
// []string{"with_ccr"}, true). For any other code the function returns
|
||||
// (code, nil, false) and callers proceed with the code unchanged. The
|
||||
// boolean signals "routing was applied"; the caller can surface the hint
|
||||
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
|
||||
// weiter." in the UI.
|
||||
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
|
||||
if route, ok := SubTrackRoutings[code]; ok {
|
||||
return route.ParentCode, route.DefaultFlags, true
|
||||
}
|
||||
return code, nil, false
|
||||
}
|
||||
215
pkg/litigationplanner/scenarios.go
Normal file
215
pkg/litigationplanner/scenarios.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Slice D scenarios — m/paliad#124 §5 (revised), mig 145.
|
||||
//
|
||||
// A Scenario is a named composition of existing proceedings + flags +
|
||||
// per-card choices + anchor dates. v1 ships with one primary proceeding
|
||||
// per scenario; the spec.proceedings[] array is architected to absorb
|
||||
// multi-peer compose (v2) without a schema migration.
|
||||
//
|
||||
// "users should not add their own rules" (m, t-paliad-301) — the spec
|
||||
// references existing rules by submission_code; it never creates new
|
||||
// ones. ValidateSpec checks every code/submission resolves against the
|
||||
// current catalog before a save is accepted.
|
||||
|
||||
// Scenario is one row of paliad.scenarios. Wire shape doubles as the
|
||||
// API request/response payload for /api/scenarios.
|
||||
type Scenario struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
// Spec carries the jsonb composition. Stored raw so we can ship
|
||||
// shape evolutions without schema churn; ParseSpec gives the
|
||||
// structured view.
|
||||
Spec NullableJSON `db:"spec" json:"spec"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// ScenarioSpec is the parsed view of Scenario.Spec. v1 = version 1.
|
||||
// Future shape changes bump the version; ParseSpec rejects unknown
|
||||
// versions so an old client doesn't silently misread a future-shape
|
||||
// scenario.
|
||||
type ScenarioSpec struct {
|
||||
Version int `json:"version"`
|
||||
BaseTriggerDate string `json:"base_trigger_date"`
|
||||
Proceedings []ScenarioProceeding `json:"proceedings"`
|
||||
}
|
||||
|
||||
// ScenarioProceeding is one entry under spec.proceedings[]. v1 honours
|
||||
// exactly one with role="primary" (additional entries with role="peer"
|
||||
// are reserved for v2 multi-proceeding compose and silently ignored
|
||||
// by the engine today).
|
||||
type ScenarioProceeding struct {
|
||||
Code string `json:"code"`
|
||||
Role string `json:"role"` // "primary" | "peer" (v2)
|
||||
TriggerDateOverride string `json:"trigger_date_override,omitempty"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
PerCardChoices map[string]ScenarioCardChoice `json:"per_card_choices,omitempty"`
|
||||
AnchorOverrides map[string]string `json:"anchor_overrides,omitempty"`
|
||||
SkipRules []string `json:"skip_rules,omitempty"`
|
||||
AppealTarget string `json:"appeal_target,omitempty"`
|
||||
}
|
||||
|
||||
// ScenarioCardChoice is one entry under
|
||||
// spec.proceedings[*].per_card_choices. Mirrors the t-paliad-265 choice
|
||||
// kinds; not every kind is populated on every card.
|
||||
type ScenarioCardChoice struct {
|
||||
Appellant string `json:"appellant,omitempty"`
|
||||
IncludeCCR *bool `json:"include_ccr,omitempty"`
|
||||
Skip *bool `json:"skip,omitempty"`
|
||||
}
|
||||
|
||||
// Spec version constant.
|
||||
const ScenarioSpecVersion = 1
|
||||
|
||||
// Sentinel errors for scenarios.
|
||||
var (
|
||||
ErrUnknownScenario = errors.New("unknown scenario")
|
||||
ErrInvalidScenario = errors.New("invalid scenario spec")
|
||||
ErrScenarioNoPrimary = errors.New("scenario spec has no proceeding with role='primary'")
|
||||
)
|
||||
|
||||
// ScenarioRole* are the canonical role slugs for ScenarioProceeding.Role.
|
||||
const (
|
||||
ScenarioRolePrimary = "primary"
|
||||
ScenarioRolePeer = "peer"
|
||||
)
|
||||
|
||||
// ParseSpec decodes Scenario.Spec into a structured ScenarioSpec. Used
|
||||
// by the engine adapter + the rule-editor preview. Surfaces a friendly
|
||||
// error wrapping ErrInvalidScenario on malformed JSON / unknown version
|
||||
// so the handler can map to a 400.
|
||||
func ParseSpec(raw NullableJSON) (*ScenarioSpec, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("%w: spec is empty", ErrInvalidScenario)
|
||||
}
|
||||
var s ScenarioSpec
|
||||
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode spec: %v", ErrInvalidScenario, err)
|
||||
}
|
||||
if s.Version != ScenarioSpecVersion {
|
||||
return nil, fmt.Errorf("%w: spec.version=%d, want %d",
|
||||
ErrInvalidScenario, s.Version, ScenarioSpecVersion)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// PrimaryProceeding returns the entry from spec.proceedings[] with
|
||||
// role="primary". Returns ErrScenarioNoPrimary if absent — every spec
|
||||
// must carry exactly one primary entry. (Multiple primaries are also
|
||||
// rejected: the engine consumes one.)
|
||||
func (s *ScenarioSpec) PrimaryProceeding() (*ScenarioProceeding, error) {
|
||||
var primary *ScenarioProceeding
|
||||
for i := range s.Proceedings {
|
||||
if s.Proceedings[i].Role == ScenarioRolePrimary {
|
||||
if primary != nil {
|
||||
return nil, fmt.Errorf("%w: multiple proceedings with role='primary'", ErrInvalidScenario)
|
||||
}
|
||||
primary = &s.Proceedings[i]
|
||||
}
|
||||
}
|
||||
if primary == nil {
|
||||
return nil, ErrScenarioNoPrimary
|
||||
}
|
||||
return primary, nil
|
||||
}
|
||||
|
||||
// CalcOptionsFromSpec builds a CalcOptions from the scenario's primary
|
||||
// entry. The caller still needs the proceeding code + the trigger date,
|
||||
// both returned alongside.
|
||||
//
|
||||
// v1: only the primary entry is honoured. v2 will iterate over peer
|
||||
// entries; the multi-peer merge lives in the paliad-side
|
||||
// ProjectionService (one Calculate call per entry, merged + sorted by
|
||||
// date).
|
||||
func (s *ScenarioSpec) CalcOptionsFromSpec() (proceedingCode, triggerDate string, opts CalcOptions, err error) {
|
||||
primary, err := s.PrimaryProceeding()
|
||||
if err != nil {
|
||||
return "", "", CalcOptions{}, err
|
||||
}
|
||||
td := s.BaseTriggerDate
|
||||
if primary.TriggerDateOverride != "" {
|
||||
td = primary.TriggerDateOverride
|
||||
}
|
||||
if td == "" {
|
||||
return "", "", CalcOptions{}, fmt.Errorf("%w: no base_trigger_date and no per-proceeding override", ErrInvalidScenario)
|
||||
}
|
||||
|
||||
perCardAppellant := make(map[string]string, len(primary.PerCardChoices))
|
||||
skipRules := make(map[string]struct{}, len(primary.SkipRules))
|
||||
includeCCRFor := make(map[string]struct{}, len(primary.PerCardChoices))
|
||||
for code, choice := range primary.PerCardChoices {
|
||||
if choice.Appellant != "" {
|
||||
perCardAppellant[code] = choice.Appellant
|
||||
}
|
||||
if choice.IncludeCCR != nil && *choice.IncludeCCR {
|
||||
includeCCRFor[code] = struct{}{}
|
||||
}
|
||||
if choice.Skip != nil && *choice.Skip {
|
||||
skipRules[code] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, code := range primary.SkipRules {
|
||||
skipRules[code] = struct{}{}
|
||||
}
|
||||
|
||||
return primary.Code, td, CalcOptions{
|
||||
Flags: primary.Flags,
|
||||
AnchorOverrides: primary.AnchorOverrides,
|
||||
AppealTarget: primary.AppealTarget,
|
||||
PerCardAppellant: perCardAppellant,
|
||||
SkipRules: skipRules,
|
||||
IncludeCCRFor: includeCCRFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ScenarioFilter narrows Catalog.LoadScenarios. All fields optional:
|
||||
//
|
||||
// - ProjectID non-nil: only scenarios attached to that project
|
||||
// (project_id = filter.ProjectID).
|
||||
// - AbstractForUser non-nil: only abstract scenarios (project_id IS
|
||||
// NULL) created by that user.
|
||||
// - Both nil: list every scenario the caller can see (RLS-gated).
|
||||
type ScenarioFilter struct {
|
||||
ProjectID *uuid.UUID
|
||||
AbstractForUser *uuid.UUID
|
||||
}
|
||||
|
||||
// CalculateFromScenario is the high-level engine entry for scenario-
|
||||
// driven rendering. Unpacks the spec, builds CalcOptions, and delegates
|
||||
// to Calculate.
|
||||
//
|
||||
// v1: surfaces only the primary proceeding's timeline. v2 multi-peer
|
||||
// expansion lives on the paliad-side ProjectionService (per-entry
|
||||
// Calculate + client-side merge); the package doesn't own that
|
||||
// orchestration.
|
||||
func CalculateFromScenario(
|
||||
ctx context.Context,
|
||||
scenario *Scenario,
|
||||
catalog Catalog,
|
||||
holidays HolidayCalendar,
|
||||
courts CourtRegistry,
|
||||
) (*Timeline, error) {
|
||||
spec, err := ParseSpec(scenario.Spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, triggerDate, opts, err := spec.CalcOptionsFromSpec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Calculate(ctx, code, triggerDate, opts, catalog, holidays, courts)
|
||||
}
|
||||
207
pkg/litigationplanner/scenarios_test.go
Normal file
207
pkg/litigationplanner/scenarios_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseSpec_Roundtrip pins the spec-decoder contract: well-formed
|
||||
// jsonb with version=1 parses; unknown versions and malformed JSON
|
||||
// surface ErrInvalidScenario.
|
||||
func TestParseSpec_Roundtrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
spec string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"v1 primary-only",
|
||||
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[{"code":"upc.inf.cfi","role":"primary"}]}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"v1 with full primary entry",
|
||||
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[
|
||||
{"code":"upc.inf.cfi","role":"primary","flags":["with_ccr"],
|
||||
"anchor_overrides":{"inf.reply":"2026-08-15"},
|
||||
"skip_rules":["inf.r30_amend"]}
|
||||
]}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"v2 spec rejected — unknown version",
|
||||
`{"version":2,"proceedings":[]}`,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty spec",
|
||||
``,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"malformed json",
|
||||
`{"version":1,"proceedings":[}`,
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := ParseSpec(NullableJSON(c.spec))
|
||||
if c.wantErr && err == nil {
|
||||
t.Errorf("ParseSpec(%s): want error, got nil", c.spec)
|
||||
}
|
||||
if !c.wantErr && err != nil {
|
||||
t.Errorf("ParseSpec(%s): unexpected error %v", c.spec, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioSpec_PrimaryProceeding pins the "exactly one primary"
|
||||
// invariant: zero → ErrScenarioNoPrimary; multiple → ErrInvalidScenario.
|
||||
func TestScenarioSpec_PrimaryProceeding(t *testing.T) {
|
||||
t.Run("zero primary → ErrScenarioNoPrimary", func(t *testing.T) {
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
Proceedings: []ScenarioProceeding{
|
||||
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
|
||||
},
|
||||
}
|
||||
_, err := s.PrimaryProceeding()
|
||||
if err != ErrScenarioNoPrimary {
|
||||
t.Errorf("want ErrScenarioNoPrimary, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two primaries rejected", func(t *testing.T) {
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
Proceedings: []ScenarioProceeding{
|
||||
{Code: "upc.inf.cfi", Role: ScenarioRolePrimary},
|
||||
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary},
|
||||
},
|
||||
}
|
||||
_, err := s.PrimaryProceeding()
|
||||
if err == nil || !strings.Contains(err.Error(), "multiple proceedings with role='primary'") {
|
||||
t.Errorf("want multi-primary error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single primary picked", func(t *testing.T) {
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
Proceedings: []ScenarioProceeding{
|
||||
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
|
||||
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary, Flags: []string{"with_amend"}},
|
||||
},
|
||||
}
|
||||
p, err := s.PrimaryProceeding()
|
||||
if err != nil {
|
||||
t.Fatalf("PrimaryProceeding: %v", err)
|
||||
}
|
||||
if p.Code != "upc.rev.cfi" {
|
||||
t.Errorf("primary code = %q, want upc.rev.cfi", p.Code)
|
||||
}
|
||||
if len(p.Flags) != 1 || p.Flags[0] != "with_amend" {
|
||||
t.Errorf("primary.Flags = %v, want [with_amend]", p.Flags)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestScenarioSpec_CalcOptionsFromSpec covers the unpack from spec
|
||||
// jsonb into the CalcOptions the engine consumes. Pins:
|
||||
// - base_trigger_date used when no per-proceeding override
|
||||
// - trigger_date_override wins when set
|
||||
// - flags + anchor_overrides + appeal_target passed through verbatim
|
||||
// - per_card_choices unpacked into PerCardAppellant / SkipRules /
|
||||
// IncludeCCRFor maps
|
||||
func TestScenarioSpec_CalcOptionsFromSpec(t *testing.T) {
|
||||
includeTrue := true
|
||||
skipTrue := true
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
BaseTriggerDate: "2026-05-26",
|
||||
Proceedings: []ScenarioProceeding{{
|
||||
Code: "upc.inf.cfi",
|
||||
Role: ScenarioRolePrimary,
|
||||
Flags: []string{"with_ccr"},
|
||||
AnchorOverrides: map[string]string{"inf.reply": "2026-08-15"},
|
||||
AppealTarget: "endentscheidung",
|
||||
SkipRules: []string{"explicit_skip_code"},
|
||||
PerCardChoices: map[string]ScenarioCardChoice{
|
||||
"inf.r30_amend": {Appellant: "claimant"},
|
||||
"inf.rejoin": {IncludeCCR: &includeTrue},
|
||||
"inf.amend_other": {Skip: &skipTrue},
|
||||
},
|
||||
}},
|
||||
}
|
||||
code, td, opts, err := s.CalcOptionsFromSpec()
|
||||
if err != nil {
|
||||
t.Fatalf("CalcOptionsFromSpec: %v", err)
|
||||
}
|
||||
if code != "upc.inf.cfi" {
|
||||
t.Errorf("code = %q, want upc.inf.cfi", code)
|
||||
}
|
||||
if td != "2026-05-26" {
|
||||
t.Errorf("triggerDate = %q, want 2026-05-26", td)
|
||||
}
|
||||
if len(opts.Flags) != 1 || opts.Flags[0] != "with_ccr" {
|
||||
t.Errorf("opts.Flags = %v, want [with_ccr]", opts.Flags)
|
||||
}
|
||||
if opts.AppealTarget != "endentscheidung" {
|
||||
t.Errorf("opts.AppealTarget = %q, want endentscheidung", opts.AppealTarget)
|
||||
}
|
||||
if got := opts.AnchorOverrides["inf.reply"]; got != "2026-08-15" {
|
||||
t.Errorf("opts.AnchorOverrides[inf.reply] = %q, want 2026-08-15", got)
|
||||
}
|
||||
if got := opts.PerCardAppellant["inf.r30_amend"]; got != "claimant" {
|
||||
t.Errorf("opts.PerCardAppellant[inf.r30_amend] = %q, want claimant", got)
|
||||
}
|
||||
if _, ok := opts.IncludeCCRFor["inf.rejoin"]; !ok {
|
||||
t.Error("opts.IncludeCCRFor missing inf.rejoin")
|
||||
}
|
||||
if _, ok := opts.SkipRules["inf.amend_other"]; !ok {
|
||||
t.Error("opts.SkipRules missing inf.amend_other (from per_card_choices.skip)")
|
||||
}
|
||||
if _, ok := opts.SkipRules["explicit_skip_code"]; !ok {
|
||||
t.Error("opts.SkipRules missing explicit_skip_code (from skip_rules[])")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioSpec_TriggerDateOverride pins the per-proceeding override
|
||||
// path (v2-ready — primary entry honours trigger_date_override too).
|
||||
func TestScenarioSpec_TriggerDateOverride(t *testing.T) {
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
BaseTriggerDate: "2026-05-26",
|
||||
Proceedings: []ScenarioProceeding{{
|
||||
Code: "upc.inf.cfi",
|
||||
Role: ScenarioRolePrimary,
|
||||
TriggerDateOverride: "2026-12-01",
|
||||
}},
|
||||
}
|
||||
_, td, _, err := s.CalcOptionsFromSpec()
|
||||
if err != nil {
|
||||
t.Fatalf("CalcOptionsFromSpec: %v", err)
|
||||
}
|
||||
if td != "2026-12-01" {
|
||||
t.Errorf("triggerDate = %q, want override 2026-12-01", td)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioSpec_NoBaseTrigger pins the safety check that a spec
|
||||
// without base_trigger_date AND without per-proceeding override
|
||||
// surfaces ErrInvalidScenario (the engine can't render without a date).
|
||||
func TestScenarioSpec_NoBaseTrigger(t *testing.T) {
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
Proceedings: []ScenarioProceeding{{
|
||||
Code: "upc.inf.cfi",
|
||||
Role: ScenarioRolePrimary,
|
||||
}},
|
||||
}
|
||||
_, _, _, err := s.CalcOptionsFromSpec()
|
||||
if err == nil {
|
||||
t.Fatal("want ErrInvalidScenario, got nil")
|
||||
}
|
||||
}
|
||||
151
pkg/litigationplanner/sort.go
Normal file
151
pkg/litigationplanner/sort.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SortDeadlinesByDurationWithinTriggerGroup is the public form of
|
||||
// sortDeadlinesByDurationWithinTriggerGroup. Exported so paliad's
|
||||
// test suite (which historically reached the helper directly) can
|
||||
// keep invoking it via a tiny wrapper.
|
||||
func SortDeadlinesByDurationWithinTriggerGroup(
|
||||
deadlines []TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) {
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
}
|
||||
|
||||
// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of
|
||||
// deadlines whose underlying rule shares the same trigger group
|
||||
// (parent_id + trigger_event_id) and reorders each run in place by
|
||||
// duration ascending. Different trigger groups keep their original
|
||||
// proceeding-sequence position — the walk only ever permutes adjacent
|
||||
// same-group rows.
|
||||
//
|
||||
// Sort key (within a run):
|
||||
// 1. Conditional / court-set rows (no concrete date in the duration
|
||||
// ladder) sort LAST, tiebroken by submission_code.
|
||||
// 2. duration_unit weight ASC: days/working_days < weeks < months < years
|
||||
// 3. duration_value ASC
|
||||
// 4. submission_code ASC (deterministic tiebreak)
|
||||
//
|
||||
// Issue: m/paliad#128 — post-decision optional events (R.151/R.353
|
||||
// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog
|
||||
// order instead of likely-sequence order. (t-paliad-296)
|
||||
func sortDeadlinesByDurationWithinTriggerGroup(
|
||||
deadlines []TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) {
|
||||
if len(deadlines) < 2 {
|
||||
return
|
||||
}
|
||||
n := len(deadlines)
|
||||
i := 0
|
||||
for i < n {
|
||||
gid := triggerGroupKey(deadlines[i], ruleByID)
|
||||
j := i + 1
|
||||
for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid {
|
||||
j++
|
||||
}
|
||||
// Root rules (no parent and no trigger_event) get gid="" and
|
||||
// would otherwise collapse into one big run. Skip the sort for
|
||||
// the "root" pseudo-group — each root rule represents its own
|
||||
// anchor (SoC, oral hearing, decision …) and the proceeding-
|
||||
// sequence order between them must be preserved.
|
||||
if j-i > 1 && gid != "" {
|
||||
chunk := deadlines[i:j]
|
||||
sort.SliceStable(chunk, func(a, b int) bool {
|
||||
return durationLessForSort(chunk[a], chunk[b], ruleByID)
|
||||
})
|
||||
}
|
||||
i = j
|
||||
}
|
||||
}
|
||||
|
||||
// triggerGroupKey returns a string key identifying which trigger group
|
||||
// a deadline belongs to. Same key = same group = candidates for sort.
|
||||
// Empty string means "root" (no parent, no trigger_event) — used as a
|
||||
// sentinel by the caller to skip sorting roots against each other.
|
||||
func triggerGroupKey(d TimelineEntry, ruleByID map[uuid.UUID]Rule) string {
|
||||
rid, err := uuid.Parse(d.RuleID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
r, ok := ruleByID[rid]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if r.ParentID != nil {
|
||||
return "p:" + r.ParentID.String()
|
||||
}
|
||||
if r.TriggerEventID != nil {
|
||||
return fmt.Sprintf("t:%d", *r.TriggerEventID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// durationLessForSort compares two deadlines for the duration-ascending
|
||||
// sort. Court-set / conditional rows (no concrete date) sort LAST
|
||||
// regardless of duration — they don't fit the duration ladder.
|
||||
func durationLessForSort(
|
||||
a, b TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) bool {
|
||||
aLast := a.IsCourtSet || a.IsConditional
|
||||
bLast := b.IsCourtSet || b.IsConditional
|
||||
if aLast != bLast {
|
||||
return !aLast
|
||||
}
|
||||
if aLast && bLast {
|
||||
return a.Code < b.Code
|
||||
}
|
||||
|
||||
ra := lookupRuleFromDeadline(a, ruleByID)
|
||||
rb := lookupRuleFromDeadline(b, ruleByID)
|
||||
|
||||
wa := durationUnitWeight(ra.DurationUnit)
|
||||
wb := durationUnitWeight(rb.DurationUnit)
|
||||
if wa != wb {
|
||||
return wa < wb
|
||||
}
|
||||
if ra.DurationValue != rb.DurationValue {
|
||||
return ra.DurationValue < rb.DurationValue
|
||||
}
|
||||
return a.Code < b.Code
|
||||
}
|
||||
|
||||
func lookupRuleFromDeadline(
|
||||
d TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) Rule {
|
||||
if d.RuleID == "" {
|
||||
return Rule{}
|
||||
}
|
||||
rid, err := uuid.Parse(d.RuleID)
|
||||
if err != nil {
|
||||
return Rule{}
|
||||
}
|
||||
return ruleByID[rid]
|
||||
}
|
||||
|
||||
// durationUnitWeight maps a duration unit to its sort weight so the
|
||||
// trigger-group sort can order shorter durations first. days and
|
||||
// working_days share weight 0 (both are sub-week granularities);
|
||||
// unknown units sort to the end so they're visible as a tail rather
|
||||
// than silently winning.
|
||||
func durationUnitWeight(unit string) int {
|
||||
switch unit {
|
||||
case "days", "working_days":
|
||||
return 0
|
||||
case "weeks":
|
||||
return 1
|
||||
case "months":
|
||||
return 2
|
||||
case "years":
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
53
pkg/litigationplanner/subtrack.go
Normal file
53
pkg/litigationplanner/subtrack.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package litigationplanner
|
||||
|
||||
// SubTrackRouting describes a proceeding type that has no native rules
|
||||
// of its own and is normally rendered inside a parent proceeding's flow
|
||||
// with one or more condition flags enabled. The Procedure Roadmap
|
||||
// (verfahrensablauf) routes calc requests for these codes to the parent
|
||||
// proceeding + default flags, but preserves the user-picked code/name
|
||||
// in the response identity and surfaces a contextual note explaining
|
||||
// the framing — see m/paliad#58 and the design doc cited above.
|
||||
//
|
||||
// Adding a new sub-track is a data-only change here: extend
|
||||
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
|
||||
// renderer picks it up automatically. The note copy lives in this file
|
||||
// because it's semantic to the routing, not UI chrome.
|
||||
type SubTrackRouting struct {
|
||||
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
|
||||
Code string
|
||||
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
|
||||
ParentCode string
|
||||
// DefaultFlags are merged into the user's flag set so the
|
||||
// gated rules render. Order is preserved.
|
||||
DefaultFlags []string
|
||||
// NoteDE / NoteEN are the contextual banner above the timeline,
|
||||
// explaining that the proceeding type is normally a sub-track.
|
||||
// Plain text — the frontend renders them as a banner.
|
||||
NoteDE string
|
||||
NoteEN string
|
||||
}
|
||||
|
||||
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
|
||||
// The pattern generalises to other "sub-track" proceeding types (e.g.
|
||||
// R.30 application to amend the patent as a standalone roadmap, R.46
|
||||
// preliminary objection) once they have a proceeding-type code of their
|
||||
// own. New entries here are picked up by the spawn-as-standalone
|
||||
// renderer in Calculate without further wiring.
|
||||
var SubTrackRoutings = map[string]SubTrackRouting{
|
||||
CodeUPCCounterclaim: {
|
||||
Code: CodeUPCCounterclaim,
|
||||
ParentCode: CodeUPCInfringement,
|
||||
DefaultFlags: []string{"with_ccr"},
|
||||
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
|
||||
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
|
||||
},
|
||||
}
|
||||
|
||||
// LookupSubTrackRouting returns the sub-track routing for a proceeding
|
||||
// code, or (zero, false) if the code is not a sub-track. Used by the
|
||||
// fristenrechner Calculate path to spawn the parent flow with the sub-
|
||||
// track's default flags.
|
||||
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
|
||||
r, ok := SubTrackRoutings[code]
|
||||
return r, ok
|
||||
}
|
||||
683
pkg/litigationplanner/types.go
Normal file
683
pkg/litigationplanner/types.go
Normal file
@@ -0,0 +1,683 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
||||
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
|
||||
// from Postgres breaks the row scan with "unsupported Scan, storing
|
||||
// driver.Value type <nil> into type *json.RawMessage" — exactly the
|
||||
// error that hid every approval_request from the inbox when m's first
|
||||
// "create" lifecycle row arrived with NULL pre_image (m's dogfood
|
||||
// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column
|
||||
// fixes the scan and preserves inline JSON output (no base64 cast).
|
||||
type NullableJSON []byte
|
||||
|
||||
// Scan implements sql.Scanner.
|
||||
func (n *NullableJSON) Scan(value any) error {
|
||||
if value == nil {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
*n = append((*n)[:0], v...)
|
||||
return nil
|
||||
case string:
|
||||
*n = []byte(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("NullableJSON: unsupported scan type %T", value)
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer.
|
||||
func (n NullableJSON) Value() (driver.Value, error) {
|
||||
if len(n) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
// MarshalJSON emits the raw JSON bytes (or "null").
|
||||
func (n NullableJSON) MarshalJSON() ([]byte, error) {
|
||||
if len(n) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON consumes raw JSON bytes (literal "null" maps to nil).
|
||||
func (n *NullableJSON) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
*n = append((*n)[:0], data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rule is one rule in the proceeding-rule tree (UPC R.023, etc.).
|
||||
//
|
||||
// JSON + db tags are intentionally identical to the historical
|
||||
// paliad.deadline_rules row shape — sqlx scans onto Rule directly and
|
||||
// the wire bytes the frontend reads are unchanged from the pre-extract
|
||||
// shape.
|
||||
type Rule struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
||||
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
DurationValue int `db:"duration_value" json:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
||||
Timing *string `db:"timing" json:"timing,omitempty"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||
DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"`
|
||||
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
|
||||
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
|
||||
// this rule's concept (joined via paliad.deadline_concept_event_types
|
||||
// where is_default = true). Lets the deadline create form auto-populate
|
||||
// the Typ chip when the user picks this rule. Hydrated by the service
|
||||
// layer; not a column. NULL when the concept has no mapped event_type.
|
||||
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
|
||||
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
||||
// proceeding-rooted rules.
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
|
||||
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
||||
// when is_spawn=true and this is non-NULL, the calculator follows
|
||||
// the FK and emits the target proceeding's root rule chain.
|
||||
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
||||
|
||||
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
||||
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
||||
// NULL = single-anchor arithmetic.
|
||||
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
||||
|
||||
// ConditionExpr is the jsonb gating expression. Grammar:
|
||||
// {"flag": "<name>"}
|
||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||
// {"op":"not", "args":[<node>]}
|
||||
// NULL or {} = unconditional.
|
||||
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
||||
|
||||
// Priority is the 4-way unified enum: 'mandatory' (default),
|
||||
// 'recommended', 'optional', 'informational'.
|
||||
Priority string `db:"priority" json:"priority"`
|
||||
|
||||
// IsCourtSet replaces the runtime heuristic (primary_party='court'
|
||||
// OR event_type IN ('hearing','decision','order')).
|
||||
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
||||
|
||||
// LifecycleState drives the rule-editor flow:
|
||||
// 'draft' | 'published' | 'archived'.
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
|
||||
// DraftOf points at the published rule this draft will replace on
|
||||
// publish. NULL on published / archived rows.
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
|
||||
// PublishedAt records when the row entered LifecycleState='published'.
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
|
||||
// ChoicesOffered declares which per-event-card choice-kinds this
|
||||
// rule offers on the Verfahrensablauf timeline (mig 129,
|
||||
// t-paliad-265). NULL = no caret affordance (default).
|
||||
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
|
||||
|
||||
// AppliesToTarget is the per-rule applies-to set for the unified
|
||||
// UPC Berufung proceeding type (Slice B1, mig 134, m/paliad#124
|
||||
// §18.1). Each element ∈ AppealTargets. NULL on rules outside
|
||||
// the appeal proceeding. The engine filters by this when
|
||||
// CalcOptions.AppealTarget is set.
|
||||
AppliesToTarget pq.StringArray `db:"applies_to_target" json:"appliesToTarget,omitempty"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of the litigation conceptual codes (INF/REV/CCR
|
||||
// /APM/APP/AMD/ZPO_CIVIL — matter management) or the lowercase dot-
|
||||
// separated fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) —
|
||||
// see docs/design-proceeding-code-taxonomy-2026-05-18.md.
|
||||
type ProceedingType struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
||||
Category *string `db:"category" json:"category,omitempty"`
|
||||
DefaultColor string `db:"default_color" json:"default_color"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
|
||||
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
|
||||
// that fires when no rule has IsRootEvent=true.
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||
|
||||
// AppealTarget is the top-level appeal-target marker (Slice B1, mig
|
||||
// 134). NULL on non-appeal proceedings. Reserved for future variants
|
||||
// — today the unified upc.apl row has this NULL (per-rule targets
|
||||
// live on Rule.AppliesToTarget).
|
||||
AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`
|
||||
|
||||
// Role label overrides (t-paliad-301 / m/paliad#132, mig 137).
|
||||
// NULL = renderer falls back to the language-default labels
|
||||
// ("Klägerseite" / "Beklagtenseite" / "Claimant side" / "Defendant side").
|
||||
// Set on proceedings where the role-naming diverges from the
|
||||
// claimant/defendant default (Appeal → Berufungskläger /
|
||||
// Berufungsbeklagter; Revocation → Antragsteller /
|
||||
// Antragsgegner Nichtigkeit; EPA Opposition → Einsprechende(r) /
|
||||
// Patentinhaber(in)).
|
||||
RoleProactiveLabelDE *string `db:"role_proactive_label_de" json:"role_proactive_label_de,omitempty"`
|
||||
RoleProactiveLabelEN *string `db:"role_proactive_label_en" json:"role_proactive_label_en,omitempty"`
|
||||
RoleReactiveLabelDE *string `db:"role_reactive_label_de" json:"role_reactive_label_de,omitempty"`
|
||||
RoleReactiveLabelEN *string `db:"role_reactive_label_en" json:"role_reactive_label_en,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerEventLabelForAppealTarget returns the per-target
|
||||
// "Auslösendes Ereignis" label for the unified UPC Berufung
|
||||
// proceeding (t-paliad-301 / m/paliad#132 Bug B). The trigger event
|
||||
// for an appeal is the underlying decision, not the appeal
|
||||
// proceeding itself — these labels override the proceeding's own
|
||||
// trigger_event_label when appeal_target is set.
|
||||
//
|
||||
// lang ∈ {"de", "en"}; any other value falls through to "de" so the
|
||||
// caller never gets an empty string.
|
||||
//
|
||||
// Returns empty when target is empty / unknown (caller must fall
|
||||
// back to the proceeding's own trigger_event_label).
|
||||
func TriggerEventLabelForAppealTarget(target, lang string) string {
|
||||
if lang != "en" {
|
||||
lang = "de"
|
||||
}
|
||||
switch target {
|
||||
case AppealTargetEndentscheidung:
|
||||
if lang == "en" {
|
||||
return "Final decision (R.118)"
|
||||
}
|
||||
return "Endentscheidung (R.118)"
|
||||
case AppealTargetKostenentscheidung:
|
||||
if lang == "en" {
|
||||
return "Cost decision"
|
||||
}
|
||||
return "Kostenentscheidung"
|
||||
case AppealTargetAnordnung:
|
||||
if lang == "en" {
|
||||
return "Order"
|
||||
}
|
||||
return "Anordnung"
|
||||
case AppealTargetSchadensbemessung:
|
||||
if lang == "en" {
|
||||
return "Damages-assessment decision"
|
||||
}
|
||||
return "Entscheidung im Schadensbemessungsverfahren"
|
||||
case AppealTargetBucheinsicht:
|
||||
if lang == "en" {
|
||||
return "Book-inspection order"
|
||||
}
|
||||
return "Anordnung der Bucheinsicht"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AdjustmentReason describes why a date was rolled forward / backward
|
||||
// off a non-working day. Populated by HolidayCalendar implementations
|
||||
// when AdjustForNonWorkingDaysWithReason moves the date.
|
||||
//
|
||||
// Date fields are JSON-serialised as YYYY-MM-DD strings (matching
|
||||
// TimelineEntry.DueDate / OriginalDate) so the frontend doesn't need a
|
||||
// separate RFC3339 parser.
|
||||
type AdjustmentReason struct {
|
||||
// Kind is the dominant cause; longest cause wins when several apply
|
||||
// (vacation > public_holiday > weekend).
|
||||
Kind string `json:"kind"`
|
||||
// Holidays collects every named holiday encountered while walking
|
||||
// past the non-working run, deduped by (date, name). May be empty
|
||||
// when the only cause is a weekend.
|
||||
Holidays []HolidayDTO `json:"holidays,omitempty"`
|
||||
// VacationName, VacationStart and VacationEnd describe the
|
||||
// contiguous vacation block the original date sits in. Populated
|
||||
// only when Kind == "vacation". Span boundaries are the first/last
|
||||
// vacation day in the block (excludes the weekends that pad it).
|
||||
VacationName string `json:"vacationName,omitempty"`
|
||||
VacationStart string `json:"vacationStart,omitempty"`
|
||||
VacationEnd string `json:"vacationEnd,omitempty"`
|
||||
// OriginalWeekday is the English weekday name of the original date —
|
||||
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
|
||||
// can localise it.
|
||||
OriginalWeekday string `json:"originalWeekday,omitempty"`
|
||||
}
|
||||
|
||||
// HolidayDTO is the JSON shape for a holiday emitted in
|
||||
// AdjustmentReason — distinct from a DB-level Holiday row so dates
|
||||
// serialise as YYYY-MM-DD strings.
|
||||
type HolidayDTO struct {
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
IsVacation bool `json:"isVacation,omitempty"`
|
||||
IsClosure bool `json:"isClosure,omitempty"`
|
||||
}
|
||||
|
||||
// CalcOptions carries optional inputs for Calculate. Callers can leave
|
||||
// fields empty/nil for the legacy behaviour.
|
||||
//
|
||||
// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with
|
||||
// anchor_alt='priority_date' (e.g. epa.grant.exa.ep_grant.publish
|
||||
// per Art. 93 EPÜ) use this date as their base instead of the
|
||||
// parent's adjusted date / the trigger date.
|
||||
// - Flags: lowercase string flags from the UI (e.g. "with_ccr",
|
||||
// "with_amend"). Drive condition_expr evaluation + flag-keyed
|
||||
// alt-swap.
|
||||
// - AnchorOverrides: rule_code → YYYY-MM-DD. Per-rule user overrides
|
||||
// of the computed deadline date. When a child rule chains off a
|
||||
// parent whose code is in AnchorOverrides, the override date is
|
||||
// used as the anchor instead of the parent's calculated date.
|
||||
// - CourtID picks the forum the proceeding is filed in (e.g.
|
||||
// "upc-ld-paris", "de-bgh"). The calculator resolves it to
|
||||
// (country, regime) for non-working-day computation.
|
||||
// - TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C
|
||||
// rules: when non-nil, the proceedingCode argument is ignored and
|
||||
// the engine selects rules WHERE trigger_event_id = *filter.
|
||||
// - RuleOverrides substitutes specific rules in the calculator's
|
||||
// rule list with caller-supplied in-memory rows. Used by the
|
||||
// rule-editor preview.
|
||||
// - PerCardAppellant / SkipRules / IncludeCCRFor / IncludeHidden
|
||||
// drive per-event-card choice overlays (t-paliad-265, t-paliad-290).
|
||||
// - ProjectHint scopes the catalog lookup to a project context
|
||||
// (paliad's catalog uses this to merge in project-scoped rules
|
||||
// in future slices; v1 catalogs may ignore it).
|
||||
type CalcOptions struct {
|
||||
PriorityDateStr string
|
||||
Flags []string
|
||||
AnchorOverrides map[string]string
|
||||
CourtID string
|
||||
TriggerEventIDFilter *int64
|
||||
RuleOverrides []Rule
|
||||
|
||||
PerCardAppellant map[string]string
|
||||
SkipRules map[string]struct{}
|
||||
IncludeCCRFor map[string]struct{}
|
||||
IncludeHidden bool
|
||||
|
||||
ProjectHint ProjectHint
|
||||
|
||||
// AppealTarget narrows the timeline to rules whose AppliesToTarget
|
||||
// contains the requested slug. Empty = no filter. Set to one of
|
||||
// AppealTargets for the unified UPC Berufung picker (Slice B1,
|
||||
// m/paliad#124 §18.1). Unknown slugs are silently dropped (no
|
||||
// filter applied) so a stale frontend chip doesn't break the
|
||||
// timeline render — see IsValidAppealTarget.
|
||||
AppealTarget string
|
||||
}
|
||||
|
||||
// ProjectHint scopes a Catalog call to a specific project. Paliad's
|
||||
// catalog uses ProjectID to merge in project-scoped rules in a future
|
||||
// slice (m/paliad#124 §6 — currently dropped per m's 2026-05-26
|
||||
// decision; the field stays for forward-compat). Other catalogs (the
|
||||
// embedded UPC snapshot used by youpc.org) ignore the hint.
|
||||
//
|
||||
// Zero value = no project context (the abstract Verfahrensablauf /
|
||||
// public Fristenrechner case).
|
||||
type ProjectHint struct {
|
||||
ProjectID uuid.UUID
|
||||
}
|
||||
|
||||
// CalcRuleParams identifies a single rule and the inputs needed to
|
||||
// compute one deadline from it. Caller supplies either RuleID OR the
|
||||
// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on
|
||||
// hand from the concept-card pill it just received a click on.
|
||||
type CalcRuleParams struct {
|
||||
RuleID string // optional — UUID
|
||||
ProceedingCode string // optional — used with RuleLocalCode
|
||||
RuleLocalCode string // optional — paliad.deadline_rules.submission_code
|
||||
TriggerDate string // required — YYYY-MM-DD
|
||||
Flags []string // optional — condition_flag inputs
|
||||
CourtID string // optional — selects holiday calendar
|
||||
}
|
||||
|
||||
// Timeline is the package's structured return for Calculate. JSON tags
|
||||
// are aligned with paliad's historical UIResponse so handlers can serve
|
||||
// it directly — the wire bytes the frontend reads are unchanged.
|
||||
type Timeline struct {
|
||||
ProceedingType string `json:"proceedingType"`
|
||||
ProceedingName string `json:"proceedingName"`
|
||||
ProceedingNameEN string `json:"proceedingNameEN,omitempty"`
|
||||
TriggerDate string `json:"triggerDate"`
|
||||
Deadlines []TimelineEntry `json:"deadlines"`
|
||||
ContextualNote string `json:"contextualNote,omitempty"`
|
||||
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
HiddenCount int `json:"hiddenCount"`
|
||||
}
|
||||
|
||||
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
|
||||
// interface (camelCase JSON to keep /tools/fristenrechner byte-identical).
|
||||
type TimelineEntry struct {
|
||||
RuleID string `json:"ruleId,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"nameEN"`
|
||||
Party string `json:"party"`
|
||||
Priority string `json:"priority"`
|
||||
RuleRef string `json:"ruleRef"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
||||
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
NotesEN string `json:"notesEN,omitempty"`
|
||||
DueDate string `json:"dueDate"`
|
||||
OriginalDate string `json:"originalDate"`
|
||||
WasAdjusted bool `json:"wasAdjusted"`
|
||||
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
||||
IsRootEvent bool `json:"isRootEvent"`
|
||||
IsCourtSet bool `json:"isCourtSet"`
|
||||
ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"`
|
||||
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
|
||||
// IsConditional signals the rule's anchor is uncertain — no
|
||||
// concrete date can be projected. Set when the rule depends on:
|
||||
// - a court-set ancestor whose date isn't anchored (overlaps
|
||||
// with IsCourtSetIndirect; the two are kept distinct because
|
||||
// IsCourtSet wraps a specific UX message "wird vom Gericht
|
||||
// bestimmt", whereas IsConditional is the broader "render as
|
||||
// 'abhängig von <parent>'" signal)
|
||||
// - timing='before' rules whose forward anchor isn't set
|
||||
// - optional opposing-side rules whose true triggering event
|
||||
// hasn't been recorded for this project (e.g. R.262(2)
|
||||
// Erwiderung auf Vertraulichkeitsantrag)
|
||||
// When true, DueDate and OriginalDate are empty and the frontend
|
||||
// renders an "abhängig von <ParentRuleName>" chip in place of a
|
||||
// date. Suppressed by an explicit user anchor. (t-paliad-289)
|
||||
IsConditional bool `json:"isConditional,omitempty"`
|
||||
// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
|
||||
// parent's identity so the frontend can render
|
||||
// "abhängig von <ParentRuleName>" when IsConditional=true.
|
||||
// Populated whenever the rule has a parent_id, not only when
|
||||
// conditional — keeps the wire shape stable. Empty for root rules.
|
||||
// When a rule has a real trigger_event_id, these fields are
|
||||
// overridden to point at the trigger_events catalog row instead of
|
||||
// the parent_id chain (t-paliad-294 / m/paliad#126).
|
||||
ParentRuleCode string `json:"parentRuleCode,omitempty"`
|
||||
ParentRuleName string `json:"parentRuleName,omitempty"`
|
||||
ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"`
|
||||
IsOverridden bool `json:"isOverridden,omitempty"`
|
||||
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
|
||||
AppellantContext string `json:"appellantContext,omitempty"`
|
||||
IsHidden bool `json:"isHidden,omitempty"`
|
||||
// DurationValue / DurationUnit / Timing surface the rule's
|
||||
// arithmetic so /tools/verfahrensablauf can show "2 Mo. nach" on
|
||||
// each event card (m/paliad#133, t-paliad-302). Source values from
|
||||
// the Rule row (not the post-alt-swap arithmetic) — the tooltip
|
||||
// reads as a property of the rule, not a recap of which branch
|
||||
// fired. Zero-duration rules (root event, court-set) emit
|
||||
// DurationValue=0 and the frontend suppresses the affordance.
|
||||
// Timing is "before" | "after" — empty when r.Timing is NULL.
|
||||
DurationValue int `json:"durationValue,omitempty"`
|
||||
DurationUnit string `json:"durationUnit,omitempty"`
|
||||
Timing string `json:"timing,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculation is the single-rule calc response that backs the
|
||||
// result-card click → calc-panel flow. Distinct from TimelineEntry
|
||||
// (which represents one rendered row inside a full-proceeding
|
||||
// response): RuleCalculation is self-contained.
|
||||
type RuleCalculation struct {
|
||||
Rule RuleCalculationRule `json:"rule"`
|
||||
Proceeding RuleCalculationProceeding `json:"proceeding"`
|
||||
TriggerDate string `json:"triggerDate"`
|
||||
OriginalDate string `json:"originalDate"`
|
||||
DueDate string `json:"dueDate"`
|
||||
WasAdjusted bool `json:"wasAdjusted"`
|
||||
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
||||
IsCourtSet bool `json:"isCourtSet"`
|
||||
FlagsApplied []string `json:"flagsApplied,omitempty"`
|
||||
FlagsRequired []string `json:"flagsRequired,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculationRule mirrors the small subset of Rule the
|
||||
// frontend needs to render the calc panel.
|
||||
type RuleCalculationRule struct {
|
||||
ID string `json:"id"`
|
||||
LocalCode string `json:"localCode,omitempty"`
|
||||
NameDE string `json:"nameDE"`
|
||||
NameEN string `json:"nameEN"`
|
||||
RuleRef string `json:"ruleRef,omitempty"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
||||
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
||||
DurationValue int `json:"durationValue"`
|
||||
DurationUnit string `json:"durationUnit"`
|
||||
Party string `json:"party,omitempty"`
|
||||
IsMandatory bool `json:"isMandatory"`
|
||||
NotesDE string `json:"notesDE,omitempty"`
|
||||
NotesEN string `json:"notesEN,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculationProceeding identifies the proceeding context for the
|
||||
// rule. Used by the frontend for display + by the add-to-project flow.
|
||||
type RuleCalculationProceeding struct {
|
||||
Code string `json:"code"`
|
||||
NameDE string `json:"nameDE"`
|
||||
NameEN string `json:"nameEN"`
|
||||
}
|
||||
|
||||
// FristenrechnerType mirrors the /api/tools/proceeding-types response
|
||||
// metadata.
|
||||
type FristenrechnerType struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"nameEN"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// EventLookupAxes carries the optional filter axes for
|
||||
// Catalog.LookupEvents (Slice B2, m/paliad#124 §18.2). All fields are
|
||||
// optional; the empty value (or nil pointer) is "no filter on this
|
||||
// axis". When multiple axes are set the catalog applies them as AND —
|
||||
// a rule must match every non-zero axis to be returned. An axis set
|
||||
// to an unknown value (jurisdiction="XX", party="foo") is treated the
|
||||
// same as "no filter on this axis" so a stale frontend doesn't
|
||||
// silently drop the entire result set.
|
||||
//
|
||||
// AppealTarget narrows to rules whose AppliesToTarget contains the
|
||||
// requested slug (same semantic as CalcOptions.AppealTarget). Useful
|
||||
// for the unified UPC Berufung lookup.
|
||||
type EventLookupAxes struct {
|
||||
// Jurisdiction filters by paliad.proceeding_types.jurisdiction
|
||||
// ("UPC" | "DE" | "EPA" | "DPMA"). Empty = any.
|
||||
Jurisdiction string
|
||||
// ProceedingTypeID narrows to one proceeding. nil = any.
|
||||
ProceedingTypeID *int
|
||||
// Party filters by paliad.deadline_rules.primary_party
|
||||
// ("claimant" | "defendant" | "court" | "both"). Empty = any.
|
||||
// Validated against PrimaryParties before the SQL pass; unknown
|
||||
// values fall through as "no filter".
|
||||
Party string
|
||||
// EventCategoryID narrows to rules associated with one
|
||||
// event_categories row via the
|
||||
// deadline_concept_event_types junction. nil = any.
|
||||
EventCategoryID *uuid.UUID
|
||||
// AppealTarget filters by Rule.AppliesToTarget containing the
|
||||
// requested slug (e.g. "endentscheidung"). Empty = any.
|
||||
// Validated against AppealTargets before the SQL pass.
|
||||
AppealTarget string
|
||||
}
|
||||
|
||||
// EventLookupDepth controls the sequence-depth of the returned events.
|
||||
type EventLookupDepth string
|
||||
|
||||
const (
|
||||
// EventLookupDepthNext returns immediate children of the matched
|
||||
// anchor (1 hop downstream via parent_id). Useful for "what comes
|
||||
// next from this point?" queries.
|
||||
EventLookupDepthNext EventLookupDepth = "next"
|
||||
// EventLookupDepthAllFollowing returns the entire downstream
|
||||
// chain (parent_id walk to leaves). Useful for "show me the
|
||||
// whole sequence from here onward" queries.
|
||||
EventLookupDepthAllFollowing EventLookupDepth = "all-following"
|
||||
)
|
||||
|
||||
// EventMatch is one result row from Catalog.LookupEvents.
|
||||
type EventMatch struct {
|
||||
// Rule carries the full deadline-rule row including parent_id,
|
||||
// duration_value/_unit, condition_expr, applies_to_target, etc.
|
||||
Rule Rule `json:"rule"`
|
||||
// ProceedingType is the owning proceeding metadata. Lets the
|
||||
// frontend render the "from <proceeding>" badge without a second
|
||||
// roundtrip.
|
||||
ProceedingType ProceedingType `json:"proceedingType"`
|
||||
// Priority surfaces Rule.Priority at the top level for
|
||||
// convenience — the four-value vocab (mandatory / recommended /
|
||||
// optional / informational).
|
||||
Priority string `json:"priority"`
|
||||
// DepthFromAnchor is 1 for the immediate match, 2+ for deeper
|
||||
// descendants returned under EventLookupDepthAllFollowing.
|
||||
// Always >= 1 for any returned row.
|
||||
DepthFromAnchor int `json:"depthFromAnchor"`
|
||||
// ParentRuleID is the parent rule's UUID when that parent is
|
||||
// itself in the returned result set (so the frontend can render
|
||||
// a tree). nil when the parent is outside the returned set.
|
||||
ParentRuleID *uuid.UUID `json:"parentRuleId,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
||||
// whose semantic anchor is an event rather than a parent rule (the
|
||||
// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is
|
||||
// triggered by the opposing party's confidentiality application, not
|
||||
// by the SoC parent rule). The conditional-rendering branch reads
|
||||
// this when stamping ParentRule* on the wire.
|
||||
type TriggerEvent struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameDE string `db:"name_de" json:"name_de"`
|
||||
Description string `db:"description" json:"description"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// Sentinel errors surfaced by Calculate / CalculateRule / Catalog
|
||||
// implementations. Handlers map these to HTTP statuses.
|
||||
var (
|
||||
ErrUnknownProceedingType = errors.New("unknown proceeding type")
|
||||
ErrUnknownRule = errors.New("unknown rule")
|
||||
)
|
||||
|
||||
// AppealTarget* are the canonical slugs for the unified UPC Berufung
|
||||
// proceeding type's appeal-target discriminator (Slice B1, m/paliad#124
|
||||
// §18.1). The verfahrensablauf picker renders one "Berufung" entry;
|
||||
// the user then picks one of these five targets and the engine filters
|
||||
// rules whose AppliesToTarget contains the requested slug.
|
||||
//
|
||||
// Schadensbemessung + Bucheinsicht have no rule rows in migration 134;
|
||||
// per m's 2026-05-26 decision they are distinct from the merits track
|
||||
// and their rule sets will be seeded in a follow-up slice (paired with
|
||||
// t-paliad-193 orphan-concept-seed or editorial via /admin/rules).
|
||||
// CalcOptions.AppealTarget="schadensbemessung" or "bucheinsicht"
|
||||
// currently returns an empty timeline.
|
||||
const (
|
||||
AppealTargetEndentscheidung = "endentscheidung"
|
||||
AppealTargetKostenentscheidung = "kostenentscheidung"
|
||||
AppealTargetAnordnung = "anordnung"
|
||||
AppealTargetSchadensbemessung = "schadensbemessung"
|
||||
AppealTargetBucheinsicht = "bucheinsicht"
|
||||
)
|
||||
|
||||
// AppealTargets is the canonical ordered list for UI chip rendering +
|
||||
// validation. Order matches the design doc + the frontend's i18n key
|
||||
// ordering — do not reorder without coordinating with the chip-group
|
||||
// renderer.
|
||||
var AppealTargets = []string{
|
||||
AppealTargetEndentscheidung,
|
||||
AppealTargetKostenentscheidung,
|
||||
AppealTargetAnordnung,
|
||||
AppealTargetSchadensbemessung,
|
||||
AppealTargetBucheinsicht,
|
||||
}
|
||||
|
||||
// PrimaryParty* are the canonical four-value vocabulary for
|
||||
// paliad.deadline_rules.primary_party (Slice B3, m/paliad#124 §18.3,
|
||||
// mig 135). The DB CHECK constraint enforces the same set; the
|
||||
// application-layer helper IsValidPrimaryParty lets the rule editor
|
||||
// surface a friendly 400 before the DB error fires.
|
||||
//
|
||||
// NULL is also valid in the DB (for the 78 orphan cross-cutting
|
||||
// concept seeds — Wiedereinsetzung, Versäumnisurteil-Einspruch,
|
||||
// Schriftsatznachreichung, Weiterbehandlung). The helper treats the
|
||||
// empty string as "no value supplied" = valid; non-empty strings must
|
||||
// match one of the four canonical values.
|
||||
const (
|
||||
PrimaryPartyClaimant = "claimant"
|
||||
PrimaryPartyDefendant = "defendant"
|
||||
PrimaryPartyCourt = "court"
|
||||
PrimaryPartyBoth = "both"
|
||||
)
|
||||
|
||||
// PrimaryParties is the canonical ordered list for validation +
|
||||
// admin-UI rendering. Order matches the rule-editor select; do not
|
||||
// reorder without coordinating with the frontend.
|
||||
var PrimaryParties = []string{
|
||||
PrimaryPartyClaimant,
|
||||
PrimaryPartyDefendant,
|
||||
PrimaryPartyCourt,
|
||||
PrimaryPartyBoth,
|
||||
}
|
||||
|
||||
// IsValidPrimaryParty returns true for empty (NULL-equivalent) or any
|
||||
// of the four canonical values. Used by the rule-editor to validate
|
||||
// writes before they hit the DB CHECK — produces a user-friendly 400
|
||||
// instead of a raw constraint-violation error.
|
||||
func IsValidPrimaryParty(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
for _, p := range PrimaryParties {
|
||||
if p == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValidAppealTarget returns true for empty (no filter requested) or
|
||||
// any of the five canonical slugs. The engine uses this to gate the
|
||||
// CalcOptions.AppealTarget filter — an unknown slug is silently
|
||||
// dropped (no filter applied) rather than producing an error, so a
|
||||
// stale frontend chip doesn't break the timeline render.
|
||||
func IsValidAppealTarget(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
for _, t := range AppealTargets {
|
||||
if t == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -315,11 +315,11 @@ func buildDocumentXML() string {
|
||||
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
|
||||
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
|
||||
|
||||
headerSubsection(&b, "Frist")
|
||||
body0(&b, "Frist-Bezeichnung: {{deadline.title}}")
|
||||
body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}")
|
||||
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
|
||||
// {{deadline.*}} placeholders stay resolvable in the variable bag
|
||||
// for custom templates that want them, but the default HL skeleton
|
||||
// no longer renders them in the submission body: the deadline is
|
||||
// internal/admin context and has no place in a court-bound document.
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
|
||||
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
|
||||
@@ -349,7 +349,6 @@ func buildDocumentXML() string {
|
||||
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
|
||||
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
|
||||
body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}")
|
||||
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
|
||||
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
|
||||
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")
|
||||
|
||||
@@ -137,14 +137,19 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
</w:styles>`
|
||||
|
||||
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
|
||||
// case caption + parties + submission heading + deadline + a single
|
||||
// neutral body block. Mirrors the variable bag from SubmissionVarsService
|
||||
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
|
||||
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
|
||||
// structure. A lawyer customising this template for a UPC SoC, EPO
|
||||
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
|
||||
// renames the party labels — every placeholder still resolves regardless
|
||||
// of the submission_code chosen.
|
||||
// case caption + parties + submission heading + a single neutral body
|
||||
// block. Mirrors the variable bag from SubmissionVarsService (firm.* /
|
||||
// today.* / user.* / project.* / parties.* / rule.*) without baking in
|
||||
// DE-LG-Klageerwiderung-specific structure. A lawyer customising this
|
||||
// template for a UPC SoC, EPO opposition, or DPMA appeal replaces the
|
||||
// [Schriftsatztext] block and renames the party labels — every
|
||||
// placeholder still resolves regardless of the submission_code chosen.
|
||||
//
|
||||
// The {{deadline.*}} placeholders are deliberately NOT rendered by the
|
||||
// default skeleton (t-paliad-287). The deadline is internal context for
|
||||
// the lawyer, not text that belongs in a court-bound submission. The
|
||||
// keys stay resolvable in the bag so a custom template can still
|
||||
// reference them where it actually wants them.
|
||||
//
|
||||
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
|
||||
// (format-preserving, single-run) substitution catches it. The
|
||||
@@ -194,11 +199,12 @@ func buildDocumentXML() string {
|
||||
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
||||
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||
|
||||
heading2(&b, "Frist")
|
||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
||||
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
|
||||
// {{deadline.*}} placeholders stay resolvable in the variable bag
|
||||
// (lawyer can still drop them into a custom paragraph) but the
|
||||
// default skeleton no longer renders them in the submission body:
|
||||
// the deadline is internal/admin context and has no place in a
|
||||
// document going out to court.
|
||||
|
||||
heading2(&b, "Schriftsatztext")
|
||||
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
|
||||
@@ -217,7 +223,7 @@ func buildDocumentXML() string {
|
||||
// the bare {{today}} alias. A lawyer customising the template can
|
||||
// delete this block; the renderer round-trips it cleanly today.
|
||||
heading2(&b, "Locale-aware variants (SKELETON)")
|
||||
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
|
||||
plain(&b, "EN long date: {{today.long_en}}")
|
||||
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
|
||||
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
||||
|
||||
Reference in New Issue
Block a user