Compare commits
42 Commits
mai/diesel
...
mai/cronus
| Author | SHA1 | Date | |
|---|---|---|---|
| e2969fc358 | |||
| 85d0cedd22 | |||
| 0e1691f00e | |||
| 05ad43aa46 | |||
| 43de8f9c7b | |||
| 635457474a | |||
| 235e68496b | |||
| 8125caf49a | |||
| 937ff13470 | |||
| b97f170c1d | |||
| 935ea23038 | |||
| f8e5be5f7a | |||
| ee0a9ea6cb | |||
| da464813b7 | |||
| 6d24fb8931 | |||
| 446c46e5c5 | |||
| d1aa0f72c0 | |||
| 94f2831f3f | |||
| 83be122b19 | |||
| df592f9fc4 | |||
| b6c2df95cc | |||
| 367627af0d | |||
| 7d7b20651d | |||
| 8f1a287549 | |||
| 38ebccc907 | |||
| 3b601f156b | |||
| cd5f752a0e | |||
| 2377f08bd7 | |||
| 1d704f6e04 | |||
| a75731a902 | |||
| 727e01c6c9 | |||
| 5cff38ff3c | |||
| 3097df3918 | |||
| 46b58dcf41 | |||
| 9da4715137 | |||
| 16ec8c490a | |||
| f49c804ddd | |||
| 5901d40b79 | |||
| c767b61a8a | |||
| 2a56b7817c | |||
| 75833082fc | |||
| ce28ea972e |
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))
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
// Embed Go's IANA tz database into the binary so time.LoadLocation works
|
||||
// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
|
||||
@@ -159,6 +160,14 @@ func main() {
|
||||
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
|
||||
submissionRenderer := services.NewSubmissionRenderer()
|
||||
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
|
||||
// t-paliad-313 Composer Slice A — base catalog + section seeding.
|
||||
// AttachComposer wires both into the draft service so Create
|
||||
// seeds base_id + submission_sections rows on new drafts. v1
|
||||
// fallback path stays active for pre-Composer drafts (base_id
|
||||
// NULL, no section rows).
|
||||
submissionBaseSvc := services.NewBaseService(pool)
|
||||
submissionSectionSvc := services.NewSectionService(pool)
|
||||
submissionDraftSvc.AttachComposer(submissionBaseSvc, submissionSectionSvc, branding.Name)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
@@ -170,7 +179,9 @@ func main() {
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
SubmissionBase: submissionBaseSvc,
|
||||
SubmissionSection: submissionSectionSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -221,6 +232,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
|
||||
@@ -337,6 +350,13 @@ func main() {
|
||||
log.Printf("CalDAV start: %v", err)
|
||||
}
|
||||
reminderSvc.Start(bgCtx)
|
||||
// Slice B.2 dual-write drift check (t-paliad-305 / m/paliad#93).
|
||||
// Runs every 6 h while the new procedural_events / sequencing_rules /
|
||||
// legal_sources tables shadow the legacy paliad.deadline_rules
|
||||
// table. A clean run logs at INFO; drift logs at WARN with the
|
||||
// full report so a broken dual-write surfaces before the next
|
||||
// deploy.
|
||||
services.StartDualWriteDriftCheckLoop(bgCtx, pool, 6*time.Hour)
|
||||
go func() {
|
||||
<-bgCtx.Done()
|
||||
log.Println("background services: shutdown signal received")
|
||||
|
||||
@@ -1449,4 +1449,170 @@ No `AskUserQuestion` per inventor protocol; head escalates to m if material.
|
||||
|
||||
---
|
||||
|
||||
## §19 Slice C — embedded UPC snapshot + generator (2026-05-26)
|
||||
|
||||
Slice A landed the package, Slice B added the catalog API surface. Slice C lays the foundation for the youpc.org cross-repo integration: an in-package UPC subset of paliad's deadline corpus, embedded as JSON, that youpc.org can use to run the engine without any paliad DB access.
|
||||
|
||||
### §19.1 Goals
|
||||
|
||||
1. **Zero DB dependency for snapshot consumers.** youpc.org imports `pkg/litigationplanner/embedded/upc` and gets a working Catalog / HolidayCalendar / CourtRegistry without ever touching paliad's Postgres.
|
||||
2. **Reproducible regeneration.** A generator binary (`cmd/gen-upc-snapshot`) reads paliad's live DB and produces the JSON. Idempotent — same DB state in, same JSON out.
|
||||
3. **Versioned snapshots.** Each snapshot carries a `version` + `generated_at` so consumers can detect regeneration and decide whether to bump their go.mod.
|
||||
4. **Stays in lockstep with paliad's engine.** The embedded data conforms to the same `Rule` / `ProceedingType` Go types the engine consumes — no schema drift, no parallel-vocab risk.
|
||||
|
||||
### §19.2 Embedding format
|
||||
|
||||
**Pick: `//go:embed` of JSON.**
|
||||
|
||||
Three candidates considered:
|
||||
- A. **`//go:embed` of JSON files** — generator emits human-readable JSON; package reads at boot via `embed.FS`. Diff-friendly in git; youpc.org sees the bytes change in code review.
|
||||
- B. **Generated Go const literals** — generator emits a `.go` file with the rule slice inlined. Type-safe at compile; harder to diff (big generated files); pollutes `git log -p` with mechanical changes.
|
||||
- C. **External resource fetched at runtime** — youpc.org would HTTP-GET the snapshot from a paliad endpoint. Adds runtime coupling between the two services; defeats the "zero DB dependency" goal.
|
||||
|
||||
**(R) = A**. JSON is the wire shape paliad's API already serves; the package's `Rule` struct already has compatible `json:` tags from Slice A. The generated bytes survive `git diff` cleanly. youpc.org can also vendor the JSON via go-module if they want fully reproducible builds.
|
||||
|
||||
### §19.3 File layout
|
||||
|
||||
```
|
||||
pkg/litigationplanner/embedded/upc/
|
||||
embed.go ← //go:embed *.json + package metadata
|
||||
snapshot.go ← SnapshotCatalog struct + Load() helper
|
||||
snapshot_test.go ← unit tests against the embedded data
|
||||
rules.json ← generator output: all UPC rules
|
||||
proceeding_types.json ← generator output: all UPC proceeding types
|
||||
trigger_events.json ← generator output: UPC-referenced trigger events
|
||||
holidays.json ← generator output: DE + UPC regime holidays
|
||||
courts.json ← generator output: UPC courts
|
||||
meta.json ← generator output: {version, generated_at, paliad_commit, source_db_label}
|
||||
|
||||
cmd/gen-upc-snapshot/
|
||||
main.go ← generator entry point
|
||||
README.md ← operator runbook
|
||||
```
|
||||
|
||||
`pkg/litigationplanner/embedded/upc` is the public consumer surface. youpc.org imports it as:
|
||||
|
||||
```go
|
||||
import 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)
|
||||
```
|
||||
|
||||
### §19.4 Snapshot data shape
|
||||
|
||||
The five data files (`rules.json`, `proceeding_types.json`, `trigger_events.json`, `holidays.json`, `courts.json`) are each a top-level JSON array of the corresponding type. The package's `Rule` / `ProceedingType` / `TriggerEvent` structs deserialise directly (their `json:` tags align with paliad's wire shape).
|
||||
|
||||
`holidays.json` and `courts.json` use minimal structures defined in the embedded sub-package (the package's core API only requires `HolidayCalendar` / `CourtRegistry` interfaces — no struct contract).
|
||||
|
||||
`meta.json` carries the versioning block:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2026-05-26-1",
|
||||
"generated_at": "2026-05-26T15:01:00Z",
|
||||
"paliad_commit": "932b177",
|
||||
"source_db_label": "paliad-dev-supabase",
|
||||
"rule_count": 81,
|
||||
"proceeding_count": 9,
|
||||
"trigger_event_count": 2,
|
||||
"holiday_count": 142,
|
||||
"court_count": 18
|
||||
}
|
||||
```
|
||||
|
||||
`version` uses a date-stamped scheme (`YYYY-MM-DD-N` where N starts at 1 and increments for same-day regenerations) — simple, sortable, no merge conflicts on regen.
|
||||
|
||||
### §19.5 Generator
|
||||
|
||||
`cmd/gen-upc-snapshot/main.go` runs as:
|
||||
|
||||
```sh
|
||||
DATABASE_URL=postgres://... \
|
||||
go run ./cmd/gen-upc-snapshot \
|
||||
-output ./pkg/litigationplanner/embedded/upc
|
||||
```
|
||||
|
||||
Flow:
|
||||
1. Connect to `DATABASE_URL` (paliad's live DB).
|
||||
2. Apply migrations first (`db.ApplyMigrations(url)`) — ensures the snapshot matches schema HEAD.
|
||||
3. SELECT all `paliad.proceeding_types` WHERE `jurisdiction = 'UPC'` AND `is_active = true`. (After B1 the unified `upc.apl` is the only appeal proceeding — the 3 archived old codes are filtered out.)
|
||||
4. SELECT all `paliad.deadline_rules` for those proceeding ids WHERE `lifecycle_state = 'published'` AND `is_active = true`.
|
||||
5. SELECT `paliad.trigger_events` referenced by any rule's `trigger_event_id`.
|
||||
6. SELECT `paliad.holidays` filtered to `country = 'DE' OR regime = 'UPC'` (the union UPC procedures need).
|
||||
7. SELECT `paliad.courts` filtered to `regime = 'UPC' OR court_type LIKE 'upc%'` (UPC court hierarchy).
|
||||
8. Write each result set to `<output>/<name>.json` (pretty-printed for diff-friendliness).
|
||||
9. Compute meta — current paliad commit (via `git rev-parse --short HEAD`), timestamp, row counts.
|
||||
10. Write `meta.json`.
|
||||
|
||||
**Versioning rule**: the generator never overwrites a meta.json with `version` equal to an existing one. If today's date is already used (suffix `-1`), the generator bumps to `-2`. This keeps regenerations within a day distinguishable. Operator can pass `-version <string>` to override.
|
||||
|
||||
### §19.6 Regeneration trigger
|
||||
|
||||
Manual. Three entry points:
|
||||
|
||||
- **`make snapshot-upc`** — Make target invokes the generator with `DATABASE_URL` from env. Documented in `cmd/gen-upc-snapshot/README.md`.
|
||||
- **`go generate ./pkg/litigationplanner/embedded/upc`** — `//go:generate` directive on a stub in the package. Same effect; lets contributors discover the regen path from the package they're modifying.
|
||||
- **Operator runs the command directly** — power-user path.
|
||||
|
||||
**No CI regeneration in v1.** The snapshot is operator-controlled. Future slice can add a nightly CI job that opens a PR with the regenerated snapshot if drift is detected (out of scope here).
|
||||
|
||||
### §19.7 SnapshotCatalog implementation
|
||||
|
||||
In `pkg/litigationplanner/embedded/upc/snapshot.go`:
|
||||
|
||||
```go
|
||||
type SnapshotCatalog struct {
|
||||
proceedings []litigationplanner.ProceedingType
|
||||
rules []litigationplanner.Rule
|
||||
triggerEvents map[int64]litigationplanner.TriggerEvent
|
||||
rulesByProc map[int][]litigationplanner.Rule // for LoadProceeding
|
||||
rulesByID map[uuid.UUID]litigationplanner.Rule
|
||||
procByID map[int]litigationplanner.ProceedingType
|
||||
procByCode map[string]litigationplanner.ProceedingType
|
||||
}
|
||||
|
||||
func NewCatalog() (*SnapshotCatalog, error) // parses embedded JSON
|
||||
```
|
||||
|
||||
All 7 Catalog interface methods (`LoadProceeding`, `LoadProceedingByID`, `LoadRuleByID`, `LoadRuleByCode`, `LoadRulesByTriggerEvent`, `LoadTriggerEventsByIDs`, `LookupEvents`) implemented against the in-memory maps. Lookup methods are O(1) on the indexed maps; `LookupEvents` does a linear scan of `rules` (the UPC subset is < 100 rows; no index needed).
|
||||
|
||||
`ProjectHint` is ignored on the snapshot side (youpc.org has no projects). `applies_to_target` filter for B1 works identically — the rules carry the same array.
|
||||
|
||||
`HolidayCalendar` impl mirrors paliad's `HolidayService` but reads from the embedded holiday slice instead of paliad.holidays. Same `AdjustForNonWorkingDaysWithReason` semantics.
|
||||
|
||||
`CourtRegistry` impl mirrors `CourtService.CountryRegime`. UPC courts only.
|
||||
|
||||
### §19.8 Tests
|
||||
|
||||
`snapshot_test.go` exercises:
|
||||
- Snapshot loads without error
|
||||
- `meta.json` parses + has non-zero counts
|
||||
- `LoadProceeding(ctx, "upc.inf.cfi", ProjectHint{})` returns the expected proceeding + > 0 rules
|
||||
- `LookupEvents(ctx, EventLookupAxes{Jurisdiction:"UPC"}, EventLookupDepthAllFollowing)` returns all rules
|
||||
- A golden compute: `Calculate(ctx, "upc.inf.cfi", "2026-01-15", CalcOptions{}, cat, hc, cr)` produces a non-empty timeline with a known root rule (Klageerhebung)
|
||||
|
||||
All tests run without a DB (zero `os.Getenv("TEST_DATABASE_URL")` checks).
|
||||
|
||||
### §19.9 Acceptance criteria
|
||||
|
||||
1. `cmd/gen-upc-snapshot` exists + builds + runs against the live paliad DB.
|
||||
2. `pkg/litigationplanner/embedded/upc/*.json` checked in with the first generated snapshot.
|
||||
3. `embedded/upc.NewCatalog()` (+ `NewHolidayCalendar` + `NewCourtRegistry`) return ready-to-use implementations of the package interfaces.
|
||||
4. Unit tests in `embedded/upc` pass without `TEST_DATABASE_URL` (no DB roundtrip).
|
||||
5. `make snapshot-upc` regenerates the snapshot.
|
||||
6. `go build ./...` + `go test ./...` all green.
|
||||
|
||||
### §19.10 Out of scope (deferred to follow-up)
|
||||
|
||||
- Snapshot signing / integrity attestation. v1 is plain JSON; future slice can ship a `meta.sig` next to `meta.json` for tamper detection.
|
||||
- DE/EPA/DPMA snapshots. v1 only ships the UPC subset (matches youpc.org's scope). Future jurisdictions add as sibling packages: `embedded/de`, `embedded/epa`, etc.
|
||||
- CI regeneration cron. Operator-driven only in v1.
|
||||
- Snapshot diff tooling. v1 relies on `git diff` of the JSON files.
|
||||
|
||||
---
|
||||
|
||||
*End of design doc.*
|
||||
|
||||
1018
docs/design-submission-generator-v2-2026-05-26.md
Normal file
1018
docs/design-submission-generator-v2-2026-05-26.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"changelog.tag.fix": "Fix",
|
||||
|
||||
// Footer
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 ein Werkzeug von",
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 by",
|
||||
|
||||
// Landing page
|
||||
"index.title": `Paliad \u2014 Patent Litigation f\u00fcr ${FIRM}`,
|
||||
@@ -312,6 +312,7 @@ 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",
|
||||
@@ -462,10 +463,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.from_project": "Aus Akte:",
|
||||
"deadlines.side.override": "Andere Seite wählen",
|
||||
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -1523,6 +1520,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.language.de": "DE",
|
||||
"submissions.draft.language.en": "EN",
|
||||
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
|
||||
"submissions.draft.base.label": "Vorlagenbasis",
|
||||
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
|
||||
"submissions.draft.sections.title": "Abschnitte",
|
||||
"submissions.draft.sections.hint": "Read-only Vorschau — editierbar in Slice B.",
|
||||
// t-paliad-240 — global Schriftsätze drafts index page.
|
||||
"submissions.index.title": "Schriftsätze — Paliad",
|
||||
"submissions.index.heading": "Schriftsätze",
|
||||
@@ -3177,7 +3179,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"changelog.tag.fix": "Fix",
|
||||
|
||||
// Footer
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 a tool by",
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 by",
|
||||
|
||||
// Landing page
|
||||
"index.title": `Paliad \u2014 Patent Litigation for ${FIRM}`,
|
||||
@@ -3410,6 +3412,7 @@ 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",
|
||||
@@ -3567,10 +3570,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.from_project": "From case:",
|
||||
"deadlines.side.override": "Choose other side",
|
||||
"deadlines.side.hint": "Pick a side to focus the columns.",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
@@ -4602,6 +4601,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.import.button": "Import from project",
|
||||
"submissions.draft.parties.title": "Parties",
|
||||
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
|
||||
"submissions.draft.base.label": "Template base",
|
||||
"submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.",
|
||||
"submissions.draft.sections.title": "Sections",
|
||||
"submissions.draft.sections.hint": "Read-only preview — editable in Slice B.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
|
||||
@@ -28,10 +28,47 @@ interface SubmissionDraftJSON {
|
||||
last_exported_at?: string | null;
|
||||
last_exported_sha?: string | null;
|
||||
last_imported_at?: string | null;
|
||||
// t-paliad-313 Composer Slice A — base reference + Composer-side
|
||||
// metadata. base_id is null on pre-Composer drafts (the v1 render
|
||||
// path stays the fallback). composer_meta carries the seed-time
|
||||
// section order in later slices.
|
||||
base_id?: string | null;
|
||||
composer_meta?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// t-paliad-313 Composer Slice A — per-draft section row, surfaced
|
||||
// read-only in the editor body. Slice B adds inline edit + PATCH.
|
||||
interface SubmissionSectionJSON {
|
||||
id: string;
|
||||
section_key: string;
|
||||
order_index: number;
|
||||
kind: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
included: boolean;
|
||||
content_md_de: string;
|
||||
content_md_en: string;
|
||||
}
|
||||
|
||||
// t-paliad-313 Composer Slice A — base catalog row, surfaced in the
|
||||
// sidebar picker dropdown.
|
||||
interface SubmissionBaseRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
firm?: string | null;
|
||||
proceeding_family?: string | null;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description_de?: string | null;
|
||||
description_en?: string | null;
|
||||
gitea_path: string;
|
||||
is_default_for: string[];
|
||||
is_active: boolean;
|
||||
section_count: number;
|
||||
}
|
||||
|
||||
interface AvailablePartyJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -64,6 +101,9 @@ interface SubmissionDraftView {
|
||||
// language has no per-firm language-matched template.
|
||||
template_tier?: string;
|
||||
language_fallback?: boolean;
|
||||
// t-paliad-313 Composer Slice A — per-draft section stack. Empty
|
||||
// for pre-Composer drafts where no rows have been seeded.
|
||||
sections: SubmissionSectionJSON[];
|
||||
}
|
||||
|
||||
interface SubmissionDraftListResponse {
|
||||
@@ -328,6 +368,11 @@ interface State {
|
||||
addPartyMode: "manual" | "search";
|
||||
addPartySearchHits: PartySearchHit[];
|
||||
addPartyBusy: boolean;
|
||||
// t-paliad-313 Composer Slice A — base catalog fetched once on boot.
|
||||
// Picker hidden until populated; empty array (after the fetch
|
||||
// completes) keeps the picker hidden permanently for this load.
|
||||
bases: SubmissionBaseRow[];
|
||||
basesLoaded: boolean;
|
||||
}
|
||||
|
||||
type PartySide = "claimant" | "defendant" | "other";
|
||||
@@ -354,6 +399,8 @@ const state: State = {
|
||||
addPartyMode: "manual",
|
||||
addPartySearchHits: [],
|
||||
addPartyBusy: false,
|
||||
bases: [],
|
||||
basesLoaded: false,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -371,6 +418,14 @@ async function boot(): Promise<void> {
|
||||
}
|
||||
state.parsed = parsed;
|
||||
|
||||
// t-paliad-313 Composer Slice A — kick the base catalog fetch in
|
||||
// parallel with the view load. The picker hydrates when both land;
|
||||
// either failing leaves the picker hidden but the editor functional.
|
||||
loadBases().catch(err => {
|
||||
console.warn("submission-draft: base catalog fetch failed", err);
|
||||
state.basesLoaded = true;
|
||||
});
|
||||
|
||||
try {
|
||||
if (parsed.mode === "global") {
|
||||
// Global path: we have a draft_id, fetch by id alone. Drafts
|
||||
@@ -523,11 +578,13 @@ function paint(): void {
|
||||
paintNoProjectBanner();
|
||||
paintSwitcher();
|
||||
paintNameRow();
|
||||
paintBasePicker();
|
||||
paintImportRow();
|
||||
paintPartyPicker();
|
||||
paintLanguageRow();
|
||||
paintLanguageFallback();
|
||||
paintVariables();
|
||||
paintSectionList();
|
||||
paintPreview();
|
||||
}
|
||||
|
||||
@@ -1143,6 +1200,142 @@ function paintPreview(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// t-paliad-313 Composer Slice A — base picker + section list
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadBases(): Promise<void> {
|
||||
const res = await fetch("/api/submission-bases", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
throw new Error("base list HTTP " + res.status);
|
||||
}
|
||||
const body = await res.json() as { bases?: SubmissionBaseRow[] };
|
||||
state.bases = body.bases ?? [];
|
||||
state.basesLoaded = true;
|
||||
// If the view has already painted, re-paint the picker so it
|
||||
// hydrates as soon as the catalog lands. paint() is idempotent.
|
||||
if (state.view) paintBasePicker();
|
||||
}
|
||||
|
||||
function paintBasePicker(): void {
|
||||
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
|
||||
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
|
||||
if (!row || !sel || !state.view) return;
|
||||
|
||||
// Hide the picker until the catalog has loaded AND the catalog has
|
||||
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
|
||||
// keeps the picker hidden indefinitely so the editor stays usable.
|
||||
if (!state.basesLoaded || state.bases.length === 0) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
|
||||
// Rebuild the <option> list each paint so language toggles + base
|
||||
// catalog updates flow through.
|
||||
sel.innerHTML = "";
|
||||
const currentBaseID = state.view.draft.base_id ?? "";
|
||||
|
||||
// "Keine Vorlagenbasis" only listed when the draft is currently in
|
||||
// that state (pre-Composer / cleared). Avoids tempting the lawyer
|
||||
// to clear after they've already picked one.
|
||||
if (!currentBaseID) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
for (const b of state.bases) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = b.id;
|
||||
opt.textContent = isEN() ? b.label_en : b.label_de;
|
||||
if (b.id === currentBaseID) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
// Wire change handler once per paint. Removing then re-adding
|
||||
// keeps the binding consistent across repaints (e.g. after
|
||||
// language toggle re-renders the labels).
|
||||
sel.onchange = () => { onBaseChange(sel.value); };
|
||||
}
|
||||
|
||||
async function onBaseChange(newBaseID: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const payload: Record<string, unknown> = {
|
||||
// Empty string in the picker maps to null = clear.
|
||||
base_id: newBaseID === "" ? null : newBaseID,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${state.view.draft.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("base swap PATCH failed", res.status);
|
||||
return;
|
||||
}
|
||||
const view = await res.json() as SubmissionDraftView;
|
||||
state.view = view;
|
||||
paint();
|
||||
} catch (err) {
|
||||
console.warn("base swap PATCH error", err);
|
||||
}
|
||||
}
|
||||
|
||||
function paintSectionList(): void {
|
||||
const wrap = document.getElementById("submission-draft-sections-wrap");
|
||||
const list = document.getElementById("submission-draft-sections-list") as HTMLOListElement | null;
|
||||
if (!wrap || !list || !state.view) return;
|
||||
|
||||
const sections = state.view.sections ?? [];
|
||||
if (sections.length === 0) {
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
|
||||
list.innerHTML = "";
|
||||
const lang = state.view.draft.language || state.view.lang || "de";
|
||||
for (const sec of sections) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "submission-draft-section";
|
||||
if (!sec.included) li.classList.add("submission-draft-section--excluded");
|
||||
|
||||
const head = document.createElement("header");
|
||||
head.className = "submission-draft-section-head";
|
||||
const title = document.createElement("h3");
|
||||
title.className = "submission-draft-section-title";
|
||||
title.textContent = (lang === "en" ? sec.label_en : sec.label_de) || sec.section_key;
|
||||
head.appendChild(title);
|
||||
const kind = document.createElement("span");
|
||||
kind.className = "submission-draft-section-kind";
|
||||
kind.textContent = sec.kind;
|
||||
head.appendChild(kind);
|
||||
if (!sec.included) {
|
||||
const muted = document.createElement("span");
|
||||
muted.className = "submission-draft-section-excluded-badge";
|
||||
muted.textContent = isEN() ? "excluded" : "ausgeblendet";
|
||||
head.appendChild(muted);
|
||||
}
|
||||
li.appendChild(head);
|
||||
|
||||
const md = (lang === "en" ? sec.content_md_en : sec.content_md_de) || "";
|
||||
const body = document.createElement("pre");
|
||||
body.className = "submission-draft-section-body";
|
||||
body.textContent = md.length > 0
|
||||
? md
|
||||
: (isEN() ? "(empty — Slice B adds inline editing)" : "(leer — editierbar in Slice B)");
|
||||
li.appendChild(body);
|
||||
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-261 (B) — click a substituted variable in the preview to
|
||||
// jump to the matching sidebar input. Re-wires on every paintPreview
|
||||
// since the preview HTML is replaced wholesale. The server side wraps
|
||||
|
||||
@@ -12,7 +12,6 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
@@ -24,26 +23,45 @@ import {
|
||||
import {
|
||||
attachEventCardChoices,
|
||||
reseedChips,
|
||||
currentChoices,
|
||||
type EventChoice,
|
||||
type ChoiceKind,
|
||||
} from "./views/event-card-choices";
|
||||
import {
|
||||
APPEAL_TARGETS,
|
||||
SCENARIO_KEYS,
|
||||
type AppealTarget,
|
||||
type Side,
|
||||
type StorageLike,
|
||||
applyFiltersToSearch,
|
||||
makeMemoryStorage,
|
||||
parseAppealTargetFromSearch,
|
||||
parseProceedingFromSearch,
|
||||
parseSideFromSearch,
|
||||
parseTriggerDateFromSearch,
|
||||
readBoolFlag,
|
||||
readCourtId,
|
||||
readEventChoices,
|
||||
writeBoolFlag,
|
||||
writeCourtId,
|
||||
writeEventChoices,
|
||||
} from "./views/verfahrensablauf-state";
|
||||
|
||||
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,19 +70,21 @@ 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.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// upc.apl.unified is NOT in this set since t-paliad-307: appeal
|
||||
// timelines route via per-rule appealRole (engine-stamped under
|
||||
// appeal_target) instead of the page-level appellant axis collapse.
|
||||
// Adding upc.apl.unified here would short-circuit the appealAware
|
||||
// path and re-introduce the dead side selector on upc.apl.unified
|
||||
// (m/paliad#136 Bug 1).
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.unified",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
@@ -73,25 +93,55 @@ 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.
|
||||
//
|
||||
// APPEAL_TARGETS itself lives in ./views/verfahrensablauf-state so the
|
||||
// pure URL parser and this page share the same canonical list.
|
||||
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);
|
||||
}
|
||||
@@ -100,48 +150,62 @@ function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function readSideFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("side");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
// Scenario storage — real localStorage in the browser, in-memory
|
||||
// fallback when localStorage throws (private mode, disabled storage,
|
||||
// etc.). All scenario writes go through this single handle so a
|
||||
// failure mode is isolated to one try/catch path.
|
||||
const scenarioStorage: StorageLike = makeScenarioStorage();
|
||||
|
||||
function readAppellantFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("appellant");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (s === null) url.searchParams.delete("side");
|
||||
else url.searchParams.set("side", s);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
function writeAppellantToURL(a: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (a === null) url.searchParams.delete("appellant");
|
||||
else url.searchParams.set("appellant", a);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// 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;
|
||||
function makeScenarioStorage(): StorageLike {
|
||||
try {
|
||||
const probe = "__paliad_va_probe__";
|
||||
window.localStorage.setItem(probe, "1");
|
||||
window.localStorage.removeItem(probe);
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return makeMemoryStorage();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function writeAppealTargetToURL(t: AppealTarget) {
|
||||
// URL writers — all four chip params route through this single helper
|
||||
// so the canonical query-string shape (no empty values, no trailing
|
||||
// `?`) is enforced in one place.
|
||||
function applyURLFilters(filters: {
|
||||
proceeding?: string;
|
||||
side?: Side;
|
||||
target?: AppealTarget;
|
||||
triggerDate?: string;
|
||||
}): void {
|
||||
const url = new URL(window.location.href);
|
||||
if (t === "") url.searchParams.delete("target");
|
||||
else url.searchParams.set("target", t);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
const nextSearch = applyFiltersToSearch(url.search, filters);
|
||||
window.history.replaceState(null, "", url.pathname + nextSearch + url.hash);
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
// Default target on first picker entry into upc.apl. m: Endentscheidung
|
||||
@@ -160,54 +224,18 @@ const anchorOverrides = new Map<string, string>();
|
||||
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||
|
||||
// Per-event-card choices (t-paliad-265). Unbound on this page (no
|
||||
// project context), so persistence is URL-only via `?event_choices=`.
|
||||
// Format: comma-separated `submission_code:kind=value` tuples. Same
|
||||
// idiom as `?side=` + `?appellant=`.
|
||||
let perCardChoices: EventChoice[] = [];
|
||||
// project context). Persistence moved from URL → localStorage under
|
||||
// SCENARIO_KEYS.eventChoices (t-paliad-308 / m/paliad#137) — these
|
||||
// are per-user scenario tweaks, not the timeline kind, so a shared
|
||||
// link should NOT leak them into the recipient's view.
|
||||
let perCardChoices: EventChoice[] = readEventChoices(scenarioStorage);
|
||||
|
||||
function readChoicesFromURL(): EventChoice[] {
|
||||
const raw = new URLSearchParams(window.location.search).get("event_choices");
|
||||
if (!raw) return [];
|
||||
const out: EventChoice[] = [];
|
||||
for (const tuple of raw.split(",")) {
|
||||
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const kind = m[2] as ChoiceKind;
|
||||
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
||||
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeChoicesToURL(choices: EventChoice[]) {
|
||||
const url = new URL(window.location.href);
|
||||
if (choices.length === 0) {
|
||||
url.searchParams.delete("event_choices");
|
||||
} else {
|
||||
const enc = choices.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`).join(",");
|
||||
url.searchParams.set("event_choices", enc);
|
||||
}
|
||||
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
|
||||
// Show-hidden toggle (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();
|
||||
// Persistence moved from URL → localStorage (t-paliad-308) — it's a
|
||||
// per-user UX preference, not scenario state worth sharing in a link.
|
||||
let showHidden = readBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden);
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
@@ -225,6 +253,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 /
|
||||
@@ -431,10 +474,22 @@ 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,
|
||||
// Appeal-target proceedings get per-rule appealRole routing
|
||||
// instead of the page-level appellant collapse, so the side
|
||||
// selector actually splits Berufungskläger vs Berufungs-
|
||||
// beklagter filings across columns. (t-paliad-307 /
|
||||
// m/paliad#136 Bug 1)
|
||||
appealAware: hasAppealTarget(selectedType),
|
||||
})
|
||||
: 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";
|
||||
@@ -484,7 +539,7 @@ function syncInfAmendEnabled() {
|
||||
if (!ccr.checked) infAmend.checked = false;
|
||||
}
|
||||
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
function selectProceeding(btn: HTMLButtonElement, opts: { writeURL?: boolean } = {}) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const nextType = btn.dataset.code || "";
|
||||
@@ -494,37 +549,76 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
if (selectedType !== nextType) clearAnchorOverrides();
|
||||
selectedType = nextType;
|
||||
|
||||
// Persist the picked proceeding to ?proceeding= so a refresh / shared
|
||||
// link reproduces the same tile. writeURL=false on the load-time
|
||||
// hydration path so we don't churn history.replaceState when the
|
||||
// URL already carries the canonical value.
|
||||
if (opts.writeURL !== false) {
|
||||
applyURLFilters({ proceeding: selectedType });
|
||||
}
|
||||
|
||||
// Trigger-event label fires from the calc response (root rule).
|
||||
// Until step 3 renders, fall back to an em-dash placeholder.
|
||||
lastResponse = null;
|
||||
syncTriggerEventLabel();
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppellantRowVisibility();
|
||||
syncAppealTargetRowVisibility();
|
||||
applyRoleLabels(selectedType);
|
||||
// Restore flags from localStorage BEFORE the initial calc so the
|
||||
// first /api/tools/fristenrechner POST already carries the user's
|
||||
// stored flag state. Court_id is async (populateCourtPicker fetches
|
||||
// courts from the API) so it restores via the .then() below + a
|
||||
// follow-up recalc when the picker is ready.
|
||||
restoreFlagsForProceeding();
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
showStep(2);
|
||||
scheduleCalc(0);
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType).then(() => {
|
||||
if (restoreCourtForProceeding()) scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
// syncAppellantRowVisibility hides the appellant selector for
|
||||
// proceedings that have no appellant axis (first-instance Inf, Rev,
|
||||
// …). Clears the in-memory state and the URL param when hidden so a
|
||||
// shared link with ?appellant= doesn't leak into an unrelated
|
||||
// proceeding's render.
|
||||
function syncAppellantRowVisibility() {
|
||||
const row = document.getElementById("appellant-row");
|
||||
if (!row) return;
|
||||
const visible = hasAppellantAxis(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppellant !== null) {
|
||||
currentAppellant = null;
|
||||
writeAppellantToURL(null);
|
||||
syncRadioGroup("appellant", "");
|
||||
// restoreFlagsForProceeding seeds the proceeding-specific flag
|
||||
// checkboxes from localStorage. Mirrors syncFlagRows in scope — only
|
||||
// flags currently visible for the active proceeding are meaningful
|
||||
// (the hidden checkboxes still write to localStorage if toggled, but
|
||||
// that's impossible because they're not in the DOM as visible
|
||||
// controls). syncInfAmendEnabled enforces the upc.inf.cfi inf-amend
|
||||
// gating after the restore.
|
||||
function restoreFlagsForProceeding(): void {
|
||||
const flagPairs: Array<[string, string]> = [
|
||||
["ccr-flag", SCENARIO_KEYS.ccr],
|
||||
["inf-amend-flag", SCENARIO_KEYS.infAmend],
|
||||
["rev-amend-flag", SCENARIO_KEYS.revAmend],
|
||||
["rev-cci-flag", SCENARIO_KEYS.revCci],
|
||||
];
|
||||
for (const [domId, storageKey] of flagPairs) {
|
||||
const cb = document.getElementById(domId) as HTMLInputElement | null;
|
||||
if (!cb) continue;
|
||||
cb.checked = readBoolFlag(scenarioStorage, storageKey);
|
||||
}
|
||||
syncInfAmendEnabled();
|
||||
}
|
||||
|
||||
// restoreCourtForProceeding tries to apply the localStorage court_id
|
||||
// to the picker after populateCourtPicker resolves. Returns true iff
|
||||
// a value actually changed (so the caller can schedule a follow-up
|
||||
// calc). Skips silently when the picker is hidden, the stored ID isn't
|
||||
// in the options list (court rotated since last visit), or the picker
|
||||
// already happens to be on the stored value.
|
||||
function restoreCourtForProceeding(): boolean {
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
const storedCourtId = readCourtId(scenarioStorage);
|
||||
if (!courtPicker || !storedCourtId) return false;
|
||||
const has = Array.from(courtPicker.options).some((o) => o.value === storedCourtId);
|
||||
if (!has) return false;
|
||||
if (courtPicker.value === storedCourtId) return false;
|
||||
courtPicker.value = storedCourtId;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
||||
@@ -539,7 +633,7 @@ function syncAppealTargetRowVisibility() {
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppealTarget !== "") {
|
||||
currentAppealTarget = "";
|
||||
writeAppealTargetToURL("");
|
||||
applyURLFilters({ target: "" });
|
||||
syncRadioGroup("appeal-target", "endentscheidung");
|
||||
}
|
||||
}
|
||||
@@ -653,11 +747,11 @@ function showSideRadioCluster() {
|
||||
// already chosen and we never overwrite. When we do prefill, write the
|
||||
// derived side to the URL so reload + back/forward round-trip cleanly.
|
||||
function applySidePrefill(os: ProjectOurSide["our_side"] | undefined) {
|
||||
if (readSideFromURL() !== null) return;
|
||||
if (parseSideFromSearch(window.location.search) !== null) return;
|
||||
const next = ourSideToSide(os);
|
||||
if (next === null) return;
|
||||
currentSide = next;
|
||||
writeSideToURL(next);
|
||||
applyURLFilters({ side: next });
|
||||
syncRadioGroup("side", next);
|
||||
sidePrefilledFromProject = true;
|
||||
renderSideChip(next);
|
||||
@@ -726,11 +820,9 @@ function initViewToggle() {
|
||||
// /api/tools/fristenrechner round-trip — perspective is a pure
|
||||
// projection of the last response, no backend involved.
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppellant = readAppellantFromURL();
|
||||
currentAppealTarget = readAppealTargetFromURL();
|
||||
currentSide = parseSideFromSearch(window.location.search);
|
||||
currentAppealTarget = parseAppealTargetFromSearch(window.location.search);
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
|
||||
syncSideHintVisibility();
|
||||
|
||||
@@ -739,22 +831,12 @@ function initPerspectiveControls() {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
applyURLFilters({ side: currentSide });
|
||||
syncSideHintVisibility();
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeAppellantToURL(currentAppellant);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
// 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.
|
||||
@@ -767,7 +849,7 @@ function initPerspectiveControls() {
|
||||
} else {
|
||||
currentAppealTarget = "";
|
||||
}
|
||||
writeAppealTargetToURL(currentAppealTarget);
|
||||
applyURLFilters({ target: currentAppealTarget });
|
||||
scheduleCalc(0);
|
||||
});
|
||||
});
|
||||
@@ -789,28 +871,57 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener("change", () => scheduleCalc());
|
||||
dateInput.addEventListener("input", () => scheduleCalc());
|
||||
// Hydrate trigger_date from URL on first paint so a refresh /
|
||||
// shared link reproduces the same dated timeline. URL wins over
|
||||
// the verfahrensablauf.tsx today-default that the <input> renders
|
||||
// with. parseTriggerDateFromSearch validates the shape so a
|
||||
// malformed link silently falls back to the today-default.
|
||||
const urlDate = parseTriggerDateFromSearch(window.location.search);
|
||||
if (urlDate) dateInput.value = urlDate;
|
||||
const persistDate = () => {
|
||||
applyURLFilters({ triggerDate: dateInput.value });
|
||||
};
|
||||
dateInput.addEventListener("change", () => { persistDate(); scheduleCalc(); });
|
||||
dateInput.addEventListener("input", () => { persistDate(); scheduleCalc(); });
|
||||
dateInput.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
|
||||
if ((e as KeyboardEvent).key === "Enter") { persistDate(); scheduleCalc(0); }
|
||||
});
|
||||
}
|
||||
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => {
|
||||
writeCourtId(scenarioStorage, courtPicker.value);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
|
||||
// Flag-checkbox listeners — each flip triggers a fresh calc so the
|
||||
// timeline re-projects with the new gating. ccr-flag additionally
|
||||
// enables/disables the nested inf-amend row.
|
||||
// enables/disables the nested inf-amend row. Each flip also writes
|
||||
// through to localStorage so the choice survives a reload (URL stays
|
||||
// clean; flags are scenario state, not filter chips — t-paliad-308).
|
||||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
if (ccrFlag) ccrFlag.addEventListener("change", () => {
|
||||
writeBoolFlag(scenarioStorage, SCENARIO_KEYS.ccr, ccrFlag.checked);
|
||||
syncInfAmendEnabled();
|
||||
// Disabling ccr also unchecks inf-amend (see syncInfAmendEnabled).
|
||||
// Mirror that into storage so the next reload doesn't repopulate a
|
||||
// disabled checkbox as checked.
|
||||
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||||
if (infAmend) writeBoolFlag(scenarioStorage, SCENARIO_KEYS.infAmend, infAmend.checked);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
|
||||
const flagStorageKeys: Record<string, string> = {
|
||||
"inf-amend-flag": SCENARIO_KEYS.infAmend,
|
||||
"rev-amend-flag": SCENARIO_KEYS.revAmend,
|
||||
"rev-cci-flag": SCENARIO_KEYS.revCci,
|
||||
};
|
||||
for (const [id, storageKey] of Object.entries(flagStorageKeys)) {
|
||||
const cb = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
|
||||
});
|
||||
if (cb) cb.addEventListener("change", () => {
|
||||
writeBoolFlag(scenarioStorage, storageKey, cb.checked);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
@@ -841,16 +952,30 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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. Hydrated from localStorage at
|
||||
// module load (showHidden); each flip writes back to localStorage
|
||||
// and triggers a 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);
|
||||
writeBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden, showHidden);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
@@ -858,11 +983,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
// t-paliad-265 — per-event-card choices. Unbound surface, so commits
|
||||
// mutate the in-memory list + URL, then trigger a recalc. The
|
||||
// popover module owns the popover lifecycle; this page owns the
|
||||
// recalc + URL plumbing.
|
||||
perCardChoices = readChoicesFromURL();
|
||||
// t-paliad-265 — per-event-card choices. Unbound surface; persistence
|
||||
// is localStorage-only (t-paliad-308) so a shared link doesn't carry
|
||||
// the recipient's per-card tweaks. The popover module owns the
|
||||
// popover lifecycle; this page owns the recalc + storage plumbing.
|
||||
const timelineEl = document.getElementById("timeline-container");
|
||||
if (timelineEl) {
|
||||
attachEventCardChoices({
|
||||
@@ -873,14 +997,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
(c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind),
|
||||
);
|
||||
perCardChoices.push(choice);
|
||||
writeChoicesToURL(perCardChoices);
|
||||
writeEventChoices(scenarioStorage, perCardChoices);
|
||||
scheduleCalc(0);
|
||||
},
|
||||
remove: (submissionCode, kind) => {
|
||||
perCardChoices = perCardChoices.filter(
|
||||
(c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
|
||||
);
|
||||
writeChoicesToURL(perCardChoices);
|
||||
writeEventChoices(scenarioStorage, perCardChoices);
|
||||
scheduleCalc(0);
|
||||
},
|
||||
});
|
||||
@@ -916,8 +1040,31 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
syncTriggerEventLabel();
|
||||
});
|
||||
|
||||
// Pre-select the first proceeding tile so users see a timeline
|
||||
// immediately on landing — matches /tools/fristenrechner behaviour.
|
||||
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
||||
if (firstBtn) selectProceeding(firstBtn);
|
||||
// Pre-select the proceeding tile. URL wins: if ?proceeding= is set
|
||||
// and points at a known tile, that tile is selected without rewriting
|
||||
// the URL. Otherwise fall back to the first tile so users see a
|
||||
// timeline immediately on landing — matches /tools/fristenrechner
|
||||
// behaviour. The auto-pick does NOT write the URL so the default
|
||||
// landing stays clean (`?proceeding=` only appears once the user
|
||||
// makes an explicit choice). (t-paliad-308 / m/paliad#137)
|
||||
const urlProceeding = parseProceedingFromSearch(window.location.search);
|
||||
let initialBtn: HTMLButtonElement | null = null;
|
||||
let urlHit = false;
|
||||
if (urlProceeding) {
|
||||
initialBtn = document.querySelector<HTMLButtonElement>(
|
||||
`.proceeding-btn[data-code="${urlProceeding.replace(/"/g, '\\"')}"]`,
|
||||
);
|
||||
urlHit = initialBtn !== null;
|
||||
}
|
||||
if (!initialBtn) {
|
||||
initialBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
||||
}
|
||||
if (initialBtn) {
|
||||
// writeURL=false when the URL either already carries this code
|
||||
// (no churn) or has no proceeding (auto-default → don't pollute
|
||||
// the clean URL). Only an unknown / stale ?proceeding= triggers
|
||||
// a rewrite so the URL converges on the resolved tile.
|
||||
const writeURL = urlProceeding !== "" && !urlHit;
|
||||
selectProceeding(initialBtn, { writeURL });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
formatDurationLabel,
|
||||
renderColumnsBody,
|
||||
stripLeadingDurationFromNotes,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
@@ -327,6 +329,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([
|
||||
@@ -464,3 +489,287 @@ describe("renderColumnsBody — side-aware column header labels (m/paliad#127)",
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 1 — appeal-aware column routing.
|
||||
// All appeal rules carry party='both' (either side could be the
|
||||
// appellant). With appealAware=true + dl.appealRole set, the bucketer
|
||||
// routes by (filer matches user) instead of collapsing every 'both'
|
||||
// row into the user's column. Without a side picked, the bucketer
|
||||
// keeps the legacy mirror so every appeal rule is visible.
|
||||
describe("bucketDeadlinesIntoColumns — appeal-aware routing (t-paliad-307)", () => {
|
||||
const appeal = (
|
||||
name: string,
|
||||
role: "appellant" | "appellee",
|
||||
due: string,
|
||||
): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
appealRole: role,
|
||||
});
|
||||
|
||||
const notice = appeal("Berufungseinlegung", "appellant", "2026-07-26");
|
||||
const grounds = appeal("Berufungsbegründung", "appellant", "2026-09-26");
|
||||
const response = appeal("Berufungserwiderung", "appellee", "2026-12-26");
|
||||
|
||||
test("appealAware + side=claimant: appellant rules → ours, appellee rules → opponent", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, grounds, response], {
|
||||
side: "claimant",
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.opponent).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.ours).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
});
|
||||
|
||||
test("appealAware + side=defendant: appellant rules → opponent, appellee rules → ours", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], {
|
||||
side: "defendant",
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.ours).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
expect(byKey.get(response.dueDate)?.opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("appealAware + side=null: mirror to both columns (every rule visible)", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], {
|
||||
side: null,
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
});
|
||||
|
||||
test("appealAware off: appealRole is ignored and legacy bucketing applies", () => {
|
||||
// Regression guard: a stale frontend that drops `appealAware: true`
|
||||
// must not silently route via appealRole — the side selector
|
||||
// would visibly change behaviour without a UI control to opt in.
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], { side: "defendant" });
|
||||
// Legacy "side without appellant" collapse → both rows into ours.
|
||||
const allOurs = rows.flatMap((r) => r.ours.map((d) => d.name));
|
||||
expect(allOurs).toEqual(["Berufungseinlegung", "Berufungserwiderung"]);
|
||||
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
|
||||
});
|
||||
|
||||
test("appealAware respects court party — court rows always route to court column", () => {
|
||||
const decision: CalculatedDeadline = {
|
||||
...notice,
|
||||
name: "Entscheidung",
|
||||
party: "court",
|
||||
appealRole: "", // court events deliberately stay empty
|
||||
dueDate: "",
|
||||
};
|
||||
const rows = bucketDeadlinesIntoColumns([decision], { side: "claimant", appealAware: true });
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["Entscheidung"]);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("appealAware + rule without appealRole falls back to legacy bucketing", () => {
|
||||
// A future appeal rule we forgot to map: appealRole='' falls
|
||||
// through the appealAware branch and lands in the legacy
|
||||
// side-collapse path → ours.
|
||||
const unmapped: CalculatedDeadline = { ...notice, appealRole: "" };
|
||||
const rows = bucketDeadlinesIntoColumns([unmapped], { side: "claimant", appealAware: true });
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 3 — duration label appends the
|
||||
// parent rule name (or the proceeding's trigger event label for
|
||||
// root rules) so the chip reads "4 Monate nach Endentscheidung"
|
||||
// instead of the dangling "4 Monate nach".
|
||||
describe("formatDurationLabel — appends parent name (t-paliad-307)", () => {
|
||||
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
|
||||
code: "x",
|
||||
name: "x",
|
||||
nameEN: "x",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "",
|
||||
originalDate: "",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 4,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("with parent label: appends to head", () => {
|
||||
expect(formatDurationLabel(dl(), "Endentscheidung (R.118)"))
|
||||
.toBe("4 Monate nach Endentscheidung (R.118)");
|
||||
});
|
||||
|
||||
test("without parent label: bare head — caller decides whether to render", () => {
|
||||
expect(formatDurationLabel(dl())).toBe("4 Monate nach");
|
||||
});
|
||||
|
||||
test("without timing: parent is not appended (degenerate phrasing)", () => {
|
||||
// No timing == we can't form "4 Monate <timing> <parent>" cleanly,
|
||||
// so the bare "4 Monate" head stays. Pinned to catch a future
|
||||
// edit that would emit "4 Monate Endentscheidung" without a
|
||||
// preposition.
|
||||
expect(formatDurationLabel(dl({ timing: "" }), "Endentscheidung")).toBe("4 Monate");
|
||||
});
|
||||
|
||||
test("singular value: switches to .one unit key", () => {
|
||||
expect(formatDurationLabel(dl({ durationValue: 1 }), "X")).toBe("1 Monat nach X");
|
||||
});
|
||||
|
||||
test("zero / missing duration: empty string", () => {
|
||||
expect(formatDurationLabel(dl({ durationValue: 0 }), "X")).toBe("");
|
||||
expect(formatDurationLabel(dl({ durationValue: 0, durationUnit: "" }), "X")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deadlineCardHtml — duration tooltip reads parent name (t-paliad-307)", () => {
|
||||
test("root rule with non-zero duration uses opts.triggerEventLabel as parent fallback", () => {
|
||||
// upc.apl.merits.notice has no parent_id but a 2-month duration
|
||||
// off the trigger event (the appealed decision). The duration
|
||||
// tooltip must read the appeal-target label, not just "2 Monate
|
||||
// nach".
|
||||
const dl: CalculatedDeadline = {
|
||||
code: "upc.apl.merits.notice",
|
||||
name: "Berufungseinlegung",
|
||||
nameEN: "Notice of Appeal",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-07-26",
|
||||
originalDate: "2026-07-26",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 2,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
};
|
||||
const html = deadlineCardHtml(dl, {
|
||||
showParty: false,
|
||||
editable: true,
|
||||
triggerEventLabel: "Endentscheidung (R.118)",
|
||||
});
|
||||
expect(html).toContain("title=\"2 Monate nach Endentscheidung (R.118)\"");
|
||||
});
|
||||
|
||||
test("non-root rule prefers parent rule name over triggerEventLabel", () => {
|
||||
// merits.response chains off merits.grounds; the duration label
|
||||
// should read "3 Monate nach Berufungsbegründung", not the
|
||||
// appeal-target fallback.
|
||||
const dl: CalculatedDeadline = {
|
||||
code: "upc.apl.merits.response",
|
||||
name: "Berufungserwiderung",
|
||||
nameEN: "Response to Appeal",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-12-26",
|
||||
originalDate: "2026-12-26",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 3,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
parentRuleCode: "upc.apl.merits.grounds",
|
||||
parentRuleName: "Berufungsbegründung",
|
||||
parentRuleNameEN: "Statement of Grounds",
|
||||
};
|
||||
const html = deadlineCardHtml(dl, {
|
||||
showParty: false,
|
||||
editable: true,
|
||||
triggerEventLabel: "Endentscheidung (R.118)",
|
||||
});
|
||||
expect(html).toContain("title=\"3 Monate nach Berufungsbegründung\"");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 4 — leading "Frist N <unit> …"
|
||||
// substring is stripped before deadline_notes renders so the new
|
||||
// duration affordance and the legacy free-text don't duplicate.
|
||||
describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", () => {
|
||||
test("DE: strips 'Frist 1 Monat VOR …. ' and keeps the rest", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung.",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("Antrag auf Simultanübersetzung.");
|
||||
});
|
||||
|
||||
test("DE: strips 'Frist 15 Tage ab …' when the whole notes is the duration prose", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist 15 Tage ab Zustellung der Kostenentscheidung",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
test("DE: strips 'Frist beträgt 2 Monate ab …. ' (Wiedereinsetzung variant)", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens 1 Jahr.",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("Spätestens 1 Jahr.");
|
||||
});
|
||||
|
||||
test("DE: composite 'Frist N … ODER M …' is preserved (option b follow-up)", () => {
|
||||
const composite =
|
||||
"Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme.";
|
||||
expect(stripLeadingDurationFromNotes(composite, "de")).toBe(composite);
|
||||
});
|
||||
|
||||
test("DE: 'Frist vom Gericht' (no number) is preserved", () => {
|
||||
const out = stripLeadingDurationFromNotes("Frist vom Gericht bestimmt", "de");
|
||||
expect(out).toBe("Frist vom Gericht bestimmt");
|
||||
});
|
||||
|
||||
test("EN: strips '1 month BEFORE …. ' and keeps the rest", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation.",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("Request for simultaneous interpretation.");
|
||||
});
|
||||
|
||||
test("EN: strips '15-day period from …'", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"15-day period from service of the cost decision",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
test("EN: strips 'Period is N <unit> from …'", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest 12 months.",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("Latest 12 months.");
|
||||
});
|
||||
|
||||
test("EN: empty / non-matching notes pass through unchanged", () => {
|
||||
expect(stripLeadingDurationFromNotes("", "en")).toBe("");
|
||||
expect(stripLeadingDurationFromNotes("Time limit set by the court", "en"))
|
||||
.toBe("Time limit set by the court");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,111 @@ export interface CalculatedDeadline {
|
||||
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;
|
||||
// appealRole carries the rule's appeal-filer identity when the
|
||||
// server computed the timeline under an appeal_target filter:
|
||||
// "appellant" (Berufungskläger files this rule), "appellee"
|
||||
// (Berufungsbeklagter files this rule), or empty for court events
|
||||
// and non-appeal timelines. The column bucketer reads this in
|
||||
// preference to primary_party='both' so a user-perspective `?side=`
|
||||
// pick can split appeal filings into the user's column vs the
|
||||
// opponent's, instead of routing every "both" rule into the
|
||||
// user's column. (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealRole?: "appellant" | "appellee" | "";
|
||||
// isTriggerEvent marks the synthetic row the engine prepends to the
|
||||
// timeline when computing an appeal: a court-set decision dated to
|
||||
// the trigger date with the per-appeal-target label
|
||||
// (Endentscheidung / Kostenentscheidung / Anordnung / …). The row
|
||||
// carries no real rule_id — it's a UI marker so the timeline reads
|
||||
// decision → appeal filings → next decision. (t-paliad-307 /
|
||||
// m/paliad#136 Bug 2)
|
||||
isTriggerEvent?: boolean;
|
||||
}
|
||||
|
||||
// stripLeadingDurationFromNotes drops the leading
|
||||
// "Frist N <unit> <preposition> <subject>." (DE) /
|
||||
// "N <unit> <preposition> <subject>." (EN) prefix from a rule's
|
||||
// deadline_notes so it doesn't duplicate the new duration affordance
|
||||
// added in m/paliad#133 (t-paliad-307 Bug 4).
|
||||
//
|
||||
// The duration affordance now renders the same prose as a badge on
|
||||
// the card ("4 Monate nach Endentscheidung (R.118)"); a free-text
|
||||
// notes string that opens with the same prose reads as a verbatim
|
||||
// duplicate. Only the leading-prefix shape is stripped — anything
|
||||
// after the first sentence is preserved (the editorial commentary
|
||||
// the lawyers actually want to read).
|
||||
//
|
||||
// Conservative: composite-duration prefaces with "ODER" /
|
||||
// "whichever is the longer" don't match and stay untouched — those
|
||||
// are the follow-up editorial cleanup (option b in the issue brief).
|
||||
//
|
||||
// Examples:
|
||||
// "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag …"
|
||||
// → "Antrag …"
|
||||
// "Frist 15 Tage ab Zustellung der Kostenentscheidung"
|
||||
// → ""
|
||||
// "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens …"
|
||||
// → "Spätestens …"
|
||||
// "1-month period from service of the main decision"
|
||||
// → ""
|
||||
// "1 month BEFORE the oral hearing (R.109.1). Request for …"
|
||||
// → "Request for …"
|
||||
// "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest …"
|
||||
// → "Latest …"
|
||||
// "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung …"
|
||||
// → unchanged (composite — option b follow-up)
|
||||
export function stripLeadingDurationFromNotes(notes: string, lang: "de" | "en"): string {
|
||||
if (!notes) return notes;
|
||||
// Terminator `(?:\.\s+|$)` matches the FIRST sentence boundary
|
||||
// (period followed by whitespace) OR end of input. Embedded dots
|
||||
// inside parenthesised citations (R.109.1, § 123(2), Rule 136(1))
|
||||
// are skipped because the char right after them isn't whitespace.
|
||||
// `[^]*?` is the JS-portable form of `.*?` with the dotAll flag —
|
||||
// any character including newlines, non-greedy.
|
||||
const re = lang === "en"
|
||||
? /^(?:Period\s+is\s+)?\d+(?:[-\s]\S+)?\s+(?:\S+\s+)?(?:before|from|after|since)\b[^]*?(?:\.\s+|$)/i
|
||||
: /^Frist\s+(?:beträgt\s+)?\d+\s+\S+\s+(?:VOR|vor|nach|ab|seit)\b[^]*?(?:\.\s+|$)/;
|
||||
return notes.replace(re, "");
|
||||
}
|
||||
|
||||
// formatDurationLabel renders the per-rule duration label for the
|
||||
// Verfahrensablauf card affordance: "2 Monate nach Endentscheidung",
|
||||
// "1 Monat vor Mündlicher Verhandlung", …
|
||||
// (m/paliad#133, t-paliad-302; parent-name append: t-paliad-307 /
|
||||
// m/paliad#136 Bug 3).
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// `parentLabel` is the rule's anchor name (parent rule's name when
|
||||
// the rule has a parent_id; otherwise the proceeding's
|
||||
// triggerEventLabel from the wire). Empty falls back to bare
|
||||
// "<n> <unit> <timing>" — bare phrasing is the pre-fix shape and
|
||||
// remains the default for fixtures / tests that omit a parent.
|
||||
export function formatDurationLabel(dl: CalculatedDeadline, parentLabel: string = ""): 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}`) : "";
|
||||
const head = timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
|
||||
if (!timingStr || !parentLabel) return head;
|
||||
return `${head} ${parentLabel}`;
|
||||
}
|
||||
|
||||
// priorityRendering returns the per-priority UX hints the save-modal
|
||||
@@ -321,15 +426,56 @@ 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;
|
||||
// triggerEventLabel: per-language label of the proceeding's anchor
|
||||
// event ("Endentscheidung (R.118)" for an Endentscheidung appeal;
|
||||
// "Klageerhebung" for upc.inf.cfi; …). Used by formatDurationLabel
|
||||
// as the parent-name fallback when a rule is a root rule (no
|
||||
// parent_id) but carries a non-zero duration — e.g. the
|
||||
// Berufungseinlegung 2 months after Endentscheidung. Pages pass the
|
||||
// already-language-resolved string. (t-paliad-307 / m/paliad#136
|
||||
// Bug 3)
|
||||
triggerEventLabel?: string;
|
||||
}
|
||||
|
||||
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" : "";
|
||||
// Parent name for the duration label (t-paliad-307 / m/paliad#136
|
||||
// Bug 3): use the rule's parent if set, else fall back to the
|
||||
// proceeding's trigger event label (e.g. "Endentscheidung (R.118)"
|
||||
// for an Endentscheidung appeal; "Klageerhebung" for upc.inf.cfi).
|
||||
// Empty for rules whose anchor isn't surface-able — the duration
|
||||
// label degrades to the bare "<n> <unit> <timing>" form in that case.
|
||||
const parentLabelForDuration = (getLang() === "en"
|
||||
? (dl.parentRuleNameEN || dl.parentRuleName)
|
||||
: (dl.parentRuleName || dl.parentRuleNameEN)) || opts.triggerEventLabel || "";
|
||||
// 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, parentLabelForDuration);
|
||||
// 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"))}"`
|
||||
: "";
|
||||
? ` 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
|
||||
@@ -425,7 +571,14 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
|
||||
}
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const rawNoteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
// Strip the leading-duration prefix so the new duration affordance
|
||||
// doesn't duplicate what the lawyer wrote verbatim into deadline_notes
|
||||
// for those legacy rule rows that still carry it.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 4)
|
||||
const noteText = rawNoteText
|
||||
? stripLeadingDurationFromNotes(rawNoteText, getLang() === "en" ? "en" : "de")
|
||||
: rawNoteText;
|
||||
const showNotes = opts.showNotes === true;
|
||||
const notesBlock = noteText && showNotes
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
@@ -434,9 +587,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>`
|
||||
@@ -545,7 +708,32 @@ export function wireDateEditClicks(
|
||||
});
|
||||
}
|
||||
|
||||
// pickTriggerEventLabel returns the per-language trigger event label
|
||||
// from a DeadlineResponse, used as the parent-fallback for root-rule
|
||||
// duration labels. Mirrors the precedence the page-level
|
||||
// triggerEventLabelFor uses (curated server label > proceedingName
|
||||
// fallback). Distinct from the page helper in that it stays language-
|
||||
// scoped to the current getLang() — root-rule duration labels render
|
||||
// in the user's current language. (t-paliad-307 / m/paliad#136 Bug 3)
|
||||
export function pickTriggerEventLabel(data: DeadlineResponse): string {
|
||||
const lang = getLang();
|
||||
const curated = lang === "en"
|
||||
? (data.triggerEventLabelEN || data.triggerEventLabel || "")
|
||||
: (data.triggerEventLabel || data.triggerEventLabelEN || "");
|
||||
if (curated) return curated;
|
||||
return lang === "en"
|
||||
? (data.proceedingNameEN || data.proceedingName || "")
|
||||
: (data.proceedingName || data.proceedingNameEN || "");
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
// Resolve the trigger event label once so the duration affordance on
|
||||
// root rules (no parent) can read it as the anchor fallback. Caller-
|
||||
// provided value wins (lets the page override for sub-track flows).
|
||||
const cardOpts: CardOpts = {
|
||||
...opts,
|
||||
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
|
||||
};
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
const itemClasses = [
|
||||
@@ -567,7 +755,7 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
${deadlineCardHtml(dl, opts)}
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -614,6 +802,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).
|
||||
@@ -623,6 +814,15 @@ export interface ColumnsBodyOpts {
|
||||
// (no mirror). Default null = mirror "both" into both cells
|
||||
// (legacy behaviour). Independent of `side`.
|
||||
appellant?: Side;
|
||||
// appealAware: forwarded to bucketDeadlinesIntoColumns when the
|
||||
// page is rendering an appeal_target-filtered timeline. Routes
|
||||
// each rule to its filer-perspective column via dl.appealRole
|
||||
// instead of the legacy primary_party='both' collapse.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealAware?: boolean;
|
||||
// triggerEventLabel: forwarded to deadlineCardHtml — see CardOpts.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 3)
|
||||
triggerEventLabel?: string;
|
||||
}
|
||||
|
||||
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
|
||||
@@ -638,6 +838,15 @@ export interface ColumnsRow {
|
||||
export interface BucketingOpts {
|
||||
side?: Side;
|
||||
appellant?: Side;
|
||||
// appealAware: when true, rules carrying a `dl.appealRole` of
|
||||
// "appellant" / "appellee" route via the appeal role + user side
|
||||
// axis instead of the legacy primary_party='both' collapse. With
|
||||
// `side=null` the bucketer keeps the mirror semantic (both columns
|
||||
// render every appeal rule); with `side` set, "appellant" rules
|
||||
// land in the user's column when the user IS the appellant, in
|
||||
// the opponent's column otherwise — mirror for "appellee" rules.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealAware?: boolean;
|
||||
}
|
||||
|
||||
// bucketDeadlinesIntoColumns is the pure routing primitive that
|
||||
@@ -672,6 +881,8 @@ export function bucketDeadlinesIntoColumns(
|
||||
return r;
|
||||
};
|
||||
|
||||
const appealAware = opts.appealAware === true;
|
||||
|
||||
deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
@@ -694,11 +905,41 @@ export function bucketDeadlinesIntoColumns(
|
||||
if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
|
||||
const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
|
||||
row[perCardCol].push(dl);
|
||||
} else if (
|
||||
appealAware &&
|
||||
(dl.appealRole === "appellant" || dl.appealRole === "appellee")
|
||||
) {
|
||||
// Appeal-aware routing (t-paliad-307 / m/paliad#136 Bug 1).
|
||||
// With no side picked, mirror to both columns so every rule
|
||||
// is visible regardless of which side the user is on. With
|
||||
// a side picked, route by (filer matches user) → ours
|
||||
// column, else opponent column. side=claimant maps the
|
||||
// user to "appellant" (Berufungskläger); side=defendant
|
||||
// maps the user to "appellee" (Berufungsbeklagter).
|
||||
if (userSide === null) {
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
} else {
|
||||
const userIsAppellant = userSide === "claimant";
|
||||
const filerIsAppellant = dl.appealRole === "appellant";
|
||||
row[filerIsAppellant === userIsAppellant ? "ours" : "opponent"].push(dl);
|
||||
}
|
||||
} else if (appellantColumn !== null) {
|
||||
// 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);
|
||||
}
|
||||
@@ -721,15 +962,31 @@ export function bucketDeadlinesIntoColumns(
|
||||
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, {
|
||||
side: userSide,
|
||||
appellant: opts.appellant,
|
||||
appealAware: opts.appealAware,
|
||||
});
|
||||
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,
|
||||
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
|
||||
};
|
||||
|
||||
// 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) {
|
||||
|
||||
309
frontend/src/client/views/verfahrensablauf-state.test.ts
Normal file
309
frontend/src/client/views/verfahrensablauf-state.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage
|
||||
// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`.
|
||||
//
|
||||
// The contract:
|
||||
// 1. URL params (proceeding, side, target, trigger_date) define which
|
||||
// timeline kind the user is looking at — paste-able, shareable,
|
||||
// refresh-resistant.
|
||||
// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the
|
||||
// per-user scenario tweaks (event_choices, court_id, flags,
|
||||
// show_hidden) — these never leak into a shared link.
|
||||
// 3. On hydrate, URL wins. localStorage fills the rest.
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
APPEAL_TARGETS,
|
||||
SCENARIO_KEYS,
|
||||
SCENARIO_PREFIX,
|
||||
URL_KEYS,
|
||||
applyFiltersToSearch,
|
||||
hydrate,
|
||||
makeMemoryStorage,
|
||||
parseAppealTargetFromSearch,
|
||||
parseProceedingFromSearch,
|
||||
parseSideFromSearch,
|
||||
parseTriggerDateFromSearch,
|
||||
readBoolFlag,
|
||||
readCourtId,
|
||||
readEventChoices,
|
||||
readScenario,
|
||||
writeBoolFlag,
|
||||
writeCourtId,
|
||||
writeEventChoices,
|
||||
} from "./verfahrensablauf-state";
|
||||
|
||||
describe("URL parsers — filter chips", () => {
|
||||
test("parseProceedingFromSearch returns empty string when absent", () => {
|
||||
expect(parseProceedingFromSearch("")).toBe("");
|
||||
expect(parseProceedingFromSearch("?side=claimant")).toBe("");
|
||||
});
|
||||
|
||||
test("parseProceedingFromSearch echoes the raw value", () => {
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi");
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified");
|
||||
});
|
||||
|
||||
test("parseSideFromSearch validates the enum", () => {
|
||||
expect(parseSideFromSearch("?side=claimant")).toBe("claimant");
|
||||
expect(parseSideFromSearch("?side=defendant")).toBe("defendant");
|
||||
expect(parseSideFromSearch("?side=neither")).toBe(null);
|
||||
expect(parseSideFromSearch("")).toBe(null);
|
||||
});
|
||||
|
||||
test("parseAppealTargetFromSearch only accepts canonical slugs", () => {
|
||||
for (const t of APPEAL_TARGETS) {
|
||||
expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t);
|
||||
}
|
||||
expect(parseAppealTargetFromSearch("?target=unknown")).toBe("");
|
||||
expect(parseAppealTargetFromSearch("")).toBe("");
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch validates the ISO-date shape", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe("");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month
|
||||
expect(parseTriggerDateFromSearch("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL encoder — applyFiltersToSearch", () => {
|
||||
test("empty filters preserve the existing query string", () => {
|
||||
expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep");
|
||||
});
|
||||
|
||||
test("setting a filter writes the canonical key", () => {
|
||||
expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi");
|
||||
expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant");
|
||||
expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung");
|
||||
expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("setting null / empty / undefined deletes the key", () => {
|
||||
expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe("");
|
||||
expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe("");
|
||||
});
|
||||
|
||||
test("invalid trigger_date is deleted (never written as-is)", () => {
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe("");
|
||||
});
|
||||
|
||||
test("setting all four filters together emits all four keys", () => {
|
||||
const out = applyFiltersToSearch("", {
|
||||
proceeding: "upc.apl.unified",
|
||||
side: "defendant",
|
||||
target: "endentscheidung",
|
||||
triggerDate: "2026-05-26",
|
||||
});
|
||||
expect(out).toContain("proceeding=upc.apl.unified");
|
||||
expect(out).toContain("side=defendant");
|
||||
expect(out).toContain("target=endentscheidung");
|
||||
expect(out).toContain("trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("other params (project, view) are preserved", () => {
|
||||
const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" });
|
||||
expect(out).toContain("project=abc");
|
||||
expect(out).toContain("view=timeline");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
|
||||
test("absent keys in the filter object don't touch existing URL values", () => {
|
||||
// Only updating side — proceeding should be untouched.
|
||||
const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" });
|
||||
expect(out).toContain("proceeding=upc.inf.cfi");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL round-trip — encode then parse yields the same value", () => {
|
||||
test("proceeding", () => {
|
||||
const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" });
|
||||
expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi");
|
||||
});
|
||||
|
||||
test("side", () => {
|
||||
const enc = applyFiltersToSearch("", { side: "defendant" });
|
||||
expect(parseSideFromSearch(enc)).toBe("defendant");
|
||||
});
|
||||
|
||||
test("target", () => {
|
||||
const enc = applyFiltersToSearch("", { target: "kostenentscheidung" });
|
||||
expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung");
|
||||
});
|
||||
|
||||
test("trigger_date", () => {
|
||||
const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" });
|
||||
expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scenario localStorage helpers", () => {
|
||||
test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => {
|
||||
expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario");
|
||||
for (const key of Object.values(SCENARIO_KEYS)) {
|
||||
expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("readEventChoices returns [] on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readEventChoices(s)).toEqual([]);
|
||||
});
|
||||
|
||||
test("writeEventChoices + readEventChoices round-trip", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const choices = [
|
||||
{ submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" },
|
||||
{ submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" },
|
||||
];
|
||||
writeEventChoices(s, choices);
|
||||
expect(readEventChoices(s)).toEqual(choices);
|
||||
});
|
||||
|
||||
test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null);
|
||||
writeEventChoices(s, []);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null);
|
||||
});
|
||||
|
||||
test("readEventChoices ignores unknown choice_kind values", () => {
|
||||
const s = makeMemoryStorage();
|
||||
s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1");
|
||||
expect(readEventChoices(s)).toEqual([
|
||||
{ submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" },
|
||||
{ submission_code: "r3", choice_kind: "skip", choice_value: "1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readCourtId(s)).toBe("");
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(readCourtId(s)).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("writeCourtId('') removes the key", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC");
|
||||
writeCourtId(s, "");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null);
|
||||
});
|
||||
|
||||
test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, false);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null);
|
||||
});
|
||||
|
||||
test("readScenario returns all fields defaulted on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readScenario(s)).toEqual({
|
||||
eventChoices: [],
|
||||
courtId: "",
|
||||
ccr: false,
|
||||
infAmend: false,
|
||||
revAmend: false,
|
||||
revCci: false,
|
||||
showHidden: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hydration order — URL wins, localStorage fills the rest", () => {
|
||||
test("URL fills filter chips, localStorage fills scenario state", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.showHidden, true);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26",
|
||||
s,
|
||||
);
|
||||
// URL-sourced
|
||||
expect(out.proceeding).toBe("upc.inf.cfi");
|
||||
expect(out.side).toBe("defendant");
|
||||
expect(out.target).toBe("endentscheidung");
|
||||
expect(out.triggerDate).toBe("2026-05-26");
|
||||
// localStorage-sourced
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
expect(out.showHidden).toBe(true);
|
||||
expect(out.ccr).toBe(true);
|
||||
});
|
||||
|
||||
test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
const out = hydrate("", s);
|
||||
expect(out.proceeding).toBe("");
|
||||
expect(out.side).toBe(null);
|
||||
expect(out.target).toBe("");
|
||||
expect(out.triggerDate).toBe("");
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("absent localStorage → URL still fills filter chips, scenario defaults", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01",
|
||||
s,
|
||||
);
|
||||
expect(out.proceeding).toBe("upc.apl.unified");
|
||||
expect(out.side).toBe("claimant");
|
||||
expect(out.target).toBe("anordnung");
|
||||
expect(out.triggerDate).toBe("2026-07-01");
|
||||
expect(out.courtId).toBe("");
|
||||
expect(out.eventChoices).toEqual([]);
|
||||
expect(out.showHidden).toBe(false);
|
||||
});
|
||||
|
||||
test("a shared link doesn't leak the recipient's scenario state in", () => {
|
||||
// Two storages: m's (loaded with court + flags) and a recipient's
|
||||
// (empty). The same URL should reproduce filter chips identically
|
||||
// but leave each user's scenario state untouched.
|
||||
const mStorage = makeMemoryStorage();
|
||||
writeCourtId(mStorage, "UPC-LD-MUC");
|
||||
writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true);
|
||||
const recipientStorage = makeMemoryStorage();
|
||||
|
||||
const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26";
|
||||
|
||||
const mView = hydrate(sharedURL, mStorage);
|
||||
const recipientView = hydrate(sharedURL, recipientStorage);
|
||||
|
||||
// Filter chips identical
|
||||
expect(mView.proceeding).toBe(recipientView.proceeding);
|
||||
expect(mView.side).toBe(recipientView.side);
|
||||
expect(mView.triggerDate).toBe(recipientView.triggerDate);
|
||||
|
||||
// Scenario state diverges — recipient sees defaults
|
||||
expect(mView.courtId).toBe("UPC-LD-MUC");
|
||||
expect(recipientView.courtId).toBe("");
|
||||
expect(mView.ccr).toBe(true);
|
||||
expect(recipientView.ccr).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL key constants match the documented contract", () => {
|
||||
test("URL_KEYS uses the spec'd snake_case names", () => {
|
||||
expect(URL_KEYS.proceeding).toBe("proceeding");
|
||||
expect(URL_KEYS.side).toBe("side");
|
||||
expect(URL_KEYS.target).toBe("target");
|
||||
expect(URL_KEYS.triggerDate).toBe("trigger_date");
|
||||
});
|
||||
});
|
||||
263
frontend/src/client/views/verfahrensablauf-state.ts
Normal file
263
frontend/src/client/views/verfahrensablauf-state.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// /tools/verfahrensablauf URL + scenario-localStorage state contract
|
||||
// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into
|
||||
// two namespaces:
|
||||
//
|
||||
// URL params (filter chips — the timeline kind the user is looking
|
||||
// at; paste-able, shareable, refresh-resistant):
|
||||
// proceeding, side, target, trigger_date
|
||||
//
|
||||
// localStorage `paliad.verfahrensablauf.scenario.*` (per-user
|
||||
// scenario inputs — the noisy parts that don't belong in a URL):
|
||||
// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
|
||||
// show_hidden
|
||||
//
|
||||
// Hydration order: URL wins. On page load, URL fills the filter chips;
|
||||
// localStorage fills the rest. Filter-chip changes write to URL only.
|
||||
// Scenario changes write to localStorage only. A shared link from a
|
||||
// colleague reproduces the timeline kind (proceeding + side + target +
|
||||
// trigger_date) but never leaks the recipient's court / flag /
|
||||
// event_choices state in.
|
||||
//
|
||||
// All helpers in this module are pure: they take a search string (or a
|
||||
// StorageLike) and return values, no DOM. The wiring in
|
||||
// ../verfahrensablauf.ts mounts them onto window.location +
|
||||
// window.localStorage at runtime.
|
||||
|
||||
import type { EventChoice, ChoiceKind } from "./event-card-choices";
|
||||
|
||||
// ----- URL params (filter chips) ----------------------------------
|
||||
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
export const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
export type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
export const URL_KEYS = {
|
||||
proceeding: "proceeding",
|
||||
side: "side",
|
||||
target: "target",
|
||||
triggerDate: "trigger_date",
|
||||
} as const;
|
||||
|
||||
// parseProceedingFromSearch extracts the proceeding code. Returns ""
|
||||
// if absent. No validation against the proceeding registry — that's
|
||||
// the caller's job (an unknown code from a stale link should leave
|
||||
// the first-tile auto-select fallback running).
|
||||
export function parseProceedingFromSearch(search: string): string {
|
||||
const v = new URLSearchParams(search).get(URL_KEYS.proceeding);
|
||||
return v ?? "";
|
||||
}
|
||||
|
||||
export function parseSideFromSearch(search: string): Side {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.side);
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
export function parseAppealTargetFromSearch(search: string): AppealTarget {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.target) || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// parseTriggerDateFromSearch validates the ISO-date shape so a
|
||||
// malformed link can't poison the date input. Accepts "YYYY-MM-DD"
|
||||
// only. Round-tripped against Date to reject 2026-02-30 etc.
|
||||
export function parseTriggerDateFromSearch(search: string): string {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || "";
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "";
|
||||
const d = new Date(raw + "T00:00:00Z");
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
if (d.toISOString().slice(0, 10) !== raw) return "";
|
||||
return raw;
|
||||
}
|
||||
|
||||
// applyFiltersToSearch produces the canonical query string for the
|
||||
// four URL-owned params. Other params (e.g. ?view=, ?project=) are
|
||||
// preserved verbatim. Empty values are deleted, never written as
|
||||
// empty string, so the URL stays clean on the default.
|
||||
export function applyFiltersToSearch(
|
||||
search: string,
|
||||
filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams(search);
|
||||
if ("proceeding" in filters) {
|
||||
if (filters.proceeding && filters.proceeding !== "") {
|
||||
params.set(URL_KEYS.proceeding, filters.proceeding);
|
||||
} else {
|
||||
params.delete(URL_KEYS.proceeding);
|
||||
}
|
||||
}
|
||||
if ("side" in filters) {
|
||||
if (filters.side === "claimant" || filters.side === "defendant") {
|
||||
params.set(URL_KEYS.side, filters.side);
|
||||
} else {
|
||||
params.delete(URL_KEYS.side);
|
||||
}
|
||||
}
|
||||
if ("target" in filters) {
|
||||
if (filters.target && filters.target !== "") {
|
||||
params.set(URL_KEYS.target, filters.target);
|
||||
} else {
|
||||
params.delete(URL_KEYS.target);
|
||||
}
|
||||
}
|
||||
if ("triggerDate" in filters) {
|
||||
if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) {
|
||||
params.set(URL_KEYS.triggerDate, filters.triggerDate);
|
||||
} else {
|
||||
params.delete(URL_KEYS.triggerDate);
|
||||
}
|
||||
}
|
||||
const s = params.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
// ----- localStorage (scenario state) ------------------------------
|
||||
|
||||
export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario";
|
||||
export const SCENARIO_KEYS = {
|
||||
eventChoices: `${SCENARIO_PREFIX}.event_choices`,
|
||||
courtId: `${SCENARIO_PREFIX}.court_id`,
|
||||
ccr: `${SCENARIO_PREFIX}.ccr`,
|
||||
infAmend: `${SCENARIO_PREFIX}.inf_amend`,
|
||||
revAmend: `${SCENARIO_PREFIX}.rev_amend`,
|
||||
revCci: `${SCENARIO_PREFIX}.rev_cci`,
|
||||
showHidden: `${SCENARIO_PREFIX}.show_hidden`,
|
||||
} as const;
|
||||
|
||||
// StorageLike is the tiny subset of the Web Storage API the scenario
|
||||
// helpers actually use. Lets the tests pass a Map-backed fake without
|
||||
// pulling in a full localStorage polyfill.
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
// readEventChoices is forgiving: malformed tuples or unknown
|
||||
// choice_kinds are dropped silently. Same shape as the legacy URL
|
||||
// codec (comma-separated `submission_code:kind=value`).
|
||||
export function readEventChoices(storage: StorageLike): EventChoice[] {
|
||||
const raw = storage.getItem(SCENARIO_KEYS.eventChoices);
|
||||
if (!raw) return [];
|
||||
const out: EventChoice[] = [];
|
||||
for (const tuple of raw.split(",")) {
|
||||
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const kind = m[2] as ChoiceKind;
|
||||
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
||||
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void {
|
||||
if (choices.length === 0) {
|
||||
storage.removeItem(SCENARIO_KEYS.eventChoices);
|
||||
return;
|
||||
}
|
||||
const enc = choices
|
||||
.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`)
|
||||
.join(",");
|
||||
storage.setItem(SCENARIO_KEYS.eventChoices, enc);
|
||||
}
|
||||
|
||||
// readCourtId / writeCourtId — empty string == no court picked. The
|
||||
// "" value is stored as a removed key, not an empty string entry, so
|
||||
// reading it back yields null rather than "".
|
||||
export function readCourtId(storage: StorageLike): string {
|
||||
return storage.getItem(SCENARIO_KEYS.courtId) ?? "";
|
||||
}
|
||||
|
||||
export function writeCourtId(storage: StorageLike, courtId: string): void {
|
||||
if (courtId === "") {
|
||||
storage.removeItem(SCENARIO_KEYS.courtId);
|
||||
return;
|
||||
}
|
||||
storage.setItem(SCENARIO_KEYS.courtId, courtId);
|
||||
}
|
||||
|
||||
// Boolean flags — "1" / "0" string encoding, removeItem on default
|
||||
// (false for flags, also false for show_hidden) so the storage stays
|
||||
// uncluttered on a fresh page.
|
||||
export function readBoolFlag(storage: StorageLike, key: string): boolean {
|
||||
return storage.getItem(key) === "1";
|
||||
}
|
||||
|
||||
export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void {
|
||||
if (on) storage.setItem(key, "1");
|
||||
else storage.removeItem(key);
|
||||
}
|
||||
|
||||
// Read all scenario state in one call — convenience for the page's
|
||||
// load-time hydration. Caller decides whether to apply each field
|
||||
// (e.g. court_id is proceeding-specific; the page may discard the
|
||||
// stored value if the active proceeding doesn't expose a court row).
|
||||
export interface ScenarioState {
|
||||
eventChoices: EventChoice[];
|
||||
courtId: string;
|
||||
ccr: boolean;
|
||||
infAmend: boolean;
|
||||
revAmend: boolean;
|
||||
revCci: boolean;
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export function readScenario(storage: StorageLike): ScenarioState {
|
||||
return {
|
||||
eventChoices: readEventChoices(storage),
|
||||
courtId: readCourtId(storage),
|
||||
ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr),
|
||||
infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend),
|
||||
revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend),
|
||||
revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci),
|
||||
showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden),
|
||||
};
|
||||
}
|
||||
|
||||
// ----- URL → localStorage hydration order -------------------------
|
||||
|
||||
// The page's load-time contract: read URL filters, then read
|
||||
// scenario state from localStorage. URL wins on conflict — but the
|
||||
// only field that can conflict is none of them today (URL owns
|
||||
// proceeding/side/target/trigger_date; localStorage owns the rest).
|
||||
// The order matters for one edge case: if a future field migrates
|
||||
// from URL → localStorage with overlap, the URL value MUST be honored.
|
||||
|
||||
export interface HydratedState extends ScenarioState {
|
||||
proceeding: string;
|
||||
side: Side;
|
||||
target: AppealTarget;
|
||||
triggerDate: string;
|
||||
}
|
||||
|
||||
export function hydrate(search: string, storage: StorageLike): HydratedState {
|
||||
const scenario = readScenario(storage);
|
||||
return {
|
||||
proceeding: parseProceedingFromSearch(search),
|
||||
side: parseSideFromSearch(search),
|
||||
target: parseAppealTargetFromSearch(search),
|
||||
triggerDate: parseTriggerDateFromSearch(search),
|
||||
...scenario,
|
||||
};
|
||||
}
|
||||
|
||||
// makeMemoryStorage — tiny StorageLike for tests / SSR fallback.
|
||||
// Not used by the runtime page (which mounts real localStorage), but
|
||||
// kept here so test files have one well-known import.
|
||||
export function makeMemoryStorage(): StorageLike {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k, v) => { store.set(k, v); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export function Footer(): string {
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<p>
|
||||
<span data-i18n="footer.text">{"© 2026 Paliad — ein Werkzeug von"}</span>{" "}
|
||||
<span data-i18n="footer.text">{"© 2026 Paliad — by"}</span>{" "}
|
||||
<a href="https://flexsiebels.de" target="_blank" rel="noopener">flexsiebels.de</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1190,10 +1190,6 @@ export type I18nKey =
|
||||
| "deadlines.appeal_target.kostenentscheidung"
|
||||
| "deadlines.appeal_target.label"
|
||||
| "deadlines.appeal_target.schadensbemessung"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
| "deadlines.appellant.none"
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.card.calc.add_to_project"
|
||||
| "deadlines.card.calc.add_to_project.disabled"
|
||||
@@ -1268,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"
|
||||
@@ -2618,6 +2615,8 @@ export type I18nKey =
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.base.hint"
|
||||
| "submissions.draft.base.label"
|
||||
| "submissions.draft.import.button"
|
||||
| "submissions.draft.language"
|
||||
| "submissions.draft.language.de"
|
||||
@@ -2630,6 +2629,8 @@ export type I18nKey =
|
||||
| "submissions.draft.parties.title"
|
||||
| "submissions.draft.preview.hint"
|
||||
| "submissions.draft.preview.title"
|
||||
| "submissions.draft.sections.hint"
|
||||
| "submissions.draft.sections.title"
|
||||
| "submissions.draft.switcher.label"
|
||||
| "submissions.draft.title"
|
||||
| "submissions.index.action.new"
|
||||
|
||||
@@ -3750,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);
|
||||
@@ -6114,6 +6124,120 @@ dialog.modal::backdrop {
|
||||
/* t-paliad-276 — DE/EN language toggle on the draft editor. Same look
|
||||
as the rest of the sidebar mini-controls; muted label + inline radios
|
||||
so it doesn't compete with the editor's primary inputs. */
|
||||
/* t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list. */
|
||||
.submission-draft-base-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-base-row label {
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.submission-draft-base-row select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.submission-draft-base-hint {
|
||||
margin: 0;
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-sections-wrap {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
}
|
||||
|
||||
.submission-draft-sections-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.submission-draft-sections-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.submission-draft-sections-hint {
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-sections-list {
|
||||
list-style: decimal inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.submission-draft-section {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: var(--color-bg-elev-2, var(--color-bg));
|
||||
}
|
||||
|
||||
.submission-draft-section--excluded {
|
||||
opacity: 0.55;
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
}
|
||||
|
||||
.submission-draft-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.submission-draft-section-title {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.submission-draft-section-kind {
|
||||
font-size: 0.75em;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.submission-draft-section-excluded-badge {
|
||||
font-size: 0.75em;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.submission-draft-section-body {
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 0.88em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.submission-draft-language-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6220,7 +6344,7 @@ dialog.modal::backdrop {
|
||||
align-items: baseline;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-alt, #fafafa);
|
||||
background: var(--color-surface-2);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -6378,7 +6502,7 @@ dialog.modal::backdrop {
|
||||
}
|
||||
|
||||
.submissions-new-chip:hover {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.submissions-new-chip--active {
|
||||
@@ -6416,7 +6540,7 @@ dialog.modal::backdrop {
|
||||
}
|
||||
|
||||
.submissions-new-project-item:hover {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.submissions-new-project-title {
|
||||
@@ -6431,7 +6555,7 @@ dialog.modal::backdrop {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0 0 1.25rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-accent, #c6f41c);
|
||||
border-radius: 6px;
|
||||
@@ -6454,7 +6578,7 @@ dialog.modal::backdrop {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
@@ -6582,7 +6706,7 @@ dialog.modal::backdrop {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
@@ -6705,7 +6829,7 @@ dialog.modal::backdrop {
|
||||
margin-left: 0.3rem;
|
||||
padding: 0 0.4em;
|
||||
border-radius: 3px;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -7912,7 +8036,7 @@ dialog.modal::backdrop {
|
||||
.collab-invite-hint {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-surface-alt, var(--color-bg-lime-tint));
|
||||
background: var(--color-bg-lime-tint);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
@@ -16572,7 +16696,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
@@ -16626,7 +16750,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -16648,7 +16772,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
}
|
||||
|
||||
.smart-timeline-kind-chip--projected {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -16715,7 +16839,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
|
||||
.smart-timeline-add-choice:hover:not(:disabled) {
|
||||
border-color: var(--color-accent-fg);
|
||||
background: var(--color-surface-alt, #fafafa);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.smart-timeline-add-choice--primary {
|
||||
|
||||
@@ -109,6 +109,27 @@ export function renderSubmissionDraft(): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
|
||||
base picker. Hydrated by client/submission-draft.ts
|
||||
once /api/submission-bases returns. Disabled
|
||||
for pre-Composer drafts (base_id NULL); switching
|
||||
autosaves the draft. */}
|
||||
<div
|
||||
className="submission-draft-base-row"
|
||||
id="submission-draft-base-row"
|
||||
style="display:none">
|
||||
<label htmlFor="submission-draft-base" data-i18n="submissions.draft.base.label">
|
||||
Vorlagenbasis
|
||||
</label>
|
||||
<select id="submission-draft-base" />
|
||||
<p
|
||||
className="submission-draft-base-hint"
|
||||
id="submission-draft-base-hint"
|
||||
data-i18n="submissions.draft.base.hint">
|
||||
Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-276 — output language toggle (DE/EN).
|
||||
Hydrated by client/submission-draft.ts; switching
|
||||
autosaves the draft and re-renders the preview. */}
|
||||
@@ -202,6 +223,29 @@ export function renderSubmissionDraft(): string {
|
||||
<div className="submission-draft-variables" id="submission-draft-variables" />
|
||||
</aside>
|
||||
|
||||
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
|
||||
read-only section list. Painted from
|
||||
view.sections. Empty/hidden for pre-Composer
|
||||
drafts where no rows have been seeded. Slice B
|
||||
turns these into in-place editable prose blocks. */}
|
||||
<section
|
||||
className="submission-draft-sections-wrap"
|
||||
id="submission-draft-sections-wrap"
|
||||
style="display:none">
|
||||
<header className="submission-draft-sections-header">
|
||||
<h2 data-i18n="submissions.draft.sections.title">Abschnitte</h2>
|
||||
<span
|
||||
className="submission-draft-sections-hint"
|
||||
data-i18n="submissions.draft.sections.hint">
|
||||
Read-only Vorschau — editierbar in Slice B.
|
||||
</span>
|
||||
</header>
|
||||
<ol
|
||||
className="submission-draft-sections-list"
|
||||
id="submission-draft-sections-list"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Preview pane — read-only HTML render of the merged
|
||||
document body. Re-renders on autosave round-trip. */}
|
||||
<section className="submission-draft-preview-wrap">
|
||||
|
||||
@@ -250,23 +250,6 @@ export function renderVerfahrensablauf(): string {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
|
||||
Re-surfaces optional cards the user has previously
|
||||
marked "Überspringen" via the per-card popover.
|
||||
@@ -358,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)
|
||||
}
|
||||
@@ -24,8 +24,7 @@ SELECT set_config(
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = true,
|
||||
updated_at = now()
|
||||
SET is_active = true
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
@@ -214,8 +214,7 @@ UPDATE paliad.deadline_rules dr
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false,
|
||||
updated_at = now()
|
||||
SET is_active = false
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 139_deadline_rules_unified_view (down) — Slice B.3, t-paliad-305
|
||||
--
|
||||
-- Drops the view. The underlying paliad.sequencing_rules /
|
||||
-- procedural_events / legal_sources tables are untouched (they own the
|
||||
-- data — the view is just a projection).
|
||||
|
||||
DROP VIEW IF EXISTS paliad.deadline_rules_unified;
|
||||
122
internal/db/migrations/139_deadline_rules_unified_view.up.sql
Normal file
122
internal/db/migrations/139_deadline_rules_unified_view.up.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
-- 139_deadline_rules_unified_view — Slice B.3 read cutover (t-paliad-305 / m/paliad#93)
|
||||
--
|
||||
-- Creates paliad.deadline_rules_unified — a Postgres VIEW that
|
||||
-- re-projects paliad.sequencing_rules + paliad.procedural_events +
|
||||
-- paliad.legal_sources back into the legacy paliad.deadline_rules
|
||||
-- column shape.
|
||||
--
|
||||
-- Why a view instead of rewriting every SELECT in Go:
|
||||
--
|
||||
-- - 19 read sites across 11 service files reference
|
||||
-- paliad.deadline_rules. Rewriting each by hand multiplies the
|
||||
-- opportunity for off-by-one bugs in the JOIN.
|
||||
-- - The view has the same column names + types as the legacy table,
|
||||
-- so the change in Go is a 1-token substitution per query
|
||||
-- (FROM paliad.deadline_rules → FROM paliad.deadline_rules_unified)
|
||||
-- with no struct or scanner changes.
|
||||
-- - When B.4 drops paliad.deadline_rules, this view stays — it
|
||||
-- becomes the canonical legacy-shape reader for any code that
|
||||
-- hasn't been migrated to direct sr/pe/ls reads.
|
||||
--
|
||||
-- Column mapping (per design §4.2):
|
||||
-- - id, proceeding_type_id, parent_id, primary_party, duration_*,
|
||||
-- timing, sequence_order, is_spawn/court_set/bilateral, priority,
|
||||
-- rule_code, rule_codes, deadline_notes(_en), condition_expr,
|
||||
-- choices_offered, applies_to_target, trigger_event_id,
|
||||
-- spawn_proceeding_type_id, anchor_alt, alt_duration_*,
|
||||
-- alt_rule_code, combine_op, lifecycle_state, draft_of,
|
||||
-- published_at, is_active, created_at, updated_at, spawn_label
|
||||
-- → from paliad.sequencing_rules
|
||||
-- - submission_code → procedural_events.code
|
||||
-- - name, name_en, description→ procedural_events
|
||||
-- - event_type → procedural_events.event_kind (renamed)
|
||||
-- - concept_id → procedural_events
|
||||
-- - legal_source → legal_sources.citation (via legal_source_id FK)
|
||||
--
|
||||
-- The view is READ-ONLY by default. Writes still go to the underlying
|
||||
-- tables — RuleEditorService is refactored in the same slice to write
|
||||
-- directly to sr/pe/ls. paliad.deadline_rules is FROZEN from B.3 onward
|
||||
-- (no new writes); the dual-write helper from B.2 is decommissioned.
|
||||
|
||||
-- The CHECK constraint on sequencing_rules.primary_party doesn't exist
|
||||
-- yet (mig 135 only constrained deadline_rules.primary_party). The view
|
||||
-- inherits whatever value sr.primary_party carries; mig 136's backfill
|
||||
-- set sr.primary_party = dr.primary_party so the canonical four-value
|
||||
-- vocab is already in place. A later slice can add the same CHECK to
|
||||
-- sequencing_rules itself.
|
||||
|
||||
CREATE OR REPLACE VIEW paliad.deadline_rules_unified AS
|
||||
SELECT
|
||||
sr.id,
|
||||
sr.proceeding_type_id,
|
||||
sr.parent_id,
|
||||
pe.code AS submission_code,
|
||||
pe.name,
|
||||
pe.name_en,
|
||||
pe.description,
|
||||
sr.primary_party,
|
||||
pe.event_kind AS event_type,
|
||||
sr.duration_value,
|
||||
sr.duration_unit,
|
||||
sr.timing,
|
||||
sr.alt_duration_value,
|
||||
sr.alt_duration_unit,
|
||||
sr.alt_rule_code,
|
||||
sr.anchor_alt,
|
||||
sr.combine_op,
|
||||
sr.rule_code,
|
||||
sr.deadline_notes,
|
||||
sr.deadline_notes_en,
|
||||
sr.sequence_order,
|
||||
sr.is_spawn,
|
||||
sr.spawn_label,
|
||||
sr.spawn_proceeding_type_id,
|
||||
sr.is_bilateral,
|
||||
sr.is_court_set,
|
||||
sr.priority,
|
||||
sr.condition_expr,
|
||||
pe.concept_id,
|
||||
ls.citation AS legal_source,
|
||||
sr.trigger_event_id,
|
||||
sr.rule_codes,
|
||||
sr.choices_offered,
|
||||
sr.applies_to_target,
|
||||
sr.lifecycle_state,
|
||||
sr.draft_of,
|
||||
sr.published_at,
|
||||
sr.is_active,
|
||||
sr.created_at,
|
||||
sr.updated_at
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id;
|
||||
|
||||
COMMENT ON VIEW paliad.deadline_rules_unified IS
|
||||
'Slice B.3 (mig 139, t-paliad-305): legacy-shape projection over '
|
||||
'sequencing_rules + procedural_events + legal_sources. Read-only — '
|
||||
'writes go directly to the three underlying tables via '
|
||||
'RuleEditorService. Survives B.4 destructive drop of '
|
||||
'paliad.deadline_rules; the view will then be the only '
|
||||
'legacy-shape reader.';
|
||||
|
||||
-- Post-apply integrity check: confirm the view's row count matches the
|
||||
-- live sequencing_rules row count. A mismatch would indicate either a
|
||||
-- mid-deploy race (rare) or a JOIN issue (the LEFT JOIN to legal_sources
|
||||
-- never drops rows, the INNER JOIN to procedural_events drops sr rows
|
||||
-- whose procedural_event_id is NULL — but that column is NOT NULL on
|
||||
-- the table so it can't happen). Belt-and-braces.
|
||||
DO $$
|
||||
DECLARE
|
||||
v_view_count int;
|
||||
v_sr_count int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
|
||||
SELECT COUNT(*) INTO v_sr_count FROM paliad.sequencing_rules;
|
||||
IF v_view_count <> v_sr_count THEN
|
||||
RAISE EXCEPTION '[mig 139] FAILED POST: view row count % does not match sequencing_rules row count %. '
|
||||
'Possible cause: a sequencing_rules row references a procedural_event_id that does not exist (NOT NULL FK should prevent this).',
|
||||
v_view_count, v_sr_count;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 139] view OK — deadline_rules_unified rows = % (= sequencing_rules)',
|
||||
v_view_count;
|
||||
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 $$;
|
||||
3
internal/db/migrations/146_submission_bases.down.sql
Normal file
3
internal/db/migrations/146_submission_bases.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-313: revert submission_bases catalog.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_bases;
|
||||
173
internal/db/migrations/146_submission_bases.up.sql
Normal file
173
internal/db/migrations/146_submission_bases.up.sql
Normal file
@@ -0,0 +1,173 @@
|
||||
-- t-paliad-313 (m/paliad#141): Composer Slice A — submission base catalog.
|
||||
--
|
||||
-- paliad.submission_bases is a thin pointer table — each row maps a
|
||||
-- short, stable slug ("hlc-letterhead", "neutral", …) onto a Gitea path
|
||||
-- that holds the actual .docx body, plus a JSON section-spec describing
|
||||
-- the base's default section set, stylemap, and per-section seed
|
||||
-- Markdown. The .docx in Gitea stays the source of truth for the
|
||||
-- chrome, fonts, paragraph styles, and (in later slices) the
|
||||
-- {{#section:KEY}} anchors. The DB row carries the listable metadata
|
||||
-- the picker needs.
|
||||
--
|
||||
-- Visibility: every authenticated user SELECTs (the catalog is shared
|
||||
-- firm-wide). Mutations are admin-only and enforced in Go at the
|
||||
-- handler layer — RLS only gates reads.
|
||||
--
|
||||
-- Slice A seeds two rows:
|
||||
-- 1. hlc-letterhead — points at the existing HLC firm skeleton
|
||||
-- (_firm-skeleton.docx with HL Patents Style typography).
|
||||
-- 2. neutral — points at the universal _skeleton.docx.
|
||||
-- Specialist bases (lg-duesseldorf, upc-formal) land in Slice E with
|
||||
-- their own .docx authoring task.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_bases (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE,
|
||||
firm text,
|
||||
proceeding_family text,
|
||||
label_de text NOT NULL,
|
||||
label_en text NOT NULL,
|
||||
description_de text,
|
||||
description_en text,
|
||||
gitea_path text NOT NULL,
|
||||
section_spec jsonb NOT NULL,
|
||||
is_default_for text[] NOT NULL DEFAULT '{}'::text[],
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_bases_firm_family_idx
|
||||
ON paliad.submission_bases (firm, proceeding_family) WHERE is_active;
|
||||
|
||||
ALTER TABLE paliad.submission_bases ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS submission_bases_select ON paliad.submission_bases;
|
||||
CREATE POLICY submission_bases_select
|
||||
ON paliad.submission_bases FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- INSERT / UPDATE / DELETE intentionally absent — admin-only mutations
|
||||
-- happen via the handler layer with explicit role checks. No RLS path
|
||||
-- for mutations means RLS denies them by default.
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_bases_set_updated_at ON paliad.submission_bases;
|
||||
CREATE TRIGGER submission_bases_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_bases
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_bases IS
|
||||
't-paliad-313: Composer base catalog. One row per base template (HLC letterhead, neutral, …) pointing at a .docx in Gitea + a JSON section spec.';
|
||||
|
||||
-- Seed: HLC letterhead + neutral skeleton. The section_spec carries the
|
||||
-- 10 default sections (letterhead, caption, introduction, requests,
|
||||
-- facts, legal_argument, evidence, exhibits, closing, signature) with
|
||||
-- their kinds, default order, and bilingual labels. seed_md_de /
|
||||
-- seed_md_en are populated for the bag-driven sections (letterhead,
|
||||
-- caption, signature); the remaining sections seed empty.
|
||||
--
|
||||
-- exhibits.included=false by default (lawyer opts in when an attachment
|
||||
-- list applies). Every other section ships included=true.
|
||||
|
||||
INSERT INTO paliad.submission_bases
|
||||
(slug, firm, proceeding_family, label_de, label_en, description_de, description_en, gitea_path, section_spec, is_default_for)
|
||||
VALUES
|
||||
('hlc-letterhead', 'HLC', NULL,
|
||||
'HLC-Briefkopf', 'HLC letterhead',
|
||||
'Mit HL Patents Style — Firmen-Header, Schriftarten, Absatzformaten.',
|
||||
'With HL Patents Style — firm header, fonts, paragraph styles.',
|
||||
'6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx',
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'stylemap', jsonb_build_object(
|
||||
'paragraph', 'HLpat-Body-B0',
|
||||
'heading_1', 'HLpat-Heading-H1',
|
||||
'heading_2', 'HLpat-Heading-H2',
|
||||
'heading_3', 'HLpat-Heading-H3',
|
||||
'list_bullet', 'HLpat-Body-B0',
|
||||
'list_numbered', 'HLpat-Body-B0',
|
||||
'blockquote', 'HLpat-Body-B1'
|
||||
),
|
||||
'defaults', jsonb_build_array(
|
||||
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
|
||||
'included',true,
|
||||
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}, {{user.office}}',
|
||||
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}, {{user.office}}'),
|
||||
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
|
||||
'included',true,
|
||||
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}'),
|
||||
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
|
||||
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
|
||||
'included',true,
|
||||
'seed_md_de', E'Mit freundlichen Grüßen',
|
||||
'seed_md_en', E'Yours sincerely,'),
|
||||
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
|
||||
'included',true,
|
||||
'seed_md_de', E'{{user.display_name}}\n{{user.office}}',
|
||||
'seed_md_en', E'{{user.display_name}}\n{{user.office}}')
|
||||
)
|
||||
),
|
||||
'{}'::text[]
|
||||
),
|
||||
('neutral', NULL, NULL,
|
||||
'Neutraler Schriftsatz', 'Neutral skeleton',
|
||||
'Universelle Vorlage ohne firmenspezifisches Branding.',
|
||||
'Universal template with no firm-specific branding.',
|
||||
'6 - material/Templates/Word/Paliad/HLC/_skeleton.docx',
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'stylemap', jsonb_build_object(
|
||||
'paragraph', 'Normal',
|
||||
'heading_1', 'Heading 1',
|
||||
'heading_2', 'Heading 2',
|
||||
'heading_3', 'Heading 3',
|
||||
'list_bullet', 'Normal',
|
||||
'list_numbered', 'Normal',
|
||||
'blockquote', 'Quote'
|
||||
),
|
||||
'defaults', jsonb_build_array(
|
||||
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
|
||||
'included',true,
|
||||
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}',
|
||||
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}'),
|
||||
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
|
||||
'included',true,
|
||||
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}',
|
||||
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}'),
|
||||
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
|
||||
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
|
||||
'included',true,
|
||||
'seed_md_de', E'Mit freundlichen Grüßen',
|
||||
'seed_md_en', E'Yours sincerely,'),
|
||||
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
|
||||
'included',true,
|
||||
'seed_md_de', E'{{user.display_name}}',
|
||||
'seed_md_en', E'{{user.display_name}}')
|
||||
)
|
||||
),
|
||||
'{}'::text[]
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- t-paliad-313: revert Composer columns on submission_drafts.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS composer_meta,
|
||||
DROP COLUMN IF EXISTS base_id;
|
||||
31
internal/db/migrations/147_submission_drafts_composer.up.sql
Normal file
31
internal/db/migrations/147_submission_drafts_composer.up.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- t-paliad-313 (m/paliad#141): Composer Slice A — point submission_drafts at a base.
|
||||
--
|
||||
-- Two purely-additive columns on paliad.submission_drafts:
|
||||
--
|
||||
-- base_id uuid — FK to paliad.submission_bases. NULL on existing
|
||||
-- drafts (Slice A explicitly does NOT auto-upgrade pre-Composer
|
||||
-- rows — that's Slice C). NEW drafts created post-Composer get
|
||||
-- base_id seeded by SubmissionDraftService.Create from the firm
|
||||
-- default for the proceeding family. ON DELETE SET NULL keeps a
|
||||
-- draft renderable via the v1 fallback chain even if its base is
|
||||
-- removed; the lawyer picks a new base via the sidebar.
|
||||
--
|
||||
-- composer_meta jsonb — Composer-specific metadata. For Slice A this
|
||||
-- carries the seed-time section order so the editor paints without
|
||||
-- a join. Future slices may add hidden_sections, active_locale,
|
||||
-- etc.
|
||||
--
|
||||
-- No data backfill, no auto-upgrade — pre-Composer drafts keep base_id
|
||||
-- NULL and render via the existing v1 path. The Go side has the
|
||||
-- corresponding gate (base_id IS NULL OR no submission_sections rows →
|
||||
-- v1 path).
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN IF NOT EXISTS base_id uuid REFERENCES paliad.submission_bases(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS composer_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.base_id IS
|
||||
't-paliad-313: Composer base reference. NULL = pre-Composer draft, renders via v1 fallback chain. ON DELETE SET NULL.';
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.composer_meta IS
|
||||
't-paliad-313: Composer-side metadata (section_order, hidden_sections, …). jsonb, default {}.';
|
||||
3
internal/db/migrations/148_submission_sections.down.sql
Normal file
3
internal/db/migrations/148_submission_sections.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-313: revert submission_sections table.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_sections;
|
||||
116
internal/db/migrations/148_submission_sections.up.sql
Normal file
116
internal/db/migrations/148_submission_sections.up.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- t-paliad-313 (m/paliad#141): Composer Slice A — per-draft section rows.
|
||||
--
|
||||
-- paliad.submission_sections holds one row per (draft, section_key) for
|
||||
-- Composer-mode drafts. Slice A seeds rows on draft create from the
|
||||
-- base's section_spec.defaults; the editor renders them read-only. Slice
|
||||
-- B turns them editable, Slice F adds reorder/hide/add-custom.
|
||||
--
|
||||
-- kind values per the design (Q10 ratification — no *_auto kind):
|
||||
-- 'prose' — free Markdown content (default).
|
||||
-- 'requests' — Anträge-style content (editor may add auto-numbering
|
||||
-- later; Slice A treats identical to 'prose').
|
||||
-- 'evidence' — Beweisangebote (editor may prefix lines with
|
||||
-- 'Beweis: '; Slice A treats identical to 'prose').
|
||||
--
|
||||
-- Visibility flows through draft_id → submission_drafts → can_see_project
|
||||
-- + owner-scoped. RLS policies mirror the four-policy shape on
|
||||
-- submission_drafts so seeding from the Go service stays inside the
|
||||
-- same RLS envelope.
|
||||
--
|
||||
-- content_md_de + content_md_en both NOT NULL DEFAULT '' so neither
|
||||
-- side blocks the bilingual-by-construction render path. Empty content
|
||||
-- renders as the missing-content marker per the editor's contract.
|
||||
--
|
||||
-- Per the brief (head's instruction msg #2392) Slice A does NOT auto-
|
||||
-- upgrade the 11 pre-Composer drafts — those remain base_id=NULL with
|
||||
-- no section rows. The v1 fallback render path stays compiled in to
|
||||
-- keep them working.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_sections (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
draft_id uuid NOT NULL REFERENCES paliad.submission_drafts(id) ON DELETE CASCADE,
|
||||
section_key text NOT NULL,
|
||||
order_index int NOT NULL,
|
||||
kind text NOT NULL,
|
||||
label_de text NOT NULL,
|
||||
label_en text NOT NULL,
|
||||
included bool NOT NULL DEFAULT true,
|
||||
content_md_de text NOT NULL DEFAULT '',
|
||||
content_md_en text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_sections_kind_check
|
||||
CHECK (kind IN ('prose', 'requests', 'evidence')),
|
||||
CONSTRAINT submission_sections_unique_per_draft
|
||||
UNIQUE (draft_id, section_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_sections_draft_idx
|
||||
ON paliad.submission_sections (draft_id, order_index);
|
||||
|
||||
ALTER TABLE paliad.submission_sections ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_select ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_select
|
||||
ON paliad.submission_sections FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_insert ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_insert
|
||||
ON paliad.submission_sections FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_update ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_update
|
||||
ON paliad.submission_sections FOR UPDATE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_delete ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_delete
|
||||
ON paliad.submission_sections FOR DELETE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_sections_set_updated_at ON paliad.submission_sections;
|
||||
CREATE TRIGGER submission_sections_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_sections
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_sections IS
|
||||
't-paliad-313: per-draft Composer section rows. Slice A: seeded on draft create from base.section_spec.defaults, rendered read-only. Slice B: editable. RLS mirrors submission_drafts (owner-scoped + can_see_project).';
|
||||
@@ -116,10 +116,21 @@ type Services struct {
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||
SubmissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base catalog +
|
||||
// per-draft section rows. Both nil in DATABASE_URL-less deploys
|
||||
// (the Composer surfaces return 503 / hide the picker).
|
||||
SubmissionBase *services.BaseService
|
||||
SubmissionSection *services.SectionService
|
||||
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
|
||||
// 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
|
||||
@@ -182,8 +193,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
backup: svc.Backup,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
eventChoice: svc.EventChoice,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
submissionBase: svc.SubmissionBase,
|
||||
submissionSection: svc.SubmissionSection,
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +416,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base catalog for
|
||||
// the sidebar picker. Wide-open SELECT (any authenticated user);
|
||||
// admin mutations are not exposed yet (Slice C).
|
||||
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
|
||||
// t-paliad-277 / m/paliad#109 — refresh project-derived variables on
|
||||
// the draft. Strips overrides for project.* / parties.* / deadline.*
|
||||
// / procedural_event.* / rule.* prefixes and bumps last_imported_at.
|
||||
@@ -446,6 +464,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)
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -360,6 +361,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
convID = ev.ConversationID
|
||||
case services.StreamError:
|
||||
errorEmitted = true
|
||||
log.Printf("paliadin: stream error turn=%s code=%s retryable=%v message=%q",
|
||||
turnID, ev.Code, ev.Retryable, ev.Message)
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
@@ -372,6 +375,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
case <-silenceTicker.C:
|
||||
elapsed := time.Since(lastEventAt)
|
||||
if elapsed >= silenceTimeout {
|
||||
log.Printf("paliadin: silence timeout turn=%s elapsed=%s (silenceTimeout=%s)",
|
||||
turnID, elapsed, silenceTimeout)
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
@@ -419,6 +424,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
}
|
||||
}
|
||||
if res.err != nil {
|
||||
log.Printf("paliadin: backend returned error turn=%s err=%v errorEmittedAlready=%v",
|
||||
turnID, res.err, errorEmitted)
|
||||
if !errorEmitted {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
@@ -432,6 +439,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
}
|
||||
result := res.result
|
||||
if result == nil {
|
||||
log.Printf("paliadin: backend returned nil result without error turn=%s errorEmittedAlready=%v",
|
||||
turnID, errorEmitted)
|
||||
// Shouldn't happen — backend contract returns either err
|
||||
// or a result. Defensive bail.
|
||||
if !errorEmitted {
|
||||
|
||||
@@ -69,8 +69,15 @@ type dbServices struct {
|
||||
// t-paliad-238 — submission draft editor.
|
||||
submissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-313 — Composer base catalog + per-draft sections.
|
||||
submissionBase *services.BaseService
|
||||
submissionSection *services.SectionService
|
||||
|
||||
// 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
|
||||
|
||||
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)
|
||||
}
|
||||
96
internal/handlers/submission_bases.go
Normal file
96
internal/handlers/submission_bases.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package handlers
|
||||
|
||||
// Submission base catalog handler — Composer Slice A (t-paliad-313,
|
||||
// m/paliad#141, design doc docs/design-submission-generator-v2-2026-05-26.md
|
||||
// §5.1 / Slice A acceptance).
|
||||
//
|
||||
// Endpoint: GET /api/submission-bases → list of active bases visible
|
||||
// to the requesting firm. The sidebar picker on the draft editor reads
|
||||
// this once on page load and caches in-memory; the response shape is
|
||||
// stable across the picker's lifetime.
|
||||
//
|
||||
// Visibility: the catalog is shared firm-wide (per the design + mig
|
||||
// 146's wide-open RLS SELECT policy). The handler still requires
|
||||
// authentication; anonymous users 401.
|
||||
//
|
||||
// Filtering: the response includes the firm's own bases AND the
|
||||
// firm-agnostic ones (firm IS NULL). The Go service-side filter passes
|
||||
// branding.Name as the firm hint; cross-firm cases (e.g. a future
|
||||
// non-HLC deployment) get their own filtered slice naturally.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// submissionBaseRow is the on-the-wire shape returned by the list
|
||||
// endpoint. Mirrors services.SubmissionBase but drops the raw bytes
|
||||
// and exposes the parsed section spec inline so the picker can show a
|
||||
// preview of the default section count without an extra round-trip.
|
||||
type submissionBaseRow struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Firm *string `json:"firm,omitempty"`
|
||||
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
DescriptionDE *string `json:"description_de,omitempty"`
|
||||
DescriptionEN *string `json:"description_en,omitempty"`
|
||||
GiteaPath string `json:"gitea_path"`
|
||||
IsDefaultFor []string `json:"is_default_for"`
|
||||
IsActive bool `json:"is_active"`
|
||||
SectionCount int `json:"section_count"`
|
||||
}
|
||||
|
||||
type submissionBaseListResponse struct {
|
||||
Bases []submissionBaseRow `json:"bases"`
|
||||
}
|
||||
|
||||
// handleListSubmissionBases backs GET /api/submission-bases.
|
||||
func handleListSubmissionBases(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBase == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "submission bases not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := dbSvc.submissionBase.List(r.Context(), branding.Name)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]submissionBaseRow, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, baseRowFromService(&rows[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, submissionBaseListResponse{Bases: out})
|
||||
}
|
||||
|
||||
// baseRowFromService projects a services.SubmissionBase into the
|
||||
// on-the-wire row shape.
|
||||
func baseRowFromService(b *services.SubmissionBase) submissionBaseRow {
|
||||
return submissionBaseRow{
|
||||
ID: b.ID.String(),
|
||||
Slug: b.Slug,
|
||||
Firm: b.Firm,
|
||||
ProceedingFamily: b.ProceedingFamily,
|
||||
LabelDE: b.LabelDE,
|
||||
LabelEN: b.LabelEN,
|
||||
DescriptionDE: b.DescriptionDE,
|
||||
DescriptionEN: b.DescriptionEN,
|
||||
GiteaPath: b.GiteaPath,
|
||||
IsDefaultFor: b.IsDefaultFor,
|
||||
IsActive: b.IsActive,
|
||||
SectionCount: len(b.SectionSpec.Defaults),
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,11 @@ type submissionDraftView struct {
|
||||
// so the frontend can render the multi-select picker in one round-
|
||||
// trip. Empty when the draft has no project attached.
|
||||
AvailableParties []submissionDraftPartyJSON `json:"available_parties"`
|
||||
// Sections is the per-draft section stack (t-paliad-313 Slice A).
|
||||
// Slice A renders these read-only; the lawyer sees what the
|
||||
// Composer seeded but can't yet edit prose. nil for pre-Composer
|
||||
// drafts (base_id NULL, no submission_sections rows).
|
||||
Sections []submissionSectionJSON `json:"sections"`
|
||||
}
|
||||
|
||||
// submissionDraftPartyJSON is the minimal party row the editor sidebar
|
||||
@@ -106,8 +111,30 @@ type submissionDraftJSON struct {
|
||||
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
|
||||
LastImportedAt *time.Time `json:"last_imported_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// BaseID — Composer base reference (t-paliad-313). NULL on
|
||||
// pre-Composer drafts; the editor sidebar surfaces this in the
|
||||
// base picker. PATCH accepts {"base_id": "<uuid>"} or
|
||||
// {"base_id": null} to set or clear.
|
||||
BaseID *uuid.UUID `json:"base_id"`
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// submissionSectionJSON is the on-the-wire row for each per-draft
|
||||
// section. Slice A renders these read-only — the lawyer sees the
|
||||
// section stack but doesn't yet edit prose. Slice B makes content_md_*
|
||||
// editable + adds the PATCH endpoint.
|
||||
type submissionSectionJSON struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SectionKey string `json:"section_key"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
}
|
||||
|
||||
type submissionRuleSummary struct {
|
||||
@@ -132,6 +159,41 @@ type submissionDraftPatchInput struct {
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
// BaseID accepts three states per the JSON contract:
|
||||
// field absent → no change (json:"-")
|
||||
// {"base_id": "<uuid>"} → set to picked base
|
||||
// {"base_id": null} → clear (return to v1 fallback)
|
||||
// We model this with a **uuid.UUID inside a custom UnmarshalJSON
|
||||
// in case extends; for now the simpler `*uuid.UUID` + presence
|
||||
// flag covers Slice A's set-base flow. Clearing is exposed but
|
||||
// rarely used (the editor always picks a base; clearing is for
|
||||
// admin-recovery flows).
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
BaseIDSet bool `json:"-"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
|
||||
// the "base_id" key appears in the payload (regardless of whether
|
||||
// the value is null or a uuid string). Lets the handler distinguish
|
||||
// "field absent" (no change) from "field set to null" (clear).
|
||||
func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
// Phase 1: decode into a raw map to detect key presence.
|
||||
raw := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
// Phase 2: decode the typed fields. Use an alias to skip this
|
||||
// custom UnmarshalJSON during the re-parse.
|
||||
type alias submissionDraftPatchInput
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
*p = submissionDraftPatchInput(a)
|
||||
if _, ok := raw["base_id"]; ok {
|
||||
p.BaseIDSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -372,6 +434,9 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
SelectedParties: input.SelectedParties,
|
||||
Language: input.Language,
|
||||
}
|
||||
if input.BaseIDSet {
|
||||
patch.BaseID = &input.BaseID
|
||||
}
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
@@ -713,6 +778,11 @@ type globalDraftPatchInput struct {
|
||||
// SelectedParties: present-but-empty array resets to "all parties",
|
||||
// present non-empty array restricts to subset, absent = no change.
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
// BaseID + baseIDProvided mirror the ProjectID pattern — present
|
||||
// (regardless of value) means "set"; absent means "no change". Set
|
||||
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
baseIDProvided bool
|
||||
}
|
||||
|
||||
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
@@ -722,6 +792,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
@@ -732,12 +803,15 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
g.Language = a.Language
|
||||
g.ProjectID = a.ProjectID
|
||||
g.SelectedParties = a.SelectedParties
|
||||
// Detect whether "project_id" was present in the JSON object.
|
||||
g.BaseID = a.BaseID
|
||||
// Detect whether "project_id" / "base_id" were present in the JSON
|
||||
// object.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
_, g.projectIDProvided = raw["project_id"]
|
||||
_, g.baseIDProvided = raw["base_id"]
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -778,6 +852,10 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
pid := in.ProjectID // may be nil → detach
|
||||
patch.ProjectID = &pid
|
||||
}
|
||||
if in.baseIDProvided {
|
||||
bid := in.BaseID // may be nil → clear
|
||||
patch.BaseID = &bid
|
||||
}
|
||||
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
@@ -952,6 +1030,30 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
Lang: lang,
|
||||
HasTemplate: true,
|
||||
AvailableParties: []submissionDraftPartyJSON{},
|
||||
Sections: []submissionSectionJSON{},
|
||||
}
|
||||
|
||||
// Composer Slice A — surface seeded sections (read-only). Empty
|
||||
// when the draft has no base + no section rows (pre-Composer
|
||||
// drafts that haven't been auto-upgraded — that's Slice C).
|
||||
if dbSvc.submissionSection != nil {
|
||||
secs, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, sec := range secs {
|
||||
view.Sections = append(view.Sections, submissionSectionJSON{
|
||||
ID: sec.ID,
|
||||
SectionKey: sec.SectionKey,
|
||||
OrderIndex: sec.OrderIndex,
|
||||
Kind: sec.Kind,
|
||||
LabelDE: sec.LabelDE,
|
||||
LabelEN: sec.LabelEN,
|
||||
Included: sec.Included,
|
||||
ContentMDDE: sec.ContentMDDE,
|
||||
ContentMDEN: sec.ContentMDEN,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
merged, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
|
||||
@@ -1135,6 +1237,10 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
meta := d.ComposerMeta
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
return submissionDraftJSON{
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
@@ -1147,6 +1253,8 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
BaseID: d.BaseID,
|
||||
ComposerMeta: meta,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name,
|
||||
pt.name_en AS proceeding_name_en
|
||||
FROM paliad.deadline_rules dr
|
||||
FROM paliad.deadline_rules_unified dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE dr.is_active = true
|
||||
AND dr.lifecycle_state = 'published'
|
||||
@@ -208,7 +208,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
|
||||
AND dr.submission_code IS NOT NULL
|
||||
AND dr.submission_code <> ''
|
||||
AND pt.is_active = true
|
||||
ORDER BY pt.code ASC, dr.submission_code ASC`)
|
||||
ORDER BY pt.code ASC, dr.sequence_order ASC, dr.submission_code ASC`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
99
internal/services/backup_service_live_test.go
Normal file
99
internal/services/backup_service_live_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// TestResolveOrgSheets_LiveSchemaSnapshot probes the live paliad schema
|
||||
// the way the backup runner does at the start of every run, then asserts
|
||||
// that every spec the registry declares either keeps all its ORDER BY
|
||||
// columns or — if any are missing — composes a fallback SELECT that the
|
||||
// DB can still execute. Catches the m/paliad#140 class of bug
|
||||
// (hardcoded ORDER BY against a renamed column) before deploy.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset. Read-only: opens a
|
||||
// REPEATABLE READ tx, never writes.
|
||||
func TestResolveOrgSheets_LiveSchemaSnapshot(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
specs := orgSheetSpecs()
|
||||
sheets, err := resolveOrgSheets(ctx, pool, specs)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveOrgSheets: %v", err)
|
||||
}
|
||||
if len(sheets) != len(specs) {
|
||||
t.Fatalf("resolved %d sheets, want %d", len(sheets), len(specs))
|
||||
}
|
||||
|
||||
// Each resolved SELECT must run cleanly against the live schema.
|
||||
// We LIMIT 1 inside a sub-SELECT so we don't materialise the full
|
||||
// table (some are large) but still exercise the ORDER BY clause.
|
||||
for _, sq := range sheets {
|
||||
wrapped := `SELECT * FROM (` + sq.SQL + `) _wrap LIMIT 1`
|
||||
if _, err := pool.QueryxContext(ctx, wrapped, sq.Args...); err != nil {
|
||||
t.Errorf("sheet %q SQL failed: %v\nSQL: %s", sq.SheetName, err, sq.SQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteOrg_LiveSmoke runs the full ExportService.WriteOrg pipeline
|
||||
// against a real DB: schema probe, REPEATABLE READ tx, every sheet
|
||||
// query, xlsx + json + per-sheet CSV assembly, outer zip framing.
|
||||
// Discards the bytes — this is a "does it crash" smoke, the bug class
|
||||
// it catches is exactly the one from m/paliad#140 (hardcoded ORDER BY
|
||||
// against a missing column).
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
func TestWriteOrg_LiveSmoke(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
svc := NewExportService(pool, "test-firm")
|
||||
var buf bytes.Buffer
|
||||
meta, err := svc.WriteOrg(context.Background(), &buf, ExportSpec{
|
||||
ActorID: uuid.New(),
|
||||
ActorEmail: "backup-smoke@test.local",
|
||||
ActorLabel: "Backup Smoke",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteOrg: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Fatalf("WriteOrg wrote no bytes")
|
||||
}
|
||||
// Spot-check meta fills.
|
||||
if meta.Scope != ExportScopeOrg {
|
||||
t.Errorf("meta.Scope = %q, want %q", meta.Scope, ExportScopeOrg)
|
||||
}
|
||||
if len(meta.RowCounts) != len(orgSheetSpecs()) {
|
||||
t.Errorf("meta.RowCounts has %d entries, want %d (one per sheet)", len(meta.RowCounts), len(orgSheetSpecs()))
|
||||
}
|
||||
// The bytes are a zip; the first 4 bytes are PK\x03\x04 for a non-empty zip.
|
||||
if buf.Len() >= 4 && !strings.HasPrefix(buf.String()[:4], "PK\x03\x04") {
|
||||
t.Errorf("bundle bytes don't look like a zip (first bytes: %x)", buf.Bytes()[:4])
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ package services
|
||||
// it would live in backup_service_live_test.go under TEST_DATABASE_URL.
|
||||
// This file covers the bits that don't need a database:
|
||||
//
|
||||
// - orgSheetQueries registry shape: no duplicates, no excluded
|
||||
// - orgSheetSpecs registry shape: no duplicates, no excluded
|
||||
// paliadin sheets, predictable prefix split between entity and ref.
|
||||
// - composeOrgSheetSQL drift-resistance: missing ORDER BY cols drop,
|
||||
// SQL override path bypasses the builder, all-missing → no clause.
|
||||
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
|
||||
// URI traversal rejection.
|
||||
|
||||
@@ -22,60 +24,216 @@ import (
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// orgSheetQueries registry
|
||||
// orgSheetSpecs registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
|
||||
func TestOrgSheetSpecs_NoDuplicates(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if seen[sq.SheetName] {
|
||||
t.Fatalf("duplicate sheet name in orgSheetQueries: %q", sq.SheetName)
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
if seen[sp.SheetName] {
|
||||
t.Fatalf("duplicate sheet name in orgSheetSpecs: %q", sp.SheetName)
|
||||
}
|
||||
seen[sq.SheetName] = true
|
||||
seen[sp.SheetName] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
|
||||
func TestOrgSheetSpecs_ExcludesPaliadinTables(t *testing.T) {
|
||||
// m's t-paliad-214 Q5 decision + this design's §11 Q3 default:
|
||||
// paliadin_turns and paliadin_aichat_conversation must be ABSENT
|
||||
// from the registry (structural exclusion, not just column-drop).
|
||||
for _, sq := range orgSheetQueries() {
|
||||
name := sq.SheetName
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
name := sp.SheetName
|
||||
if strings.Contains(name, "paliadin") {
|
||||
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
|
||||
t.Fatalf("orgSheetSpecs leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
|
||||
}
|
||||
// Belt-and-braces: SQL bodies should not reference the tables
|
||||
// either (no UNION joins, no subqueries pulling them in).
|
||||
if strings.Contains(sq.SQL, "paliadin_turns") || strings.Contains(sq.SQL, "paliadin_aichat_conversation") {
|
||||
t.Fatalf("orgSheetQueries[%q] SQL references a paliadin table: %s", name, sq.SQL)
|
||||
if strings.Contains(sp.Table, "paliadin") {
|
||||
t.Fatalf("orgSheetSpecs[%q].Table references a paliadin table: %s", name, sp.Table)
|
||||
}
|
||||
// Belt-and-braces: SQL override bodies (the few sheets that
|
||||
// bypass the Table+OrderBy builder) also can't pull paliadin
|
||||
// tables in through UNION/subquery.
|
||||
if strings.Contains(sp.SQL, "paliadin_turns") || strings.Contains(sp.SQL, "paliadin_aichat_conversation") {
|
||||
t.Fatalf("orgSheetSpecs[%q] SQL references a paliadin table: %s", name, sp.SQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
|
||||
func TestOrgSheetSpecs_RefSheetsPrefixed(t *testing.T) {
|
||||
// Every sheet whose data is read-only reference material is
|
||||
// expected to use the `ref__` prefix. The writer's downstream
|
||||
// consumers rely on this convention to group reference data
|
||||
// visually in the workbook.
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if !strings.HasPrefix(sq.SheetName, "ref__") {
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
if !strings.HasPrefix(sp.SheetName, "ref__") {
|
||||
continue
|
||||
}
|
||||
// Reference sheets shouldn't carry per-row WHERE clauses (they
|
||||
// dump the whole reference table for portability).
|
||||
if strings.Contains(strings.ToUpper(sq.SQL), "WHERE") {
|
||||
t.Fatalf("orgSheetQueries[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sq.SheetName)
|
||||
// dump the whole reference table for portability). Only
|
||||
// applies to the SQL-override path; the Table+OrderBy builder
|
||||
// never emits a WHERE.
|
||||
if sp.SQL != "" && strings.Contains(strings.ToUpper(sp.SQL), "WHERE") {
|
||||
t.Fatalf("orgSheetSpecs[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sp.SheetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_OrderByForDeterminism(t *testing.T) {
|
||||
// Every sheet must specify an ORDER BY so the byte-deterministic
|
||||
// contract from t-paliad-214 §3 holds across runs.
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if !strings.Contains(strings.ToUpper(sq.SQL), "ORDER BY") {
|
||||
t.Fatalf("orgSheetQueries[%q] missing ORDER BY (determinism contract): %s", sq.SheetName, sq.SQL)
|
||||
func TestOrgSheetSpecs_OrderByForDeterminism(t *testing.T) {
|
||||
// Every sheet must declare a stable sort: either OrderBy on the
|
||||
// Table+OrderBy path, or ORDER BY in the SQL override. Keeps the
|
||||
// byte-deterministic contract from t-paliad-214 §3 across runs.
|
||||
//
|
||||
// (Drift removes ORDER BY columns at runtime, but only ones that
|
||||
// no longer exist in the schema — the spec-level declaration is
|
||||
// still required so we know what *should* be ordered.)
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
if sp.SQL != "" {
|
||||
if !strings.Contains(strings.ToUpper(sp.SQL), "ORDER BY") {
|
||||
t.Fatalf("orgSheetSpecs[%q] SQL override missing ORDER BY (determinism contract): %s", sp.SheetName, sp.SQL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(sp.OrderBy) == 0 {
|
||||
t.Fatalf("orgSheetSpecs[%q] has no OrderBy and no SQL override (determinism contract)", sp.SheetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composeOrgSheetSQL — drift-resistant SQL builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestComposeOrgSheetSQL_AllColumnsPresent(t *testing.T) {
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "appointments",
|
||||
Table: "paliad.appointments",
|
||||
OrderBy: []string{"id"},
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"appointments": {"id": {}, "project_id": {}},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.appointments ORDER BY id"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 0 {
|
||||
t.Fatalf("expected no dropped columns, got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_DropsMissingOrderByColumn(t *testing.T) {
|
||||
// The original bug from m/paliad#138 reproduced in unit form:
|
||||
// orderBy references a column the table doesn't have.
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "appointment_caldav_targets",
|
||||
Table: "paliad.appointment_caldav_targets",
|
||||
OrderBy: []string{"appointment_id", "calendar_binding_id"}, // wrong: real col is binding_id
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"appointment_caldav_targets": {
|
||||
"appointment_id": {},
|
||||
"binding_id": {},
|
||||
},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 1 || dropped[0] != "calendar_binding_id" {
|
||||
t.Fatalf("expected dropped=[calendar_binding_id], got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_AllOrderByMissing_NoClause(t *testing.T) {
|
||||
// If every declared ORDER BY column is gone, the builder still
|
||||
// produces a runnable SELECT — without ORDER BY. The export
|
||||
// succeeds; the order across runs is no longer deterministic for
|
||||
// this sheet until the spec is updated. WARN log alerts the
|
||||
// operator (verified in TestResolveOrgSheets_LogsWarnings).
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "ghost",
|
||||
Table: "paliad.ghost",
|
||||
OrderBy: []string{"missing_a", "missing_b"},
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"ghost": {"unrelated": {}},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.ghost"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 2 {
|
||||
t.Fatalf("expected 2 dropped columns, got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_SQLOverride_BypassesBuilder(t *testing.T) {
|
||||
// When a sheet declares SQL, the builder MUST NOT touch it — even
|
||||
// if the column knowledge would suggest a change. Custom
|
||||
// projections (documents drops ai_extracted) and special-case
|
||||
// joins both rely on this.
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "documents",
|
||||
Table: "paliad.documents", // should be ignored
|
||||
OrderBy: []string{"id"}, // should be ignored
|
||||
SQL: "SELECT id, title FROM paliad.documents ORDER BY id",
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"documents": {}, // empty → would drop everything if builder ran
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
if got != spec.SQL {
|
||||
t.Fatalf("SQL override mutated: got %q, want %q", got, spec.SQL)
|
||||
}
|
||||
if len(dropped) != 0 {
|
||||
t.Fatalf("override path should never report drops; got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_UnknownTable_DropsAllOrderBy(t *testing.T) {
|
||||
// A table missing entirely from the schema snapshot is treated as
|
||||
// "no columns known" — every ORDER BY column gets dropped, but
|
||||
// the SELECT still emits (so a stale registry doesn't crash the
|
||||
// backup; the operator gets WARNs to fix it).
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "renamed_table",
|
||||
Table: "paliad.renamed_table",
|
||||
OrderBy: []string{"id"},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, map[string]map[string]struct{}{})
|
||||
want := "SELECT * FROM paliad.renamed_table"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 1 || dropped[0] != "id" {
|
||||
t.Fatalf("expected dropped=[id], got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_PreservesOrderByOrder(t *testing.T) {
|
||||
// Multi-column OrderBy must keep its declared order, with kept
|
||||
// columns concatenated in the same sequence. Determinism contract
|
||||
// from t-paliad-214 §3 depends on this.
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "partner_unit_members",
|
||||
Table: "paliad.partner_unit_members",
|
||||
OrderBy: []string{"partner_unit_id", "missing_middle", "user_id"},
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"partner_unit_members": {
|
||||
"partner_unit_id": {},
|
||||
"user_id": {},
|
||||
},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 1 || dropped[0] != "missing_middle" {
|
||||
t.Fatalf("expected dropped=[missing_middle], got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active,
|
||||
trigger_event_label_de, trigger_event_label_en,
|
||||
appeal_target`
|
||||
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
|
||||
@@ -53,13 +55,13 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) (
|
||||
if proceedingTypeID != nil {
|
||||
err = s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1 AND is_active = true
|
||||
ORDER BY sequence_order`, *proceedingTypeID)
|
||||
} else {
|
||||
err = s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE is_active = true
|
||||
ORDER BY proceeding_type_id, sequence_order`)
|
||||
}
|
||||
@@ -98,7 +100,7 @@ func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Contex
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT dr.id AS rule_id, j.event_type_id
|
||||
FROM paliad.deadline_rules dr
|
||||
FROM paliad.deadline_rules_unified dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
JOIN paliad.deadline_concept_event_types j
|
||||
ON j.concept_id = dr.concept_id
|
||||
@@ -150,7 +152,7 @@ func (s *DeadlineRuleService) GetRuleTree(ctx context.Context, proceedingTypeCod
|
||||
var rules []models.DeadlineRule
|
||||
if err := s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1 AND is_active = true
|
||||
ORDER BY sequence_order`, pt.ID); err != nil {
|
||||
return nil, fmt.Errorf("list rules for %q: %w", proceedingTypeCode, err)
|
||||
@@ -173,10 +175,10 @@ func (s *DeadlineRuleService) GetFullTimeline(ctx context.Context, proceedingTyp
|
||||
var rules []models.DeadlineRule
|
||||
err := s.db.SelectContext(ctx, &rules, `
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT * FROM paliad.deadline_rules
|
||||
SELECT * FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1 AND parent_id IS NULL AND is_active = true
|
||||
UNION ALL
|
||||
SELECT dr.* FROM paliad.deadline_rules dr
|
||||
SELECT dr.* FROM paliad.deadline_rules_unified dr
|
||||
JOIN tree t ON dr.parent_id = t.id
|
||||
WHERE dr.is_active = true
|
||||
)
|
||||
@@ -194,7 +196,7 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE id IN (?) AND is_active = true
|
||||
ORDER BY sequence_order`, ids)
|
||||
if err != nil {
|
||||
@@ -262,7 +264,7 @@ func (s *DeadlineRuleService) ListByTriggerEvent(ctx context.Context, triggerEve
|
||||
var rules []models.DeadlineRule
|
||||
if err := s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE trigger_event_id = $1
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order`, triggerEventID); err != nil {
|
||||
@@ -290,7 +292,7 @@ func (s *DeadlineRuleService) ListByProceedingTypeIDs(ctx context.Context, ids [
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id IN (?)
|
||||
AND is_active = true
|
||||
ORDER BY proceeding_type_id, sequence_order`, ids)
|
||||
@@ -325,7 +327,7 @@ func (s *DeadlineRuleService) ListByConcept(ctx context.Context, conceptID uuid.
|
||||
var rules []models.DeadlineRule
|
||||
if err := s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE concept_id = $1
|
||||
AND is_active = true
|
||||
ORDER BY proceeding_type_id NULLS LAST, sequence_order`, conceptID); err != nil {
|
||||
|
||||
@@ -272,7 +272,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
ar.requester_kind AS requester_kind
|
||||
FROM paliad.deadlines f
|
||||
JOIN paliad.projects p ON p.id = f.project_id
|
||||
LEFT JOIN paliad.deadline_rules r ON r.id = f.rule_id
|
||||
LEFT JOIN paliad.deadline_rules_unified r ON r.id = f.rule_id
|
||||
LEFT JOIN paliad.approval_requests ar ON ar.id = f.pending_request_id
|
||||
WHERE ` + strings.Join(conds, " AND ") + `
|
||||
ORDER BY f.due_date ASC, f.created_at DESC`
|
||||
@@ -585,6 +585,16 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("update deadline: %w", err)
|
||||
}
|
||||
// Slice B.2 dual-write (t-paliad-305): if rule_id was in the
|
||||
// patch (auto/custom swap from t-paliad-258), the parallel
|
||||
// procedural_event_id + sequencing_rule_id columns must follow.
|
||||
// Call unconditionally — it's a single UPDATE keyed on
|
||||
// deadlineID and a no-op when rule_id is unchanged.
|
||||
if input.RuleSet {
|
||||
if err := syncDeadlineDualLinks(ctx, tx, deadlineID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if input.EventTypeIDs != nil && s.eventTypes != nil {
|
||||
|
||||
392
internal/services/dual_write.go
Normal file
392
internal/services/dual_write.go
Normal file
@@ -0,0 +1,392 @@
|
||||
// Slice B.2 dual-write (t-paliad-305 / m/paliad#93) — keep paliad's
|
||||
// new tables (procedural_events / sequencing_rules / legal_sources) in
|
||||
// lock-step with the legacy paliad.deadline_rules table during the
|
||||
// dual-write window. Mig 136 (Slice B.1) created the new tables and
|
||||
// backfilled them once. This file keeps them in sync going forward.
|
||||
//
|
||||
// Contract:
|
||||
//
|
||||
// - Every RuleEditorService method that mutates paliad.deadline_rules
|
||||
// calls syncDualWriteFromDeadlineRule(ctx, tx, id) inside the same
|
||||
// transaction, AFTER the deadline_rules write, BEFORE tx.Commit.
|
||||
// - The sync is idempotent (INSERT … ON CONFLICT … DO UPDATE) so the
|
||||
// same call works for Create (new row), UpdateDraft (existing row),
|
||||
// CloneAsDraft (new row referencing an old row), Publish (lifecycle
|
||||
// flip), Archive/Restore (lifecycle flip), and the published-peer
|
||||
// archive that Publish performs as a cascade.
|
||||
// - The sync re-derives the new-table state from paliad.deadline_rules
|
||||
// in pure SQL — no struct mapping in Go. The legacy table stays the
|
||||
// source of truth during B.2 (B.3 flips reads, B.4 drops it).
|
||||
// - Read paths still read deadline_rules in B.2. The new tables are a
|
||||
// parallel projection kept consistent for B.3's read cutover; they
|
||||
// are not yet authoritative.
|
||||
//
|
||||
// Why a per-row sync instead of a global trigger:
|
||||
//
|
||||
// - The deadline_rules audit trigger (mig 079) reads paliad.audit_reason
|
||||
// to record the rationale on every change. Putting the new-table
|
||||
// write in the same TX preserves that auditability — set_config is
|
||||
// transactional and the new writes share the same reason.
|
||||
// - A Postgres-side AFTER UPDATE trigger on deadline_rules would also
|
||||
// work but it's harder to test in isolation and harder to revert
|
||||
// when B.4 drops the source table. A Go-side sync is reversible
|
||||
// with a code revert; an SQL trigger needs a follow-up migration.
|
||||
//
|
||||
// The drift-check job (CheckDualWriteDrift below) runs daily and
|
||||
// alerts on mismatches. If the sync ever silently misses a row, the
|
||||
// drift check surfaces it inside one day.
|
||||
//
|
||||
// See docs/design-procedural-events-model-2026-05-25.md §5.2 (dual-write
|
||||
// phase) and docs/design-procedural-events-b0-findings-2026-05-26.md §7.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// syncDualWriteFromDeadlineRule re-projects the deadline_rules row with
|
||||
// the given id into legal_sources + procedural_events + sequencing_rules.
|
||||
// Runs three UPSERT statements in the open transaction.
|
||||
//
|
||||
// Synthetic-code rule (for rows where deadline_rules.submission_code is
|
||||
// NULL) mirrors mig 136's backfill: 'null.' || first 8 hex chars of the
|
||||
// uuid (dashes stripped). This must stay byte-identical to the mig 136
|
||||
// expression or the lookup join inside the sequencing_rules UPSERT
|
||||
// misses.
|
||||
func syncDualWriteFromDeadlineRule(ctx context.Context, tx *sqlx.Tx, id uuid.UUID) error {
|
||||
// 1. legal_sources — UPSERT the citation (no-op if already present).
|
||||
// jurisdiction is parsed from the first dot-separated segment;
|
||||
// 'other' on empty (paranoid fallback, no live rows hit it).
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
||||
SELECT dr.legal_source,
|
||||
COALESCE(NULLIF(split_part(dr.legal_source, '.', 1), ''), 'other')
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.id = $1 AND dr.legal_source IS NOT NULL
|
||||
ON CONFLICT (citation) DO NOTHING`, id); err != nil {
|
||||
return fmt.Errorf("dual-write legal_sources for rule %s: %w", id, err)
|
||||
}
|
||||
|
||||
// 2. procedural_events — UPSERT keyed by code. The code is the
|
||||
// submission_code if present, else the synthetic 'null.<8hex>'
|
||||
// minted from the deadline_rules row's id (matches mig 136).
|
||||
// legal_source_id is resolved by JOIN on legal_sources.citation
|
||||
// (NULL when the rule has no legal_source).
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
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
|
||||
COALESCE(dr.submission_code,
|
||||
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)),
|
||||
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.id = $1
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
name_en = EXCLUDED.name_en,
|
||||
description = EXCLUDED.description,
|
||||
event_kind = EXCLUDED.event_kind,
|
||||
primary_party_default = EXCLUDED.primary_party_default,
|
||||
legal_source_id = EXCLUDED.legal_source_id,
|
||||
concept_id = EXCLUDED.concept_id,
|
||||
lifecycle_state = EXCLUDED.lifecycle_state,
|
||||
published_at = EXCLUDED.published_at,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = now()`, id); err != nil {
|
||||
return fmt.Errorf("dual-write procedural_events for rule %s: %w", id, err)
|
||||
}
|
||||
|
||||
// 3. sequencing_rules — UPSERT keyed by id (1:1 inheritance from
|
||||
// deadline_rules.id). procedural_event_id resolved by JOIN on
|
||||
// the (real or synthetic) code. All hat-3 mechanics columns copy
|
||||
// 1:1 from the deadline_rules row's post-write state.
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
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, 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))
|
||||
WHERE dr.id = $1
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
procedural_event_id = EXCLUDED.procedural_event_id,
|
||||
proceeding_type_id = EXCLUDED.proceeding_type_id,
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
trigger_event_id = EXCLUDED.trigger_event_id,
|
||||
duration_value = EXCLUDED.duration_value,
|
||||
duration_unit = EXCLUDED.duration_unit,
|
||||
timing = EXCLUDED.timing,
|
||||
alt_duration_value = EXCLUDED.alt_duration_value,
|
||||
alt_duration_unit = EXCLUDED.alt_duration_unit,
|
||||
alt_rule_code = EXCLUDED.alt_rule_code,
|
||||
anchor_alt = EXCLUDED.anchor_alt,
|
||||
combine_op = EXCLUDED.combine_op,
|
||||
condition_expr = EXCLUDED.condition_expr,
|
||||
primary_party = EXCLUDED.primary_party,
|
||||
sequence_order = EXCLUDED.sequence_order,
|
||||
is_spawn = EXCLUDED.is_spawn,
|
||||
spawn_label = EXCLUDED.spawn_label,
|
||||
spawn_proceeding_type_id = EXCLUDED.spawn_proceeding_type_id,
|
||||
is_bilateral = EXCLUDED.is_bilateral,
|
||||
is_court_set = EXCLUDED.is_court_set,
|
||||
priority = EXCLUDED.priority,
|
||||
rule_code = EXCLUDED.rule_code,
|
||||
rule_codes = EXCLUDED.rule_codes,
|
||||
deadline_notes = EXCLUDED.deadline_notes,
|
||||
deadline_notes_en = EXCLUDED.deadline_notes_en,
|
||||
choices_offered = EXCLUDED.choices_offered,
|
||||
applies_to_target = EXCLUDED.applies_to_target,
|
||||
lifecycle_state = EXCLUDED.lifecycle_state,
|
||||
draft_of = EXCLUDED.draft_of,
|
||||
published_at = EXCLUDED.published_at,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = now()`, id); err != nil {
|
||||
return fmt.Errorf("dual-write sequencing_rules for rule %s: %w", id, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncDeadlineDualLinks mirrors a deadline's legacy rule_id back-link
|
||||
// onto the new procedural_event_id + sequencing_rule_id columns added
|
||||
// by mig 136. Call this within an open transaction AFTER any UPDATE
|
||||
// that mutates paliad.deadlines.rule_id (mig 122 introduced rule_id
|
||||
// as the deadline→rule FK; today's writers are DeadlineService.Update
|
||||
// and RuleEditorService.ResolveOrphan).
|
||||
//
|
||||
// Idempotent: NULL rule_id collapses both new columns to NULL by virtue
|
||||
// of the subquery returning NULL. Slice B.2 (t-paliad-305).
|
||||
func syncDeadlineDualLinks(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE paliad.deadlines d
|
||||
SET sequencing_rule_id = d.rule_id,
|
||||
procedural_event_id = (
|
||||
SELECT sr.procedural_event_id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.id = d.rule_id
|
||||
)
|
||||
WHERE d.id = $1`, deadlineID); err != nil {
|
||||
return fmt.Errorf("sync deadline dual-links for %s: %w", deadlineID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DualWriteDriftReport summarises the comparison between the legacy
|
||||
// paliad.deadline_rules table and the new procedural_events /
|
||||
// sequencing_rules tables that B.2's dual-write is meant to keep in
|
||||
// sync. A zero-drift report (every count delta zero, every join clean)
|
||||
// is the steady state during the dual-write window; any non-zero field
|
||||
// is the signal that a write path either bypassed
|
||||
// syncDualWriteFromDeadlineRule or that an out-of-band mutation
|
||||
// happened (e.g. raw SQL run by an operator).
|
||||
type DualWriteDriftReport struct {
|
||||
// Counts on the legacy and the projected side.
|
||||
DeadlineRules int `json:"deadline_rules"`
|
||||
SequencingRules int `json:"sequencing_rules"`
|
||||
ProceduralEvents int `json:"procedural_events"`
|
||||
LegalSources int `json:"legal_sources"`
|
||||
|
||||
// Expected (from the legacy side) vs observed (on the new side).
|
||||
ExpectedPE int `json:"expected_procedural_events"`
|
||||
ExpectedLegalSources int `json:"expected_legal_sources"`
|
||||
|
||||
// MissingSR — deadline_rules rows with no sequencing_rules row by id.
|
||||
// OrphanedSR — sequencing_rules rows whose id doesn't exist in
|
||||
// deadline_rules anymore (would only happen with a deletion path
|
||||
// that bypasses dual-write).
|
||||
MissingSR int `json:"missing_sequencing_rules"`
|
||||
OrphanedSR int `json:"orphaned_sequencing_rules"`
|
||||
|
||||
// MismatchedLifecycle — rows where deadline_rules.lifecycle_state
|
||||
// disagrees with sequencing_rules.lifecycle_state. Should always be
|
||||
// zero during dual-write.
|
||||
MismatchedLifecycle int `json:"mismatched_lifecycle"`
|
||||
|
||||
// MismatchedActive — same shape, for is_active.
|
||||
MismatchedActive int `json:"mismatched_active"`
|
||||
}
|
||||
|
||||
// HasDrift returns true if any field signals divergence between the
|
||||
// legacy and projected sides. Used by the drift-check ticker to decide
|
||||
// whether to log at WARN (drift) or INFO (clean).
|
||||
func (r DualWriteDriftReport) HasDrift() bool {
|
||||
if r.SequencingRules != r.DeadlineRules {
|
||||
return true
|
||||
}
|
||||
if r.ProceduralEvents != r.ExpectedPE {
|
||||
return true
|
||||
}
|
||||
if r.LegalSources != r.ExpectedLegalSources {
|
||||
return true
|
||||
}
|
||||
if r.MissingSR != 0 || r.OrphanedSR != 0 {
|
||||
return true
|
||||
}
|
||||
if r.MismatchedLifecycle != 0 || r.MismatchedActive != 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckDualWriteDrift compares the legacy paliad.deadline_rules table
|
||||
// against the parallel new tables maintained by Slice B.2's dual-write.
|
||||
// Returns a DualWriteDriftReport — caller decides what to do with
|
||||
// non-zero drift (log, page, fail healthcheck, etc.).
|
||||
//
|
||||
// Read-only. Safe to run against prod. Single query per metric so the
|
||||
// pool isn't held for a long time. No locks; tolerates concurrent
|
||||
// writes (counts may shift by one or two during the read, but a
|
||||
// persistent drift > 0 is the alarm signal).
|
||||
func CheckDualWriteDrift(ctx context.Context, conn *sqlx.DB) (*DualWriteDriftReport, error) {
|
||||
var r DualWriteDriftReport
|
||||
|
||||
q := func(label, sql string, dst *int) error {
|
||||
if err := conn.GetContext(ctx, dst, sql); err != nil {
|
||||
return fmt.Errorf("drift-check %s: %w", label, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := q("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &r.DeadlineRules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &r.SequencingRules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &r.ProceduralEvents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &r.LegalSources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := q("expected_pe", `
|
||||
SELECT
|
||||
(SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL)
|
||||
+
|
||||
(SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL)
|
||||
`, &r.ExpectedPE); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("expected_ls",
|
||||
`SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`,
|
||||
&r.ExpectedLegalSources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := q("missing_sr", `
|
||||
SELECT COUNT(*) FROM paliad.deadline_rules dr
|
||||
LEFT JOIN paliad.sequencing_rules sr ON sr.id = dr.id
|
||||
WHERE sr.id IS NULL`, &r.MissingSR); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("orphaned_sr", `
|
||||
SELECT COUNT(*) FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.deadline_rules dr ON dr.id = sr.id
|
||||
WHERE dr.id IS NULL`, &r.OrphanedSR); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := q("mismatched_lifecycle", `
|
||||
SELECT COUNT(*) FROM paliad.deadline_rules dr
|
||||
JOIN paliad.sequencing_rules sr ON sr.id = dr.id
|
||||
WHERE dr.lifecycle_state <> sr.lifecycle_state`, &r.MismatchedLifecycle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("mismatched_active", `
|
||||
SELECT COUNT(*) FROM paliad.deadline_rules dr
|
||||
JOIN paliad.sequencing_rules sr ON sr.id = dr.id
|
||||
WHERE dr.is_active <> sr.is_active`, &r.MismatchedActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// StartDualWriteDriftCheckLoop runs CheckDualWriteDrift on a fixed
|
||||
// interval for the lifetime of ctx. A clean run logs at INFO level;
|
||||
// drift logs at WARN level with the full report payload. The first
|
||||
// check fires after `interval`, not immediately on Start — by the time
|
||||
// the ticker first fires the process has finished booting and the
|
||||
// initial backfill + dual-write writes have settled.
|
||||
//
|
||||
// Slice B.2 (t-paliad-305). interval should be short enough to surface
|
||||
// drift before the next deploy (so a broken dual-write doesn't sit
|
||||
// silent for a week) and long enough to avoid noise (the check holds
|
||||
// no locks but it does run nine SELECT COUNTs).
|
||||
//
|
||||
// Recommended interval: 6h. Override via the caller (cmd/server picks
|
||||
// the runtime value).
|
||||
func StartDualWriteDriftCheckLoop(ctx context.Context, conn *sqlx.DB, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = 6 * time.Hour
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
report, err := CheckDualWriteDrift(ctx, conn)
|
||||
if err != nil {
|
||||
log.Printf("dual-write drift-check: error: %v", err)
|
||||
continue
|
||||
}
|
||||
if report.HasDrift() {
|
||||
log.Printf("dual-write drift-check: DRIFT DETECTED — "+
|
||||
"deadline_rules=%d sequencing_rules=%d "+
|
||||
"procedural_events=%d (expected %d) "+
|
||||
"legal_sources=%d (expected %d) "+
|
||||
"missing_sr=%d orphaned_sr=%d "+
|
||||
"mismatched_lifecycle=%d mismatched_active=%d",
|
||||
report.DeadlineRules, report.SequencingRules,
|
||||
report.ProceduralEvents, report.ExpectedPE,
|
||||
report.LegalSources, report.ExpectedLegalSources,
|
||||
report.MissingSR, report.OrphanedSR,
|
||||
report.MismatchedLifecycle, report.MismatchedActive)
|
||||
} else {
|
||||
log.Printf("dual-write drift-check: OK — "+
|
||||
"deadline_rules=%d sequencing_rules=%d "+
|
||||
"procedural_events=%d legal_sources=%d",
|
||||
report.DeadlineRules, report.SequencingRules,
|
||||
report.ProceduralEvents, report.LegalSources)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
300
internal/services/dual_write_test.go
Normal file
300
internal/services/dual_write_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Slice B.2 dual-write tests (t-paliad-305 / m/paliad#93).
|
||||
//
|
||||
// Asserts the parallel projection — paliad.procedural_events +
|
||||
// paliad.sequencing_rules + paliad.legal_sources — stays in lock-step
|
||||
// with paliad.deadline_rules through the full RuleEditorService
|
||||
// lifecycle. Skipped when TEST_DATABASE_URL is unset.
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestDualWrite_RuleEditorLifecycle walks Create → UpdateDraft →
|
||||
// CloneAsDraft → Publish → Archive → Restore on RuleEditorService and
|
||||
// after each operation asserts that paliad.sequencing_rules has the
|
||||
// 1:1 mirror, paliad.procedural_events carries the projected identity,
|
||||
// and paliad.legal_sources carries the citation.
|
||||
func TestDualWrite_RuleEditorLifecycle(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)
|
||||
svc := NewRuleEditorService(pool, rules)
|
||||
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason', 'slice b.2 test cleanup', true)`)
|
||||
// Order matters: sequencing_rules → procedural_events → legal_sources
|
||||
// (FK direction). deadline_rules cleanup last because mig 079 audit
|
||||
// trigger captures the DELETE.
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
|
||||
SELECT id FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'
|
||||
)`)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
|
||||
WHERE code LIKE 'sliceb2.%' OR code LIKE 'null.sliceb2%'`)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.legal_sources
|
||||
WHERE citation LIKE 'SLICEB2.%'`)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'`)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_TEST_PT'`)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
var ptID int
|
||||
if err := pool.GetContext(ctx, &ptID, `
|
||||
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
|
||||
VALUES ('SLICEB2_TEST_PT', 'Slice B.2 Test PT', 'Slice B.2 Test PT', 'fristenrechner', 'UPC', true)
|
||||
RETURNING id`); err != nil {
|
||||
t.Fatalf("seed proceeding_type: %v", err)
|
||||
}
|
||||
|
||||
subCode := "sliceb2.create"
|
||||
legalSrc := "SLICEB2.PatG.1"
|
||||
|
||||
// 1. Create — assert the parallel rows land.
|
||||
created, err := svc.Create(ctx, CreateRuleInput{
|
||||
Name: "SLICEB2_TEST_create",
|
||||
NameEN: "SLICEB2_TEST_create_EN",
|
||||
ProceedingTypeID: &ptID,
|
||||
SubmissionCode: &subCode,
|
||||
LegalSource: &legalSrc,
|
||||
DurationValue: 30,
|
||||
DurationUnit: "days",
|
||||
Priority: "mandatory",
|
||||
}, "B.2 dual-write create test")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// legal_sources should now carry SLICEB2.PatG.1
|
||||
var lsCount int
|
||||
if err := pool.GetContext(ctx, &lsCount,
|
||||
`SELECT COUNT(*) FROM paliad.legal_sources WHERE citation = $1`, legalSrc); err != nil {
|
||||
t.Fatalf("query legal_sources: %v", err)
|
||||
}
|
||||
if lsCount != 1 {
|
||||
t.Errorf("legal_sources after Create: got %d, want 1 for citation %q", lsCount, legalSrc)
|
||||
}
|
||||
|
||||
// procedural_events should carry the submission_code
|
||||
var peName, peLifecycle string
|
||||
if err := pool.GetContext(ctx, &peName,
|
||||
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
||||
t.Fatalf("query procedural_events name: %v", err)
|
||||
}
|
||||
if peName != "SLICEB2_TEST_create" {
|
||||
t.Errorf("procedural_events.name after Create: got %q, want %q", peName, "SLICEB2_TEST_create")
|
||||
}
|
||||
if err := pool.GetContext(ctx, &peLifecycle,
|
||||
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
||||
t.Fatalf("query procedural_events lifecycle: %v", err)
|
||||
}
|
||||
if peLifecycle != "draft" {
|
||||
t.Errorf("procedural_events.lifecycle_state after Create: got %q, want %q", peLifecycle, "draft")
|
||||
}
|
||||
|
||||
// sequencing_rules should have id = created.id and link to PE
|
||||
var srCount, srMatchPE int
|
||||
if err := pool.GetContext(ctx, &srCount,
|
||||
`SELECT COUNT(*) FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
|
||||
t.Fatalf("query sequencing_rules count: %v", err)
|
||||
}
|
||||
if srCount != 1 {
|
||||
t.Errorf("sequencing_rules row after Create: got %d, want 1 for id %s", srCount, created.ID)
|
||||
}
|
||||
if err := pool.GetContext(ctx, &srMatchPE, `
|
||||
SELECT COUNT(*) FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE sr.id = $1 AND pe.code = $2`, created.ID, subCode); err != nil {
|
||||
t.Fatalf("query sr→pe join: %v", err)
|
||||
}
|
||||
if srMatchPE != 1 {
|
||||
t.Errorf("sequencing_rules.procedural_event_id after Create: got %d join hits, want 1", srMatchPE)
|
||||
}
|
||||
|
||||
// 2. UpdateDraft — change name + legal_source. Assert propagation.
|
||||
newName := "SLICEB2_TEST_updated"
|
||||
newLegal := "SLICEB2.ZPO.2"
|
||||
_, err = svc.UpdateDraft(ctx, created.ID, RulePatch{
|
||||
Name: &newName,
|
||||
LegalSource: &newLegal,
|
||||
}, "B.2 dual-write update test")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDraft: %v", err)
|
||||
}
|
||||
|
||||
var afterName string
|
||||
if err := pool.GetContext(ctx, &afterName,
|
||||
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
||||
t.Fatalf("query pe.name post-update: %v", err)
|
||||
}
|
||||
if afterName != newName {
|
||||
t.Errorf("procedural_events.name after UpdateDraft: got %q, want %q", afterName, newName)
|
||||
}
|
||||
|
||||
// New citation must appear in legal_sources, and procedural_events.legal_source_id
|
||||
// must point at it (idempotent UPSERT — the old SLICEB2.PatG.1 row stays).
|
||||
var pePointsAtNewLegal int
|
||||
if err := pool.GetContext(ctx, &pePointsAtNewLegal, `
|
||||
SELECT COUNT(*) FROM paliad.procedural_events pe
|
||||
JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
|
||||
WHERE pe.code = $1 AND ls.citation = $2`, subCode, newLegal); err != nil {
|
||||
t.Fatalf("query pe→ls join: %v", err)
|
||||
}
|
||||
if pePointsAtNewLegal != 1 {
|
||||
t.Errorf("procedural_events.legal_source_id after UpdateDraft: got %d hits, want 1", pePointsAtNewLegal)
|
||||
}
|
||||
|
||||
// 3. Publish — flip to published. Assert lifecycle mirror.
|
||||
_, err = svc.Publish(ctx, created.ID, "B.2 dual-write publish test")
|
||||
if err != nil {
|
||||
t.Fatalf("Publish: %v", err)
|
||||
}
|
||||
var srLifecycle, peLifecycleAfterPub string
|
||||
if err := pool.GetContext(ctx, &srLifecycle,
|
||||
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
|
||||
t.Fatalf("query sr.lifecycle: %v", err)
|
||||
}
|
||||
if srLifecycle != "published" {
|
||||
t.Errorf("sequencing_rules.lifecycle_state after Publish: got %q, want %q", srLifecycle, "published")
|
||||
}
|
||||
if err := pool.GetContext(ctx, &peLifecycleAfterPub,
|
||||
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
||||
t.Fatalf("query pe.lifecycle post-publish: %v", err)
|
||||
}
|
||||
if peLifecycleAfterPub != "published" {
|
||||
t.Errorf("procedural_events.lifecycle_state after Publish: got %q, want %q", peLifecycleAfterPub, "published")
|
||||
}
|
||||
|
||||
// 4. Archive — flip to archived. Assert mirror.
|
||||
_, err = svc.Archive(ctx, created.ID, "B.2 dual-write archive test")
|
||||
if err != nil {
|
||||
t.Fatalf("Archive: %v", err)
|
||||
}
|
||||
var srLifecycleArchived string
|
||||
if err := pool.GetContext(ctx, &srLifecycleArchived,
|
||||
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
|
||||
t.Fatalf("query sr.lifecycle post-archive: %v", err)
|
||||
}
|
||||
if srLifecycleArchived != "archived" {
|
||||
t.Errorf("sequencing_rules.lifecycle_state after Archive: got %q, want %q", srLifecycleArchived, "archived")
|
||||
}
|
||||
|
||||
// 5. Drift check should return zero drift right after the dance.
|
||||
report, err := CheckDualWriteDrift(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckDualWriteDrift: %v", err)
|
||||
}
|
||||
if report.HasDrift() {
|
||||
t.Errorf("CheckDualWriteDrift unexpectedly flagged drift: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDualWrite_SyntheticCodeForNullSubmission asserts that a rule
|
||||
// created with submission_code=NULL gets a synthetic 'null.<8hex>'
|
||||
// procedural_events row matching mig 136's mint expression — so a new
|
||||
// draft without a code participates in the dual-write contract without
|
||||
// colliding with any code-bearing rule.
|
||||
func TestDualWrite_SyntheticCodeForNullSubmission(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)
|
||||
svc := NewRuleEditorService(pool, rules)
|
||||
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice b.2 null-code cleanup', true)`)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
|
||||
SELECT id FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
|
||||
)`)
|
||||
// Synthetic PE rows are keyed off the rule's uuid; delete by name reference.
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
|
||||
WHERE code IN (
|
||||
SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
|
||||
FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
|
||||
)`)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'`)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_NC_PT'`)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
var ptID int
|
||||
if err := pool.GetContext(ctx, &ptID, `
|
||||
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
|
||||
VALUES ('SLICEB2_NC_PT', 'NC PT', 'NC PT', 'fristenrechner', 'UPC', true)
|
||||
RETURNING id`); err != nil {
|
||||
t.Fatalf("seed proceeding_type: %v", err)
|
||||
}
|
||||
|
||||
created, err := svc.Create(ctx, CreateRuleInput{
|
||||
Name: "SLICEB2_TEST_nullcode",
|
||||
NameEN: "SLICEB2_TEST_nullcode_EN",
|
||||
ProceedingTypeID: &ptID,
|
||||
// SubmissionCode intentionally NIL → tests the synthetic-code branch.
|
||||
DurationValue: 5,
|
||||
DurationUnit: "days",
|
||||
Priority: "mandatory",
|
||||
}, "B.2 dual-write null-code test")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// Compute the expected synthetic code in the same way mig 136 / the
|
||||
// dual-write helper do — keep the expression in lock-step with the
|
||||
// SQL via this Go-side mirror.
|
||||
var expectedCode string
|
||||
if err := pool.GetContext(ctx, &expectedCode,
|
||||
`SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
|
||||
FROM paliad.deadline_rules WHERE id = $1`, created.ID); err != nil {
|
||||
t.Fatalf("compute expected synthetic code: %v", err)
|
||||
}
|
||||
|
||||
var actualCode string
|
||||
if err := pool.GetContext(ctx, &actualCode, `
|
||||
SELECT pe.code
|
||||
FROM paliad.procedural_events pe
|
||||
JOIN paliad.sequencing_rules sr ON sr.procedural_event_id = pe.id
|
||||
WHERE sr.id = $1`, created.ID); err != nil {
|
||||
t.Fatalf("query procedural_events via sequencing_rules: %v", err)
|
||||
}
|
||||
if actualCode != expectedCode {
|
||||
t.Errorf("synthetic code mismatch: got %q, want %q", actualCode, expectedCode)
|
||||
}
|
||||
if len(actualCode) != len("null.")+8 {
|
||||
t.Errorf("synthetic code length: got %d, want 13 (null.+8hex)", len(actualCode))
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
COALESCE(timing, 'after') AS timing,
|
||||
deadline_notes, deadline_notes_en, alt_duration_value, alt_duration_unit,
|
||||
combine_op, rule_codes
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE trigger_event_id = $1 AND is_active = true
|
||||
ORDER BY sequence_order`, triggerEventID)
|
||||
if err != nil {
|
||||
|
||||
@@ -46,6 +46,7 @@ import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -297,7 +298,10 @@ func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSp
|
||||
// is just bookkeeping that releases the snapshot.
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
sheets := orgSheetQueries()
|
||||
sheets, err := resolveOrgSheets(ctx, tx, orgSheetSpecs())
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
if err := s.writeBundle(ctx, tx, w, sheets, &meta); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
@@ -1138,7 +1142,7 @@ func personalSheetQueries(actorID uuid.UUID) []sheetQuery {
|
||||
},
|
||||
{
|
||||
SheetName: "ref__deadline_rules",
|
||||
SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`,
|
||||
SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`,
|
||||
},
|
||||
{
|
||||
SheetName: "ref__deadline_concepts",
|
||||
@@ -1518,7 +1522,7 @@ SELECT 'partner_unit_default'::text AS source,
|
||||
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
|
||||
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
|
||||
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
|
||||
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
|
||||
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`},
|
||||
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
|
||||
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
|
||||
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
|
||||
@@ -1560,73 +1564,249 @@ SELECT 'partner_unit_default'::text AS source,
|
||||
// secret|token|password|api_key|private_key on every sheet as a
|
||||
// belt-and-braces filter. user_caldav_config.password_encrypted is
|
||||
// explicitly named in DropColumns too.
|
||||
func orgSheetQueries() []sheetQuery {
|
||||
return []sheetQuery{
|
||||
//
|
||||
// Drift-resistance (m/paliad#140): each spec declares its desired
|
||||
// ORDER BY columns as a list. At backup time the exporter probes
|
||||
// information_schema.columns for the live schema; any ORDER BY column
|
||||
// that no longer exists is dropped (logged WARN). This way a column
|
||||
// rename or removal never breaks a backup — the worst case is a sheet
|
||||
// that loses sort stability until the spec is updated. A sheet whose
|
||||
// ORDER BY columns are all gone still exports, just in pg's natural
|
||||
// (unspecified) order.
|
||||
//
|
||||
// Custom column projections (e.g. documents drops ai_extracted) live
|
||||
// in the SQL override field; if set, it bypasses the Table+OrderBy
|
||||
// builder entirely. Use it sparingly — every override re-introduces
|
||||
// drift risk for that sheet.
|
||||
|
||||
// orgSheetSpec declares one org-scope sheet for the drift-resistant
|
||||
// builder. Either set SQL (free-form override) or set Table+OrderBy
|
||||
// (let the builder compose `SELECT * FROM <Table> ORDER BY <existing>`).
|
||||
type orgSheetSpec struct {
|
||||
// SheetName lands in the workbook sheet and the JSON top-level key.
|
||||
SheetName string
|
||||
// Table is schema-qualified (e.g. "paliad.appointments"). Used only
|
||||
// when SQL is empty. The schema/table form must be valid SQL
|
||||
// identifiers — the builder splits on the dot, no quoting.
|
||||
Table string
|
||||
// OrderBy is the *desired* sort columns. Missing columns are
|
||||
// dropped silently-with-a-WARN at build time; remaining columns
|
||||
// keep their declared order. Empty/all-missing → no ORDER BY (still
|
||||
// deterministic-within-a-snapshot under the REPEATABLE READ tx, but
|
||||
// the order across runs may differ).
|
||||
OrderBy []string
|
||||
// SQL is an explicit override; if non-empty, Table+OrderBy are
|
||||
// ignored entirely. Use only when the projection cannot be
|
||||
// expressed as SELECT * (e.g. documents drops the ai_extracted
|
||||
// jsonb column).
|
||||
SQL string
|
||||
// Args are positional arguments. Only meaningful with SQL override;
|
||||
// the Table+OrderBy path takes no args.
|
||||
Args []any
|
||||
// DropColumns is an explicit list of column names to drop from the
|
||||
// result regardless of the PII deny-regex.
|
||||
DropColumns []string
|
||||
}
|
||||
|
||||
func orgSheetSpecs() []orgSheetSpec {
|
||||
return []orgSheetSpec{
|
||||
// --- entity sheets (alphabetical) ---
|
||||
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_binding_id`},
|
||||
{SheetName: "appointments", SQL: `SELECT * FROM paliad.appointments ORDER BY id`},
|
||||
{SheetName: "approval_policies", SQL: `SELECT * FROM paliad.approval_policies ORDER BY id`},
|
||||
{SheetName: "approval_requests", SQL: `SELECT * FROM paliad.approval_requests ORDER BY id`},
|
||||
{SheetName: "appointment_caldav_targets", Table: "paliad.appointment_caldav_targets", OrderBy: []string{"appointment_id", "binding_id"}},
|
||||
{SheetName: "appointments", Table: "paliad.appointments", OrderBy: []string{"id"}},
|
||||
{SheetName: "approval_policies", Table: "paliad.approval_policies", OrderBy: []string{"id"}},
|
||||
{SheetName: "approval_requests", Table: "paliad.approval_requests", OrderBy: []string{"id"}},
|
||||
// backups is self-reflexive — including it makes "what backups
|
||||
// have we taken" recoverable from any prior backup. Tiny table.
|
||||
{SheetName: "backups", SQL: `SELECT * FROM paliad.backups ORDER BY started_at, id`},
|
||||
{SheetName: "caldav_sync_log", SQL: `SELECT * FROM paliad.caldav_sync_log ORDER BY occurred_at, id`},
|
||||
{SheetName: "checklist_instances", SQL: `SELECT * FROM paliad.checklist_instances ORDER BY id`},
|
||||
{SheetName: "checklist_shares", SQL: `SELECT * FROM paliad.checklist_shares ORDER BY id`},
|
||||
{SheetName: "checklists", SQL: `SELECT * FROM paliad.checklists ORDER BY id`},
|
||||
{SheetName: "deadline_rule_audit", SQL: `SELECT * FROM paliad.deadline_rule_audit ORDER BY changed_at, id`},
|
||||
{SheetName: "deadlines", SQL: `SELECT * FROM paliad.deadlines ORDER BY id`},
|
||||
{SheetName: "backups", Table: "paliad.backups", OrderBy: []string{"started_at", "id"}},
|
||||
{SheetName: "caldav_sync_log", Table: "paliad.caldav_sync_log", OrderBy: []string{"occurred_at", "id"}},
|
||||
{SheetName: "checklist_instances", Table: "paliad.checklist_instances", OrderBy: []string{"id"}},
|
||||
{SheetName: "checklist_shares", Table: "paliad.checklist_shares", OrderBy: []string{"id"}},
|
||||
{SheetName: "checklists", Table: "paliad.checklists", OrderBy: []string{"id"}},
|
||||
{SheetName: "deadline_rule_audit", Table: "paliad.deadline_rule_audit", OrderBy: []string{"changed_at", "id"}},
|
||||
{SheetName: "deadlines", Table: "paliad.deadlines", OrderBy: []string{"id"}},
|
||||
// documents: ai_extracted jsonb dropped (verbose AI prompts;
|
||||
// matches the personal/project precedent). Binaries are not in
|
||||
// the export — only metadata.
|
||||
// the export — only metadata. Uses SQL override because the
|
||||
// projection isn't SELECT *.
|
||||
{
|
||||
SheetName: "documents",
|
||||
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
|
||||
FROM paliad.documents
|
||||
ORDER BY id`,
|
||||
},
|
||||
{SheetName: "email_broadcasts", SQL: `SELECT * FROM paliad.email_broadcasts ORDER BY id`},
|
||||
{SheetName: "email_template_versions", SQL: `SELECT * FROM paliad.email_template_versions ORDER BY id`},
|
||||
{SheetName: "email_templates", SQL: `SELECT * FROM paliad.email_templates ORDER BY id`},
|
||||
{SheetName: "firm_dashboard_default", SQL: `SELECT * FROM paliad.firm_dashboard_default ORDER BY id`},
|
||||
{SheetName: "invitations", SQL: `SELECT * FROM paliad.invitations ORDER BY sent_at, id`},
|
||||
{SheetName: "notes", SQL: `SELECT * FROM paliad.notes ORDER BY id`},
|
||||
{SheetName: "parties", SQL: `SELECT * FROM paliad.parties ORDER BY id`},
|
||||
{SheetName: "partner_unit_events", SQL: `SELECT * FROM paliad.partner_unit_events ORDER BY id`},
|
||||
{SheetName: "partner_unit_members", SQL: `SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id`},
|
||||
{SheetName: "partner_units", SQL: `SELECT * FROM paliad.partner_units ORDER BY id`},
|
||||
{SheetName: "policy_audit_log", SQL: `SELECT * FROM paliad.policy_audit_log ORDER BY changed_at, id`},
|
||||
{SheetName: "project_events", SQL: `SELECT * FROM paliad.project_events ORDER BY id`},
|
||||
{SheetName: "project_partner_units", SQL: `SELECT * FROM paliad.project_partner_units ORDER BY project_id, partner_unit_id`},
|
||||
{SheetName: "project_teams", SQL: `SELECT * FROM paliad.project_teams ORDER BY project_id, user_id`},
|
||||
{SheetName: "projects", SQL: `SELECT * FROM paliad.projects ORDER BY id`},
|
||||
{SheetName: "reminder_log", SQL: `SELECT * FROM paliad.reminder_log ORDER BY sent_at, id`},
|
||||
{SheetName: "submission_drafts", SQL: `SELECT * FROM paliad.submission_drafts ORDER BY id`},
|
||||
{SheetName: "system_audit_log", SQL: `SELECT * FROM paliad.system_audit_log ORDER BY created_at, id`},
|
||||
{SheetName: "email_broadcasts", Table: "paliad.email_broadcasts", OrderBy: []string{"id"}},
|
||||
{SheetName: "email_template_versions", Table: "paliad.email_template_versions", OrderBy: []string{"id"}},
|
||||
{SheetName: "email_templates", Table: "paliad.email_templates", OrderBy: []string{"key", "lang"}},
|
||||
{SheetName: "firm_dashboard_default", Table: "paliad.firm_dashboard_default", OrderBy: []string{"id"}},
|
||||
{SheetName: "invitations", Table: "paliad.invitations", OrderBy: []string{"sent_at", "id"}},
|
||||
{SheetName: "notes", Table: "paliad.notes", OrderBy: []string{"id"}},
|
||||
{SheetName: "parties", Table: "paliad.parties", OrderBy: []string{"id"}},
|
||||
{SheetName: "partner_unit_events", Table: "paliad.partner_unit_events", OrderBy: []string{"id"}},
|
||||
{SheetName: "partner_unit_members", Table: "paliad.partner_unit_members", OrderBy: []string{"partner_unit_id", "user_id"}},
|
||||
{SheetName: "partner_units", Table: "paliad.partner_units", OrderBy: []string{"id"}},
|
||||
{SheetName: "policy_audit_log", Table: "paliad.policy_audit_log", OrderBy: []string{"created_at", "id"}},
|
||||
{SheetName: "project_events", Table: "paliad.project_events", OrderBy: []string{"id"}},
|
||||
{SheetName: "project_partner_units", Table: "paliad.project_partner_units", OrderBy: []string{"project_id", "partner_unit_id"}},
|
||||
{SheetName: "project_teams", Table: "paliad.project_teams", OrderBy: []string{"project_id", "user_id"}},
|
||||
{SheetName: "projects", Table: "paliad.projects", OrderBy: []string{"id"}},
|
||||
{SheetName: "reminder_log", Table: "paliad.reminder_log", OrderBy: []string{"sent_at", "id"}},
|
||||
{SheetName: "submission_drafts", Table: "paliad.submission_drafts", OrderBy: []string{"id"}},
|
||||
{SheetName: "system_audit_log", Table: "paliad.system_audit_log", OrderBy: []string{"created_at", "id"}},
|
||||
{
|
||||
SheetName: "user_caldav_config",
|
||||
SQL: `SELECT * FROM paliad.user_caldav_config ORDER BY user_id`,
|
||||
Table: "paliad.user_caldav_config",
|
||||
OrderBy: []string{"user_id"},
|
||||
DropColumns: []string{"password_encrypted"}, // belt-and-braces; piiColumnDenyRegex also catches it
|
||||
},
|
||||
{SheetName: "user_calendar_bindings", SQL: `SELECT * FROM paliad.user_calendar_bindings ORDER BY user_id, calendar_path`},
|
||||
{SheetName: "user_card_layouts", SQL: `SELECT * FROM paliad.user_card_layouts ORDER BY id`},
|
||||
{SheetName: "user_dashboard_layouts", SQL: `SELECT * FROM paliad.user_dashboard_layouts ORDER BY user_id`},
|
||||
{SheetName: "user_pinned_projects", SQL: `SELECT * FROM paliad.user_pinned_projects ORDER BY user_id, project_id`},
|
||||
{SheetName: "user_views", SQL: `SELECT * FROM paliad.user_views ORDER BY id`},
|
||||
{SheetName: "users", SQL: `SELECT * FROM paliad.users ORDER BY id`},
|
||||
{SheetName: "user_calendar_bindings", Table: "paliad.user_calendar_bindings", OrderBy: []string{"user_id", "calendar_path"}},
|
||||
{SheetName: "user_card_layouts", Table: "paliad.user_card_layouts", OrderBy: []string{"id"}},
|
||||
{SheetName: "user_dashboard_layouts", Table: "paliad.user_dashboard_layouts", OrderBy: []string{"user_id"}},
|
||||
{SheetName: "user_pinned_projects", Table: "paliad.user_pinned_projects", OrderBy: []string{"user_id", "project_id"}},
|
||||
{SheetName: "user_views", Table: "paliad.user_views", OrderBy: []string{"id"}},
|
||||
{SheetName: "users", Table: "paliad.users", OrderBy: []string{"id"}},
|
||||
|
||||
// --- reference data (alphabetical, prefixed ref__) ---
|
||||
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
|
||||
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
|
||||
{SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`},
|
||||
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
|
||||
{SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`},
|
||||
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
|
||||
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
|
||||
{SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`},
|
||||
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
|
||||
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
|
||||
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
|
||||
{SheetName: "ref__trigger_events", SQL: `SELECT * FROM paliad.trigger_events ORDER BY id`},
|
||||
{SheetName: "ref__countries", Table: "paliad.countries", OrderBy: []string{"code"}},
|
||||
{SheetName: "ref__courts", Table: "paliad.courts", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__deadline_concept_event_types", Table: "paliad.deadline_concept_event_types", OrderBy: []string{"concept_id", "event_type_id"}},
|
||||
{SheetName: "ref__deadline_concepts", Table: "paliad.deadline_concepts", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__deadline_event_types", Table: "paliad.deadline_event_types", OrderBy: []string{"deadline_id", "event_type_id"}},
|
||||
{SheetName: "ref__deadline_rules", Table: "paliad.deadline_rules_unified", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__event_categories", Table: "paliad.event_categories", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__event_category_concepts", Table: "paliad.event_category_concepts", OrderBy: []string{"event_category_id", "concept_id"}},
|
||||
{SheetName: "ref__event_types", Table: "paliad.event_types", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__holidays", Table: "paliad.holidays", OrderBy: []string{"date", "country"}},
|
||||
{SheetName: "ref__proceeding_types", Table: "paliad.proceeding_types", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__trigger_events", Table: "paliad.trigger_events", OrderBy: []string{"id"}},
|
||||
}
|
||||
}
|
||||
|
||||
// composeOrgSheetSQL turns one orgSheetSpec into the final SQL string,
|
||||
// using a per-table column set (typically loaded once per backup run
|
||||
// from information_schema.columns). Returns the SQL and the list of
|
||||
// ORDER BY columns that were dropped because they don't exist in the
|
||||
// live schema.
|
||||
//
|
||||
// Pure function — no DB access — so the missing-column behaviour is
|
||||
// unit-testable without a fixture database.
|
||||
//
|
||||
// Rules:
|
||||
// - If spec.SQL is non-empty, return it unchanged (override path).
|
||||
// - Otherwise build `SELECT * FROM <Table> [ORDER BY <kept-cols>]`.
|
||||
// - Columns are kept in their declared order; missing ones recorded
|
||||
// in `dropped` and omitted from ORDER BY.
|
||||
// - If no ORDER BY columns survive, the ORDER BY clause is omitted.
|
||||
//
|
||||
// knownCols maps unqualified table names (e.g. "appointments") to the
|
||||
// set of columns they have. A table missing from knownCols is treated
|
||||
// as "no columns known" — every declared ORDER BY column gets dropped.
|
||||
func composeOrgSheetSQL(spec orgSheetSpec, knownCols map[string]map[string]struct{}) (sqlText string, dropped []string) {
|
||||
if spec.SQL != "" {
|
||||
return spec.SQL, nil
|
||||
}
|
||||
unqualified := spec.Table
|
||||
if i := strings.IndexByte(unqualified, '.'); i >= 0 {
|
||||
unqualified = unqualified[i+1:]
|
||||
}
|
||||
cols := knownCols[unqualified]
|
||||
kept := make([]string, 0, len(spec.OrderBy))
|
||||
for _, c := range spec.OrderBy {
|
||||
if _, ok := cols[c]; ok {
|
||||
kept = append(kept, c)
|
||||
} else {
|
||||
dropped = append(dropped, c)
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("SELECT * FROM ")
|
||||
b.WriteString(spec.Table)
|
||||
if len(kept) > 0 {
|
||||
b.WriteString(" ORDER BY ")
|
||||
b.WriteString(strings.Join(kept, ", "))
|
||||
}
|
||||
return b.String(), dropped
|
||||
}
|
||||
|
||||
// loadOrgSheetColumns probes information_schema.columns once for every
|
||||
// table referenced by Table+OrderBy specs. Returns a lookup
|
||||
// {table_name → {column_name → {}}} restricted to the paliad schema.
|
||||
//
|
||||
// The queryer is whatever runs the backup's read snapshot — typically
|
||||
// the REPEATABLE READ tx opened in WriteOrg, so the schema snapshot
|
||||
// matches the row snapshot.
|
||||
func loadOrgSheetColumns(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) (map[string]map[string]struct{}, error) {
|
||||
tableSet := map[string]struct{}{}
|
||||
for _, sp := range specs {
|
||||
if sp.Table == "" {
|
||||
continue // SQL-override sheets carry their own column refs
|
||||
}
|
||||
t := sp.Table
|
||||
if i := strings.IndexByte(t, '.'); i >= 0 {
|
||||
t = t[i+1:]
|
||||
}
|
||||
tableSet[t] = struct{}{}
|
||||
}
|
||||
if len(tableSet) == 0 {
|
||||
return map[string]map[string]struct{}{}, nil
|
||||
}
|
||||
tables := make([]string, 0, len(tableSet))
|
||||
for t := range tableSet {
|
||||
tables = append(tables, t)
|
||||
}
|
||||
rows, err := queryer.QueryxContext(ctx, `
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = ANY($1)
|
||||
`, tables)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("probe paliad columns: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]map[string]struct{}, len(tableSet))
|
||||
for rows.Next() {
|
||||
var table, column string
|
||||
if err := rows.Scan(&table, &column); err != nil {
|
||||
return nil, fmt.Errorf("scan paliad columns: %w", err)
|
||||
}
|
||||
set, ok := out[table]
|
||||
if !ok {
|
||||
set = map[string]struct{}{}
|
||||
out[table] = set
|
||||
}
|
||||
set[column] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate paliad columns: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveOrgSheets materialises an org-scope spec list into the
|
||||
// concrete []sheetQuery that writeBundle expects. Composes each
|
||||
// spec's SQL via composeOrgSheetSQL using a schema snapshot loaded
|
||||
// from the same queryer. Logs WARN per dropped ORDER BY column.
|
||||
func resolveOrgSheets(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) ([]sheetQuery, error) {
|
||||
knownCols, err := loadOrgSheetColumns(ctx, queryer, specs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]sheetQuery, 0, len(specs))
|
||||
for _, sp := range specs {
|
||||
sqlText, dropped := composeOrgSheetSQL(sp, knownCols)
|
||||
for _, c := range dropped {
|
||||
slog.Warn("backup: ORDER BY column dropped (not in schema)",
|
||||
"sheet", sp.SheetName,
|
||||
"table", sp.Table,
|
||||
"column", c,
|
||||
)
|
||||
}
|
||||
out = append(out, sheetQuery{
|
||||
SheetName: sp.SheetName,
|
||||
SQL: sqlText,
|
||||
Args: sp.Args,
|
||||
DropColumns: sp.DropColumns,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*model
|
||||
var rule models.DeadlineRule
|
||||
err := c.rules.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE id = $1 AND is_active = true`, ruleID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, lp.ErrUnknownRule
|
||||
@@ -200,7 +200,7 @@ func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, subm
|
||||
var rule models.DeadlineRule
|
||||
err = c.rules.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
|
||||
pt.ID, submissionCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -311,7 +311,7 @@ func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxe
|
||||
pt.trigger_event_label_de AS pt_trigger_event_label_de,
|
||||
pt.trigger_event_label_en AS pt_trigger_event_label_en,
|
||||
pt.appeal_target AS pt_appeal_target
|
||||
FROM paliad.deadline_rules dr
|
||||
FROM paliad.deadline_rules_unified dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE ` + strings.Join(where, "\n AND ") + `
|
||||
ORDER BY dr.proceeding_type_id, dr.sequence_order`
|
||||
@@ -516,6 +516,61 @@ func computeDepths(
|
||||
return depths
|
||||
}
|
||||
|
||||
// LoadScenarios lists scenarios visible to the caller (Slice D,
|
||||
// m/paliad#124 §5, mig 145). RLS on paliad.scenarios enforces:
|
||||
// project-scoped rows require paliad.can_see_project(project_id);
|
||||
// abstract rows require created_by = auth.uid(). The filter narrows
|
||||
// the SELECT (project_id-bound, abstract-for-user, or all).
|
||||
func (c *paliadCatalog) LoadScenarios(ctx context.Context, filter lp.ScenarioFilter) ([]lp.Scenario, error) {
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
where = append(where, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if filter.ProjectID != nil {
|
||||
add("project_id = $%d", *filter.ProjectID)
|
||||
}
|
||||
if filter.AbstractForUser != nil {
|
||||
where = append(where, "project_id IS NULL")
|
||||
add("created_by = $%d", *filter.AbstractForUser)
|
||||
}
|
||||
query := `SELECT id, project_id, name, description, spec,
|
||||
created_by, created_at, updated_at
|
||||
FROM paliad.scenarios`
|
||||
if len(where) > 0 {
|
||||
query += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
var rows []lp.Scenario
|
||||
if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("load scenarios: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// MatchScenario returns the scenario with the given id, or
|
||||
// lp.ErrUnknownScenario if not visible / not found. RLS gates
|
||||
// visibility; a not-found result could mean "doesn't exist" OR
|
||||
// "exists but you can't see it" — either way the caller treats it
|
||||
// as unknown.
|
||||
func (c *paliadCatalog) MatchScenario(ctx context.Context, id uuid.UUID) (*lp.Scenario, error) {
|
||||
var s lp.Scenario
|
||||
err := c.rules.db.GetContext(ctx, &s,
|
||||
`SELECT id, project_id, name, description, spec,
|
||||
created_by, created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE id = $1`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, lp.ErrUnknownScenario
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("match scenario %q: %w", id, err)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// _ proves paliadCatalog satisfies lp.Catalog at compile time.
|
||||
var _ lp.Catalog = (*paliadCatalog)(nil)
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestLookupEvents(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=schadensbemessung returns empty (no rules seeded yet)", func(t *testing.T) {
|
||||
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,
|
||||
@@ -152,8 +152,68 @@ func TestLookupEvents(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Errorf("schadensbemessung should be empty until rules seeded; got %d rows", len(matches))
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1767,7 +1767,7 @@ func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
|
||||
ptID, code)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -1784,7 +1784,7 @@ func (s *ProjectionService) lookupRuleByID(ctx context.Context, id uuid.UUID) (*
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup rule by id: %w", err)
|
||||
|
||||
@@ -117,7 +117,7 @@ func (s *RuleEditorService) ListOrphans(ctx context.Context) ([]Orphan, error) {
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &cs, `
|
||||
SELECT id, rule_code, name, name_en
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE id = ANY($1::uuid[])`, pq.Array(uuidStrs)); err != nil {
|
||||
return nil, fmt.Errorf("list orphan candidate rules: %w", err)
|
||||
}
|
||||
@@ -221,6 +221,12 @@ func (s *RuleEditorService) ResolveOrphan(ctx context.Context, orphanID uuid.UUI
|
||||
); err != nil {
|
||||
return fmt.Errorf("set deadline rule_id: %w", err)
|
||||
}
|
||||
// Slice B.2 dual-write (t-paliad-305): mirror the new linkage onto
|
||||
// the parallel deadlines.procedural_event_id + sequencing_rule_id
|
||||
// columns so they don't drift from rule_id.
|
||||
if err := syncDeadlineDualLinks(ctx, tx, oc.DeadlineID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rule_backfill_orphans
|
||||
SET resolved_at = $1,
|
||||
|
||||
@@ -209,6 +209,14 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
return nil, fmt.Errorf("insert rule: %w", err)
|
||||
}
|
||||
|
||||
// Slice B.2 dual-write (t-paliad-305): project the new row into
|
||||
// legal_sources / procedural_events / sequencing_rules in the same
|
||||
// transaction so the parallel tables stay in lock-step with
|
||||
// deadline_rules through the B.3 read-cutover window.
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create: %w", err)
|
||||
}
|
||||
@@ -276,6 +284,10 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("update rule draft: %w", err)
|
||||
}
|
||||
// Slice B.2 dual-write (t-paliad-305).
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update: %w", err)
|
||||
}
|
||||
@@ -336,6 +348,14 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("clone rule as draft: %w", err)
|
||||
}
|
||||
// Slice B.2 dual-write (t-paliad-305): new draft gets its own
|
||||
// procedural_events + sequencing_rules row. The synthetic-code
|
||||
// branch fires here when the source rule had NULL submission_code
|
||||
// (the clone inherits the NULL and mints a fresh 'null.<8hex>'
|
||||
// derived from newID).
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, newID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit clone: %w", err)
|
||||
}
|
||||
@@ -392,6 +412,18 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
|
||||
}
|
||||
}
|
||||
|
||||
// Slice B.2 dual-write (t-paliad-305): sync both sides — the newly
|
||||
// published draft AND the cloned-from peer that just flipped to
|
||||
// archived (if any).
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current.DraftOf != nil {
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, *current.DraftOf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit publish: %w", err)
|
||||
}
|
||||
@@ -459,6 +491,12 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
|
||||
}
|
||||
}
|
||||
|
||||
// Slice B.2 dual-write (t-paliad-305): mirror the lifecycle flip
|
||||
// onto sequencing_rules + procedural_events.
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit flip: %w", err)
|
||||
}
|
||||
@@ -598,7 +636,7 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([
|
||||
where = "WHERE " + strings.Join(conds, " AND ")
|
||||
}
|
||||
query := `SELECT ` + ruleColumns + `
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
` + where + `
|
||||
ORDER BY proceeding_type_id NULLS LAST, sequence_order
|
||||
LIMIT ` + addArg(f.Limit) + ` OFFSET ` + addArg(f.Offset)
|
||||
@@ -618,7 +656,7 @@ func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.
|
||||
func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
|
||||
var r models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &r,
|
||||
`SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE id = $1`, id)
|
||||
`SELECT `+ruleColumns+` FROM paliad.deadline_rules_unified WHERE id = $1`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrRuleNotFound
|
||||
}
|
||||
@@ -677,7 +715,7 @@ func (s *RuleEditorService) validateSpawnNoCycle(ctx context.Context, ruleID *uu
|
||||
visited[current] = true
|
||||
var nexts []sql.NullInt64
|
||||
q := `SELECT DISTINCT spawn_proceeding_type_id::bigint
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1
|
||||
AND is_spawn = true
|
||||
AND spawn_proceeding_type_id IS NOT NULL
|
||||
|
||||
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)
|
||||
274
internal/services/submission_base_service.go
Normal file
274
internal/services/submission_base_service.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package services
|
||||
|
||||
// Submission base catalog service — Composer Slice A (t-paliad-313,
|
||||
// design doc docs/design-submission-generator-v2-2026-05-26.md §4.2 +
|
||||
// §5.1).
|
||||
//
|
||||
// Each row in paliad.submission_bases maps a stable slug onto a Gitea
|
||||
// path (the .docx body) plus a JSON section spec that drives the
|
||||
// editor's default section seeding. Slice A surfaces this catalog via
|
||||
// a sidebar picker and uses GetDefaultForCode to pre-fill base_id on
|
||||
// new drafts.
|
||||
//
|
||||
// Read-only — admin mutations land in Slice C's /admin/submission-bases
|
||||
// editor. Visibility is wide-open SELECT (the catalog is shared
|
||||
// firm-wide); RLS denies mutations by default.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// SubmissionBase mirrors a row in paliad.submission_bases.
|
||||
type SubmissionBase struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Slug string `db:"slug" json:"slug"`
|
||||
Firm *string `db:"firm" json:"firm,omitempty"`
|
||||
ProceedingFamily *string `db:"proceeding_family" json:"proceeding_family,omitempty"`
|
||||
LabelDE string `db:"label_de" json:"label_de"`
|
||||
LabelEN string `db:"label_en" json:"label_en"`
|
||||
DescriptionDE *string `db:"description_de" json:"description_de,omitempty"`
|
||||
DescriptionEN *string `db:"description_en" json:"description_en,omitempty"`
|
||||
GiteaPath string `db:"gitea_path" json:"gitea_path"`
|
||||
SectionSpecRaw []byte `db:"section_spec" json:"-"`
|
||||
IsDefaultForRaw pq.StringArray `db:"is_default_for" json:"-"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
|
||||
// SectionSpec is the parsed section spec; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
SectionSpec BaseSectionSpec `json:"section_spec"`
|
||||
|
||||
// IsDefaultFor is the parsed string-slice form of the
|
||||
// is_default_for column.
|
||||
IsDefaultFor []string `json:"is_default_for"`
|
||||
}
|
||||
|
||||
// BaseSectionSpec is the parsed shape of submission_bases.section_spec.
|
||||
// Slice A consumes Defaults to seed submission_sections rows on draft
|
||||
// create; later slices consume Stylemap (Slice B's MD→OOXML walker) and
|
||||
// Version (forward compat).
|
||||
type BaseSectionSpec struct {
|
||||
Version int `json:"version"`
|
||||
Stylemap map[string]string `json:"stylemap"`
|
||||
Defaults []BaseSectionSpecDefault `json:"defaults"`
|
||||
}
|
||||
|
||||
// BaseSectionSpecDefault declares one default section per base. SeedMD*
|
||||
// is the Markdown copied into submission_sections.content_md_* on draft
|
||||
// create. Empty seed = blank prose section.
|
||||
type BaseSectionSpecDefault struct {
|
||||
SectionKey string `json:"section_key"`
|
||||
Kind string `json:"kind"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
SeedMDDE string `json:"seed_md_de"`
|
||||
SeedMDEN string `json:"seed_md_en"`
|
||||
}
|
||||
|
||||
// BaseService reads the catalog. No mutations in Slice A.
|
||||
type BaseService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewBaseService wires the service.
|
||||
func NewBaseService(db *sqlx.DB) *BaseService {
|
||||
return &BaseService{db: db}
|
||||
}
|
||||
|
||||
// ErrBaseNotFound is the sentinel for "no base with that id/slug".
|
||||
var ErrBaseNotFound = errors.New("submission base: not found")
|
||||
|
||||
const baseColumns = `id, slug, firm, proceeding_family, label_de, label_en,
|
||||
description_de, description_en, gitea_path,
|
||||
section_spec, is_default_for, is_active`
|
||||
|
||||
// List returns every active base ordered by firm-then-label.
|
||||
// firmFilter (when non-empty) restricts to rows where firm matches OR
|
||||
// firm IS NULL — the picker shows the firm's own bases plus the
|
||||
// firm-agnostic ones.
|
||||
func (s *BaseService) List(ctx context.Context, firmFilter string) ([]SubmissionBase, error) {
|
||||
var rows []SubmissionBase
|
||||
var err error
|
||||
if firmFilter == "" {
|
||||
err = s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active
|
||||
ORDER BY COALESCE(firm, ''), label_de`)
|
||||
} else {
|
||||
err = s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND (firm = $1 OR firm IS NULL)
|
||||
ORDER BY (firm IS NULL), label_de`,
|
||||
firmFilter)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list submission bases: %w", err)
|
||||
}
|
||||
for i := range rows {
|
||||
if err := rows[i].decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetByID fetches one base by uuid.
|
||||
func (s *BaseService) GetByID(ctx context.Context, id uuid.UUID) (*SubmissionBase, error) {
|
||||
var b SubmissionBase
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE id = $1 AND is_active`,
|
||||
id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrBaseNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission base by id: %w", err)
|
||||
}
|
||||
if err := b.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// GetBySlug fetches one base by stable slug ("hlc-letterhead", …).
|
||||
func (s *BaseService) GetBySlug(ctx context.Context, slug string) (*SubmissionBase, error) {
|
||||
var b SubmissionBase
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE slug = $1 AND is_active`,
|
||||
slug)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrBaseNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission base by slug: %w", err)
|
||||
}
|
||||
if err := b.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// GetDefaultForCode picks the base SubmissionDraftService.Create should
|
||||
// seed for a new draft, given the requesting firm and the draft's
|
||||
// submission_code. Priority:
|
||||
//
|
||||
// 1. firm-matched base whose is_default_for[] contains the exact code.
|
||||
// 2. firm-matched base whose proceeding_family matches the code's
|
||||
// family (first three dot-segments, e.g. "de.inf.lg" from
|
||||
// "de.inf.lg.erwidg").
|
||||
// 3. firm-matched base with NULL proceeding_family (firm-agnostic
|
||||
// fallback within the firm).
|
||||
// 4. firm-NULL (cross-firm) base by family match.
|
||||
// 5. firm-NULL base with NULL family — the universal neutral fallback.
|
||||
// 6. first active row (deterministic ordering on (firm IS NULL,
|
||||
// label_de)).
|
||||
//
|
||||
// Returns ErrBaseNotFound if the table is empty.
|
||||
func (s *BaseService) GetDefaultForCode(ctx context.Context, firm, submissionCode string) (*SubmissionBase, error) {
|
||||
family := familyOfCode(submissionCode)
|
||||
|
||||
tryQueries := []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm = $1 AND $2 = ANY(is_default_for)
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{firm, submissionCode},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm = $1 AND proceeding_family = $2
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{firm, family},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm = $1 AND proceeding_family IS NULL
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{firm},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm IS NULL AND proceeding_family = $1
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{family},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm IS NULL AND proceeding_family IS NULL
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active
|
||||
ORDER BY (firm IS NULL), label_de LIMIT 1`,
|
||||
[]any{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, q := range tryQueries {
|
||||
var b SubmissionBase
|
||||
err := s.db.GetContext(ctx, &b, q.sql, q.args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get default base: %w", err)
|
||||
}
|
||||
if err := b.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
return nil, ErrBaseNotFound
|
||||
}
|
||||
|
||||
// familyOfCode returns the first three dot-segments of a submission_code.
|
||||
// "de.inf.lg.erwidg" → "de.inf.lg". Codes with fewer than three segments
|
||||
// pass through unchanged (none in the corpus today, but safe).
|
||||
func familyOfCode(code string) string {
|
||||
parts := strings.SplitN(code, ".", 4)
|
||||
if len(parts) <= 3 {
|
||||
return code
|
||||
}
|
||||
return strings.Join(parts[:3], ".")
|
||||
}
|
||||
|
||||
// decode fills the parsed views from the raw scan fields.
|
||||
func (b *SubmissionBase) decode() error {
|
||||
if len(b.SectionSpecRaw) > 0 {
|
||||
if err := json.Unmarshal(b.SectionSpecRaw, &b.SectionSpec); err != nil {
|
||||
return fmt.Errorf("decode submission base section_spec: %w", err)
|
||||
}
|
||||
}
|
||||
b.IsDefaultFor = []string(b.IsDefaultForRaw)
|
||||
if b.IsDefaultFor == nil {
|
||||
b.IsDefaultFor = []string{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
99
internal/services/submission_base_service_test.go
Normal file
99
internal/services/submission_base_service_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package services
|
||||
|
||||
// Unit tests for Composer base helpers — pure functions, no DB
|
||||
// dependency (t-paliad-313 Slice A).
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFamilyOfCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// canonical four-segment codes → first three segments
|
||||
{"de.inf.lg.erwidg", "de.inf.lg"},
|
||||
{"de.inf.lg.klage", "de.inf.lg"},
|
||||
{"de.inf.olg.berufung", "de.inf.olg"},
|
||||
{"upc.inf.cfi.soc", "upc.inf.cfi"},
|
||||
{"upc.inf.cfi.sod", "upc.inf.cfi"},
|
||||
{"upc.apl.cost.leave_app", "upc.apl.cost"},
|
||||
{"epa.opp.opd.einspruch", "epa.opp.opd"},
|
||||
// five-segment codes (rarely used in the corpus today) → still
|
||||
// truncate to three
|
||||
{"upc.inf.cfi.appeal_spawn.followup", "upc.inf.cfi"},
|
||||
// shorter codes pass through unchanged
|
||||
{"de.inf.lg", "de.inf.lg"},
|
||||
{"de.inf", "de.inf"},
|
||||
{"de", "de"},
|
||||
// empty stays empty
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := familyOfCode(tc.in); got != tc.want {
|
||||
t.Errorf("familyOfCode(%q) = %q; want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSectionSpec_DecodeShape(t *testing.T) {
|
||||
// The default seed in mig 146 emits a JSON document the service
|
||||
// must decode round-trip; this golden pins the exact field shape
|
||||
// the editor expects.
|
||||
raw := []byte(`{
|
||||
"version": 1,
|
||||
"stylemap": {
|
||||
"paragraph": "HLpat-Body-B0",
|
||||
"heading_1": "HLpat-Heading-H1",
|
||||
"heading_2": "HLpat-Heading-H2",
|
||||
"heading_3": "HLpat-Heading-H3",
|
||||
"list_bullet": "HLpat-Body-B0",
|
||||
"list_numbered": "HLpat-Body-B0",
|
||||
"blockquote": "HLpat-Body-B1"
|
||||
},
|
||||
"defaults": [
|
||||
{"section_key":"letterhead","kind":"prose","order_index":1,"label_de":"Briefkopf","label_en":"Letterhead","included":true,"seed_md_de":"hi","seed_md_en":"hi"},
|
||||
{"section_key":"requests","kind":"requests","order_index":4,"label_de":"Anträge","label_en":"Requests","included":true,"seed_md_de":"","seed_md_en":""}
|
||||
]
|
||||
}`)
|
||||
b := SubmissionBase{SectionSpecRaw: raw}
|
||||
if err := b.decode(); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if b.SectionSpec.Version != 1 {
|
||||
t.Errorf("Version = %d; want 1", b.SectionSpec.Version)
|
||||
}
|
||||
if got := b.SectionSpec.Stylemap["heading_1"]; got != "HLpat-Heading-H1" {
|
||||
t.Errorf("Stylemap[heading_1] = %q; want HLpat-Heading-H1", got)
|
||||
}
|
||||
if len(b.SectionSpec.Defaults) != 2 {
|
||||
t.Fatalf("Defaults len = %d; want 2", len(b.SectionSpec.Defaults))
|
||||
}
|
||||
first := b.SectionSpec.Defaults[0]
|
||||
if first.SectionKey != "letterhead" || first.Kind != "prose" || first.OrderIndex != 1 {
|
||||
t.Errorf("Defaults[0] = %+v; want letterhead/prose/1", first)
|
||||
}
|
||||
if first.SeedMDDE != "hi" || first.SeedMDEN != "hi" {
|
||||
t.Errorf("Defaults[0] seed_md_* = %q/%q; want hi/hi", first.SeedMDDE, first.SeedMDEN)
|
||||
}
|
||||
second := b.SectionSpec.Defaults[1]
|
||||
if second.SectionKey != "requests" || second.Kind != "requests" || second.OrderIndex != 4 {
|
||||
t.Errorf("Defaults[1] = %+v; want requests/requests/4", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSectionSpec_EmptyDecode(t *testing.T) {
|
||||
// A bare row (SectionSpecRaw == nil) decodes cleanly into the
|
||||
// zero value — no panic, no garbage.
|
||||
b := SubmissionBase{}
|
||||
if err := b.decode(); err != nil {
|
||||
t.Fatalf("decode empty: %v", err)
|
||||
}
|
||||
if b.SectionSpec.Version != 0 || len(b.SectionSpec.Defaults) != 0 {
|
||||
t.Errorf("expected zero SectionSpec on empty raw; got %+v", b.SectionSpec)
|
||||
}
|
||||
if b.IsDefaultFor == nil {
|
||||
t.Errorf("IsDefaultFor must be non-nil (empty slice) after decode; got nil")
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,17 @@ type SubmissionDraft struct {
|
||||
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
|
||||
LastImportedAt *time.Time `db:"last_imported_at" json:"last_imported_at,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// BaseID is the Composer base reference (t-paliad-313). NULL on
|
||||
// pre-Composer drafts — the v1 render path stays the fallback.
|
||||
// ON DELETE SET NULL keeps a draft renderable if its base is
|
||||
// removed; the lawyer picks a new one via the sidebar.
|
||||
BaseID *uuid.UUID `db:"base_id" json:"base_id,omitempty"`
|
||||
// ComposerMetaRaw / ComposerMeta — Composer-side metadata jsonb.
|
||||
// Slice A: empty default. Future slices populate section_order,
|
||||
// hidden_sections, etc.
|
||||
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// Variables is the decoded overrides map; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
@@ -70,15 +79,36 @@ type SubmissionDraft struct {
|
||||
// the backward-compat "include every party" behaviour; a non-empty
|
||||
// slice restricts the variable bag to the listed paliad.parties rows.
|
||||
SelectedParties []uuid.UUID `json:"selected_parties"`
|
||||
|
||||
// ComposerMeta is the parsed Composer-side metadata (t-paliad-313).
|
||||
// Slice A: typically empty. Populated on read by decodeComposerMeta().
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
}
|
||||
|
||||
// SubmissionDraftService handles CRUD on submission_drafts and exposes
|
||||
// the render/preview/export entry points the handler layer calls.
|
||||
//
|
||||
// The Composer wiring (t-paliad-313, Slice A): bases + sections are
|
||||
// optional — when nil the service stays back-compat with the v1 shape
|
||||
// (drafts created without a base_id, no section rows). When wired, new
|
||||
// drafts created via Create get base_id seeded from the firm default
|
||||
// and submission_sections rows inserted from the base's section spec.
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
vars *SubmissionVarsService
|
||||
renderer *SubmissionRenderer
|
||||
|
||||
// bases + sections are optional Composer wiring (t-paliad-313).
|
||||
// Nil means "stay back-compat with the v1 shape" — new drafts
|
||||
// keep base_id NULL and no submission_sections rows get seeded.
|
||||
bases *BaseService
|
||||
sections *SectionService
|
||||
|
||||
// firmName captures branding.Name at construction time. Used to
|
||||
// resolve the firm-default base in Create. Empty string is
|
||||
// allowed (treated as "no firm filter" at base-lookup time).
|
||||
firmName string
|
||||
}
|
||||
|
||||
// NewSubmissionDraftService wires the service.
|
||||
@@ -91,6 +121,19 @@ func NewSubmissionDraftService(db *sqlx.DB, projects *ProjectService, vars *Subm
|
||||
}
|
||||
}
|
||||
|
||||
// AttachComposer wires the Composer-side services. Called by
|
||||
// cmd/server/main.go after constructing the base + section services.
|
||||
// firm is branding.Name (typically "HLC"); empty string disables the
|
||||
// firm filter at default-base lookup.
|
||||
//
|
||||
// Calling AttachComposer is purely additive — drafts created before the
|
||||
// call (or with bases==nil) keep the v1 behaviour. Idempotent.
|
||||
func (s *SubmissionDraftService) AttachComposer(bases *BaseService, sections *SectionService, firm string) {
|
||||
s.bases = bases
|
||||
s.sections = sections
|
||||
s.firmName = firm
|
||||
}
|
||||
|
||||
// DraftPatch carries optional fields for Update. nil pointer = "no
|
||||
// change"; non-nil = "set to this". Variables is replace-semantics —
|
||||
// the lawyer's sidebar sends the full map every save.
|
||||
@@ -117,6 +160,16 @@ type DraftPatch struct {
|
||||
// Language sets the output language. Valid values: "de", "en".
|
||||
// Anything else returns ErrInvalidInput. t-paliad-276.
|
||||
Language *string
|
||||
|
||||
// BaseID swaps the Composer base. Two-level pointer mirrors the
|
||||
// ProjectID shape so callers can encode the three operations:
|
||||
// nil → no change
|
||||
// *p == nil → clear (set base_id NULL, return to v1 fallback)
|
||||
// **p → set to the picked base
|
||||
// Slice A: lawyer flips this from the sidebar picker. Section
|
||||
// content is unaffected — the base swap is render-side only.
|
||||
// t-paliad-313.
|
||||
BaseID **uuid.UUID
|
||||
}
|
||||
|
||||
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
|
||||
@@ -133,6 +186,7 @@ const draftColumns = `id, project_id, submission_code, user_id, name, language,
|
||||
variables, selected_parties,
|
||||
last_exported_at, last_exported_sha,
|
||||
last_imported_at,
|
||||
base_id, composer_meta,
|
||||
created_at, updated_at`
|
||||
|
||||
// List returns every draft for (project, submission_code, user)
|
||||
@@ -185,6 +239,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
|
||||
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
|
||||
d.variables, d.selected_parties,
|
||||
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
|
||||
d.base_id, d.composer_meta,
|
||||
d.created_at, d.updated_at,
|
||||
p.title AS project_title,
|
||||
p.reference AS project_reference
|
||||
@@ -279,6 +334,14 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
|
||||
// A nil projectID creates a project-less draft (t-paliad-243); the
|
||||
// visibility check is skipped — the caller is the owner and the row is
|
||||
// private to them.
|
||||
//
|
||||
// Composer wiring (t-paliad-313, Slice A): when AttachComposer has
|
||||
// been called and a base resolves for the submission_code, the INSERT
|
||||
// runs in a transaction alongside SectionService.SeedFromSpec so the
|
||||
// new draft and its seeded sections land atomically. If the base
|
||||
// lookup fails (catalog empty, no firm match, etc.) the draft still
|
||||
// creates with base_id=NULL — Composer is additive, the v1 fallback
|
||||
// path remains valid.
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
if projectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
|
||||
@@ -294,16 +357,61 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
|
||||
// Anything other than "en" normalizes to "de" — matches the DB CHECK
|
||||
// constraint and the project's primary-language default.
|
||||
draftLang := normalizeDraftLanguage(lang)
|
||||
|
||||
// Resolve the Composer base for this draft. nil result keeps the
|
||||
// draft v1-shaped (base_id NULL, no sections rows).
|
||||
var baseToSeed *SubmissionBase
|
||||
if s.bases != nil {
|
||||
base, err := s.bases.GetDefaultForCode(ctx, s.firmName, submissionCode)
|
||||
switch {
|
||||
case err == nil:
|
||||
baseToSeed = base
|
||||
case errors.Is(err, ErrBaseNotFound):
|
||||
// Catalog empty / no match — fall through to v1 shape.
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin create submission draft tx: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var baseID *uuid.UUID
|
||||
if baseToSeed != nil {
|
||||
id := baseToSeed.ID
|
||||
baseID = &id
|
||||
}
|
||||
|
||||
var d SubmissionDraft
|
||||
err = s.db.GetContext(ctx, &d,
|
||||
err = tx.GetContext(ctx, &d,
|
||||
`INSERT INTO paliad.submission_drafts
|
||||
(project_id, submission_code, user_id, name, language)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
(project_id, submission_code, user_id, name, language, base_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING `+draftColumns,
|
||||
projectID, submissionCode, userID, name, draftLang)
|
||||
projectID, submissionCode, userID, name, draftLang, baseID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create submission draft: %w", err)
|
||||
}
|
||||
|
||||
if baseToSeed != nil && s.sections != nil {
|
||||
if err := s.sections.SeedFromSpec(ctx, tx, d.ID, baseToSeed.SectionSpec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create submission draft tx: %w", err)
|
||||
}
|
||||
committed = true
|
||||
|
||||
if err := d.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -446,6 +554,18 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.BaseID != nil {
|
||||
newBID := *patch.BaseID // *uuid.UUID — nil means clear
|
||||
if newBID != nil && s.bases != nil {
|
||||
// Validate the picked base exists + is active.
|
||||
if _, err := s.bases.GetByID(ctx, *newBID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("base_id = $%d", idx))
|
||||
args = append(args, newBID)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return existing, nil
|
||||
@@ -682,14 +802,32 @@ func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, us
|
||||
return out, resolved, nil
|
||||
}
|
||||
|
||||
// decode fills the parsed views (Variables, SelectedParties) from the
|
||||
// raw scan fields. Called by every fetch path so the caller sees both
|
||||
// populated together.
|
||||
// decode fills the parsed views (Variables, SelectedParties,
|
||||
// ComposerMeta) from the raw scan fields. Called by every fetch path
|
||||
// so the caller sees them populated together.
|
||||
func (d *SubmissionDraft) decode() error {
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.decodeSelectedParties()
|
||||
if err := d.decodeSelectedParties(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.decodeComposerMeta()
|
||||
}
|
||||
|
||||
// decodeComposerMeta turns the raw composer_meta jsonb into a
|
||||
// map[string]any. NULL or empty payload yields an empty map.
|
||||
func (d *SubmissionDraft) decodeComposerMeta() error {
|
||||
if len(d.ComposerMetaRaw) == 0 {
|
||||
d.ComposerMeta = map[string]any{}
|
||||
return nil
|
||||
}
|
||||
out := map[string]any{}
|
||||
if err := json.Unmarshal(d.ComposerMetaRaw, &out); err != nil {
|
||||
return fmt.Errorf("decode submission draft composer_meta: %w", err)
|
||||
}
|
||||
d.ComposerMeta = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
|
||||
|
||||
134
internal/services/submission_section_service.go
Normal file
134
internal/services/submission_section_service.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package services
|
||||
|
||||
// Submission section service — Composer Slice A (t-paliad-313, design
|
||||
// doc docs/design-submission-generator-v2-2026-05-26.md §4.3 + §6).
|
||||
//
|
||||
// Each row in paliad.submission_sections is one ordered, named block
|
||||
// inside a Composer draft. Slice A seeds rows on draft create from the
|
||||
// base's section_spec.defaults and exposes them read-only for the
|
||||
// editor's section-list pane. Slice B turns them editable, Slice F
|
||||
// adds reorder/hide/add-custom.
|
||||
//
|
||||
// Visibility flows through draft_id → submission_drafts → owner-scoped
|
||||
// + can_see_project (RLS in mig 148 mirrors the four-policy shape on
|
||||
// submission_drafts). Service calls go through SubmissionDraftService
|
||||
// for the visibility gate before touching this table.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// SubmissionSection mirrors a row in paliad.submission_sections.
|
||||
type SubmissionSection struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
DraftID uuid.UUID `db:"draft_id" json:"draft_id"`
|
||||
SectionKey string `db:"section_key" json:"section_key"`
|
||||
OrderIndex int `db:"order_index" json:"order_index"`
|
||||
Kind string `db:"kind" json:"kind"`
|
||||
LabelDE string `db:"label_de" json:"label_de"`
|
||||
LabelEN string `db:"label_en" json:"label_en"`
|
||||
Included bool `db:"included" json:"included"`
|
||||
ContentMDDE string `db:"content_md_de" json:"content_md_de"`
|
||||
ContentMDEN string `db:"content_md_en" json:"content_md_en"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// SectionService handles per-draft section rows. Slice A: read + seed
|
||||
// only. Editable mutations land in Slice B's brief.
|
||||
type SectionService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewSectionService wires the service.
|
||||
func NewSectionService(db *sqlx.DB) *SectionService {
|
||||
return &SectionService{db: db}
|
||||
}
|
||||
|
||||
// ErrSubmissionSectionNotFound is the sentinel for "no section with
|
||||
// that id visible to this user".
|
||||
var ErrSubmissionSectionNotFound = errors.New("submission section: not found")
|
||||
|
||||
const sectionColumns = `id, draft_id, section_key, order_index, kind,
|
||||
label_de, label_en, included,
|
||||
content_md_de, content_md_en,
|
||||
created_at, updated_at`
|
||||
|
||||
// ListForDraft returns every section row for a draft, ordered by
|
||||
// order_index ASC. Caller is responsible for the visibility gate
|
||||
// (SubmissionDraftService.Get returns ErrSubmissionDraftNotFound for
|
||||
// un-visible drafts, which the handler maps to 404). RLS in mig 148
|
||||
// additionally enforces owner-scope at the DB layer.
|
||||
func (s *SectionService) ListForDraft(ctx context.Context, draftID uuid.UUID) ([]SubmissionSection, error) {
|
||||
var rows []SubmissionSection
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+sectionColumns+`
|
||||
FROM paliad.submission_sections
|
||||
WHERE draft_id = $1
|
||||
ORDER BY order_index ASC`,
|
||||
draftID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list submission sections: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Get returns one section by id. Visibility gate is the caller's
|
||||
// responsibility — Slice A handlers wrap this with a SubmissionDraftService.Get
|
||||
// to enforce owner+can_see_project before exposing the section.
|
||||
func (s *SectionService) Get(ctx context.Context, sectionID uuid.UUID) (*SubmissionSection, error) {
|
||||
var sec SubmissionSection
|
||||
err := s.db.GetContext(ctx, &sec,
|
||||
`SELECT `+sectionColumns+`
|
||||
FROM paliad.submission_sections
|
||||
WHERE id = $1`,
|
||||
sectionID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionSectionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission section: %w", err)
|
||||
}
|
||||
return &sec, nil
|
||||
}
|
||||
|
||||
// SeedFromSpec inserts one row per BaseSectionSpec.Default into
|
||||
// submission_sections for the given draft. Runs inside the caller's
|
||||
// transaction (the SubmissionDraftService.Create path wraps the
|
||||
// draft INSERT + section seed in one tx so a failed seed rolls back
|
||||
// the draft too).
|
||||
//
|
||||
// Idempotent at the row level — UNIQUE (draft_id, section_key) returns
|
||||
// an error if the seed runs twice for the same draft, which is the
|
||||
// desired safety net (we never want to silently double-seed).
|
||||
//
|
||||
// Per the Q10 ratification: every kind is one of prose | requests |
|
||||
// evidence — there is no *_auto kind. Caption/letterhead/signature
|
||||
// sections are regular prose rows seeded with bag-driven Markdown.
|
||||
func (s *SectionService) SeedFromSpec(ctx context.Context, tx *sqlx.Tx, draftID uuid.UUID, spec BaseSectionSpec) error {
|
||||
if len(spec.Defaults) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, d := range spec.Defaults {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.submission_sections
|
||||
(draft_id, section_key, order_index, kind,
|
||||
label_de, label_en, included,
|
||||
content_md_de, content_md_en)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
draftID, d.SectionKey, d.OrderIndex, d.Kind,
|
||||
d.LabelDE, d.LabelEN, d.Included,
|
||||
d.SeedMDDE, d.SeedMDEN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed submission section %s: %w", d.SectionKey, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
178
internal/services/submission_section_service_live_test.go
Normal file
178
internal/services/submission_section_service_live_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package services
|
||||
|
||||
// Live-DB integration tests for the Composer seeding flow (t-paliad-313
|
||||
// Slice A). Skipped when TEST_DATABASE_URL is unset, mirroring the
|
||||
// other live-DB tests (see cansee_test.go for the bootstrap pattern).
|
||||
//
|
||||
// Covers:
|
||||
// 1. Mig 146 seeded the catalog: hlc-letterhead + neutral both
|
||||
// resolve via GetBySlug and carry 10 section defaults each.
|
||||
// 2. BaseService.GetDefaultForCode picks the firm-matched base for a
|
||||
// canonical submission_code (e.g. de.inf.lg.erwidg) — Slice A
|
||||
// contract that drives new-draft seeding.
|
||||
// 3. SubmissionDraftService.Create on a fresh draft seeds base_id +
|
||||
// 10 submission_sections rows in one transaction, with order_index
|
||||
// ascending and bilingual labels populated.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestComposerSeedFlow(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()
|
||||
|
||||
bases := NewBaseService(pool)
|
||||
|
||||
t.Run("seed catalog: hlc-letterhead has 10 default sections", func(t *testing.T) {
|
||||
b, err := bases.GetBySlug(ctx, "hlc-letterhead")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBySlug(hlc-letterhead): %v", err)
|
||||
}
|
||||
if got := len(b.SectionSpec.Defaults); got != 10 {
|
||||
t.Errorf("len(Defaults) = %d; want 10", got)
|
||||
}
|
||||
if b.SectionSpec.Stylemap["heading_1"] != "HLpat-Heading-H1" {
|
||||
t.Errorf("Stylemap[heading_1] = %q; want HLpat-Heading-H1", b.SectionSpec.Stylemap["heading_1"])
|
||||
}
|
||||
// Verify the section order is strictly ascending.
|
||||
prev := 0
|
||||
for _, d := range b.SectionSpec.Defaults {
|
||||
if d.OrderIndex <= prev {
|
||||
t.Errorf("non-ascending order_index: %d (prev=%d) at %s", d.OrderIndex, prev, d.SectionKey)
|
||||
}
|
||||
prev = d.OrderIndex
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("seed catalog: neutral exists with universal stylemap", func(t *testing.T) {
|
||||
b, err := bases.GetBySlug(ctx, "neutral")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBySlug(neutral): %v", err)
|
||||
}
|
||||
if b.SectionSpec.Stylemap["heading_1"] != "Heading 1" {
|
||||
t.Errorf("neutral Stylemap[heading_1] = %q; want \"Heading 1\"", b.SectionSpec.Stylemap["heading_1"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetDefaultForCode firm match", func(t *testing.T) {
|
||||
// HLC + de.inf.lg.erwidg → hlc-letterhead (firm-matched).
|
||||
b, err := bases.GetDefaultForCode(ctx, "HLC", "de.inf.lg.erwidg")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefaultForCode HLC: %v", err)
|
||||
}
|
||||
if b.Slug != "hlc-letterhead" {
|
||||
t.Errorf("Slug = %q; want hlc-letterhead", b.Slug)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetDefaultForCode falls back to neutral when no firm hint", func(t *testing.T) {
|
||||
b, err := bases.GetDefaultForCode(ctx, "", "de.inf.lg.erwidg")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefaultForCode no-firm: %v", err)
|
||||
}
|
||||
// Without a firm hint, the fallback chain skips firm-matched
|
||||
// queries and lands on the firm-NULL neutral base.
|
||||
if b.Slug != "neutral" {
|
||||
t.Errorf("Slug = %q; want neutral (firm-NULL fallback)", b.Slug)
|
||||
}
|
||||
})
|
||||
|
||||
// Section seeding via SubmissionDraftService.Create — exercises the
|
||||
// transactional INSERT path. Requires a real auth.users + paliad.users
|
||||
// row because submission_drafts.user_id is FK-constrained.
|
||||
t.Run("SubmissionDraftService.Create seeds 10 section rows", func(t *testing.T) {
|
||||
userID := uuid.New()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_sections WHERE draft_id IN (SELECT id FROM paliad.submission_drafts WHERE user_id = $1)`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
email := "composer-seed-" + userID.String()[:8] + "@hlc.com"
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, $2, 'Composer Seed', 'munich', 'standard', 'de')`,
|
||||
userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
sections := NewSectionService(pool)
|
||||
drafts.AttachComposer(bases, sections, "HLC")
|
||||
|
||||
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if d.BaseID == nil {
|
||||
t.Fatalf("BaseID = nil; want seeded base reference")
|
||||
}
|
||||
// hlc-letterhead is the firm default for HLC.
|
||||
base, _ := bases.GetByID(ctx, *d.BaseID)
|
||||
if base == nil || base.Slug != "hlc-letterhead" {
|
||||
t.Errorf("seeded base slug = %v; want hlc-letterhead", base)
|
||||
}
|
||||
|
||||
secs, err := sections.ListForDraft(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForDraft: %v", err)
|
||||
}
|
||||
if len(secs) != 10 {
|
||||
t.Errorf("section count = %d; want 10", len(secs))
|
||||
}
|
||||
// Verify section_key set + bilingual labels populated.
|
||||
wantKeys := map[string]bool{
|
||||
"letterhead": false, "caption": false, "introduction": false,
|
||||
"requests": false, "facts": false, "legal_argument": false,
|
||||
"evidence": false, "exhibits": false, "closing": false, "signature": false,
|
||||
}
|
||||
prev := 0
|
||||
for _, sec := range secs {
|
||||
wantKeys[sec.SectionKey] = true
|
||||
if sec.OrderIndex <= prev {
|
||||
t.Errorf("non-ascending order_index: %d (prev=%d) at %s", sec.OrderIndex, prev, sec.SectionKey)
|
||||
}
|
||||
prev = sec.OrderIndex
|
||||
if sec.LabelDE == "" || sec.LabelEN == "" {
|
||||
t.Errorf("section %s missing bilingual label: de=%q en=%q", sec.SectionKey, sec.LabelDE, sec.LabelEN)
|
||||
}
|
||||
}
|
||||
for k, seen := range wantKeys {
|
||||
if !seen {
|
||||
t.Errorf("missing seeded section_key: %s", k)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissio
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
|
||||
58
pkg/litigationplanner/appeal_role.go
Normal file
58
pkg/litigationplanner/appeal_role.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package litigationplanner
|
||||
|
||||
// AppealRole* are the canonical filer-role slugs used by the unified
|
||||
// upc.apl Berufung proceeding (t-paliad-307 / m/paliad#136 Bug 1).
|
||||
//
|
||||
// Every appeal filing rule carries primary_party='both' in the catalog
|
||||
// (either party could be the appellant, depending on which side lost
|
||||
// downstream), so the static primary_party column can't drive
|
||||
// column-bucketing under a user-perspective `?side=` pick. The
|
||||
// per-rule appeal role fills that gap: "appellant" rules are filed by
|
||||
// the Berufungskläger (the party who lost in the lower instance and
|
||||
// is now appealing); "appellee" rules are filed by the
|
||||
// Berufungsbeklagter (the party defending the lower-instance
|
||||
// decision). The mapping is rule-semantic, not data-driven — we know
|
||||
// from R.224/235 which submission belongs to which side.
|
||||
const (
|
||||
AppealRoleAppellant = "appellant"
|
||||
AppealRoleAppellee = "appellee"
|
||||
)
|
||||
|
||||
// AppealFilerRole returns the appeal-filer role for a submission code
|
||||
// in the unified upc.apl proceeding. Empty string for codes whose role
|
||||
// is not statically known (court-issued events, unmapped codes, or
|
||||
// non-appeal proceedings).
|
||||
//
|
||||
// The engine stamps TimelineEntry.AppealRole with this value when
|
||||
// CalcOptions.AppealTarget is set so the frontend column-bucketer can
|
||||
// route each "both"-party rule into the correct user-perspective
|
||||
// column (Berufungskläger vs Berufungsbeklagter) once the user picks
|
||||
// a side.
|
||||
//
|
||||
// Adding a new appeal rule? Add its submission_code to the matching
|
||||
// branch below. Court-issued events (cost.decision, order.order,
|
||||
// merits.oral, merits.decision) deliberately stay empty — they route
|
||||
// to the court column on primary_party='court'.
|
||||
func AppealFilerRole(submissionCode string) string {
|
||||
switch submissionCode {
|
||||
// Appellant filings — Berufungskläger initiates the appeal +
|
||||
// replies to the cross-appeal.
|
||||
case "upc.apl.merits.notice",
|
||||
"upc.apl.merits.grounds",
|
||||
"upc.apl.merits.cross_a_reply",
|
||||
"upc.apl.cost.leave_app",
|
||||
"upc.apl.order.with_leave",
|
||||
"upc.apl.order.grounds_orders",
|
||||
"upc.apl.order.discretion",
|
||||
"upc.apl.order.cross_reply":
|
||||
return AppealRoleAppellant
|
||||
// Appellee filings — Berufungsbeklagter responds to the appeal +
|
||||
// files the cross-appeal.
|
||||
case "upc.apl.merits.response",
|
||||
"upc.apl.merits.cross_a",
|
||||
"upc.apl.order.response_orders",
|
||||
"upc.apl.order.cross":
|
||||
return AppealRoleAppellee
|
||||
}
|
||||
return ""
|
||||
}
|
||||
192
pkg/litigationplanner/appeal_role_test.go
Normal file
192
pkg/litigationplanner/appeal_role_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestAppealFilerRole pins the rule-semantic mapping that drives
|
||||
// column-bucketing on the unified upc.apl Berufung timeline
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1). Every appeal filing rule has
|
||||
// primary_party='both' in the catalog so the bucketer can't decide
|
||||
// between Berufungskläger and Berufungsbeklagter columns from
|
||||
// primary_party alone — the appeal role fills that gap.
|
||||
func TestAppealFilerRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
code string
|
||||
want string
|
||||
}{
|
||||
// Appellant filings (Berufungskläger initiates / replies to cross).
|
||||
{"upc.apl.merits.notice", AppealRoleAppellant},
|
||||
{"upc.apl.merits.grounds", AppealRoleAppellant},
|
||||
{"upc.apl.merits.cross_a_reply", AppealRoleAppellant},
|
||||
{"upc.apl.cost.leave_app", AppealRoleAppellant},
|
||||
{"upc.apl.order.with_leave", AppealRoleAppellant},
|
||||
{"upc.apl.order.grounds_orders", AppealRoleAppellant},
|
||||
{"upc.apl.order.discretion", AppealRoleAppellant},
|
||||
{"upc.apl.order.cross_reply", AppealRoleAppellant},
|
||||
// Appellee filings (Berufungsbeklagter responds + cross-appeals).
|
||||
{"upc.apl.merits.response", AppealRoleAppellee},
|
||||
{"upc.apl.merits.cross_a", AppealRoleAppellee},
|
||||
{"upc.apl.order.response_orders", AppealRoleAppellee},
|
||||
{"upc.apl.order.cross", AppealRoleAppellee},
|
||||
// Court-issued events stay empty — they route on party='court'.
|
||||
{"upc.apl.merits.decision", ""},
|
||||
{"upc.apl.merits.oral", ""},
|
||||
{"upc.apl.cost.decision", ""},
|
||||
{"upc.apl.order.order", ""},
|
||||
// Unmapped codes are empty (defensive — never silently picks a
|
||||
// side for a new appeal rule we forgot to map).
|
||||
{"upc.inf.cfi.soc", ""},
|
||||
{"", ""},
|
||||
{"foo.bar", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := AppealFilerRole(c.code); got != c.want {
|
||||
t.Errorf("AppealFilerRole(%q) = %q, want %q", c.code, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_AppealSyntheticTriggerRow exercises the synthetic root
|
||||
// row the engine prepends when CalcOptions.AppealTarget is set
|
||||
// (t-paliad-307 / m/paliad#136 Bug 2). The row carries the
|
||||
// per-appeal-target label, the trigger date as DueDate, IsRootEvent=
|
||||
// IsTriggerEvent=true, and party=court. Without the appeal_target
|
||||
// filter, no synthetic row is emitted (regression guard).
|
||||
func TestCalculate_AppealSyntheticTriggerRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
jurisdiction := "UPC"
|
||||
procID := 1
|
||||
pt := ProceedingType{
|
||||
ID: procID,
|
||||
Code: "upc.apl.unified",
|
||||
Name: "Berufung",
|
||||
NameEN: "Appeal",
|
||||
Jurisdiction: &jurisdiction,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
mkID := func() uuid.UUID {
|
||||
id, _ := uuid.NewRandom()
|
||||
return id
|
||||
}
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &procID
|
||||
|
||||
noticeCode := "upc.apl.merits.notice"
|
||||
groundsCode := "upc.apl.merits.grounds"
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: mkID(),
|
||||
ProceedingTypeID: procIDPtr,
|
||||
SubmissionCode: ¬iceCode,
|
||||
Name: "Berufungseinlegung",
|
||||
NameEN: "Notice of Appeal",
|
||||
PrimaryParty: str(PrimaryPartyBoth),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 0,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
|
||||
},
|
||||
{
|
||||
ID: mkID(),
|
||||
ProceedingTypeID: procIDPtr,
|
||||
SubmissionCode: &groundsCode,
|
||||
Name: "Berufungsbegründung",
|
||||
NameEN: "Statement of Grounds",
|
||||
PrimaryParty: str(PrimaryPartyBoth),
|
||||
DurationValue: 4,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 1,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
|
||||
},
|
||||
}
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
t.Run("with appeal_target — synthetic row prepended + appeal_role stamped", func(t *testing.T) {
|
||||
opts := CalcOptions{AppealTarget: AppealTargetEndentscheidung}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
if len(timeline.Deadlines) < 3 {
|
||||
t.Fatalf("expected synthetic row + 2 rules, got %d rows", len(timeline.Deadlines))
|
||||
}
|
||||
// Synthetic row first.
|
||||
first := timeline.Deadlines[0]
|
||||
if !first.IsTriggerEvent {
|
||||
t.Errorf("first row IsTriggerEvent=%v, want true", first.IsTriggerEvent)
|
||||
}
|
||||
if !first.IsRootEvent {
|
||||
t.Errorf("first row IsRootEvent=%v, want true", first.IsRootEvent)
|
||||
}
|
||||
if first.Name != "Endentscheidung (R.118)" {
|
||||
t.Errorf("first row Name=%q, want %q", first.Name, "Endentscheidung (R.118)")
|
||||
}
|
||||
if first.NameEN != "Final decision (R.118)" {
|
||||
t.Errorf("first row NameEN=%q, want %q", first.NameEN, "Final decision (R.118)")
|
||||
}
|
||||
if first.DueDate != "2026-05-26" {
|
||||
t.Errorf("first row DueDate=%q, want 2026-05-26", first.DueDate)
|
||||
}
|
||||
if first.Party != PrimaryPartyCourt {
|
||||
t.Errorf("first row Party=%q, want court", first.Party)
|
||||
}
|
||||
// Real rules should carry AppealRole.
|
||||
byCode := map[string]TimelineEntry{}
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
if got := byCode[noticeCode].AppealRole; got != AppealRoleAppellant {
|
||||
t.Errorf("notice AppealRole=%q, want appellant", got)
|
||||
}
|
||||
if got := byCode[groundsCode].AppealRole; got != AppealRoleAppellant {
|
||||
t.Errorf("grounds AppealRole=%q, want appellant", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without appeal_target — no synthetic row, no appeal_role", func(t *testing.T) {
|
||||
opts := CalcOptions{}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
for _, d := range timeline.Deadlines {
|
||||
if d.IsTriggerEvent {
|
||||
t.Errorf("unexpected synthetic trigger row when appeal_target is unset: %+v", d)
|
||||
}
|
||||
if d.AppealRole != "" {
|
||||
t.Errorf("unexpected AppealRole=%q when appeal_target is unset (rule %q)", d.AppealRole, d.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown appeal_target — short-circuits to no-op", func(t *testing.T) {
|
||||
opts := CalcOptions{AppealTarget: "bogus"}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
// IsValidAppealTarget("bogus") = false, so the engine skips
|
||||
// both the rule filter AND the synthetic trigger emission.
|
||||
for _, d := range timeline.Deadlines {
|
||||
if d.IsTriggerEvent {
|
||||
t.Errorf("unexpected synthetic trigger row for unknown target: %+v", d)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package litigationplanner
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Catalog supplies proceeding-type metadata + rules for the calculator.
|
||||
//
|
||||
@@ -59,4 +63,17 @@ type Catalog interface {
|
||||
// (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)
|
||||
}
|
||||
|
||||
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 @@
|
||||
[]
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -183,10 +184,27 @@ func Calculate(
|
||||
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
|
||||
}
|
||||
|
||||
// Walk the rule list in sequence_order (already sorted by the
|
||||
// catalog query) and compute each entry, keeping a code→date map so
|
||||
// RelativeTo / parent_id references resolve to the adjusted
|
||||
// predecessor date.
|
||||
// Walk the rule list in TOPOLOGICAL order (parents before children),
|
||||
// not the raw sequence_order order from the catalog. The catalog
|
||||
// returns rules sorted by sequence_order, which is the chronological/
|
||||
// display order. That order is parent-first for the common
|
||||
// timing='after' case but parent-LAST for timing='before' children
|
||||
// (e.g. upc.inf.cfi.translation_request at seq=45 vs its parent
|
||||
// upc.inf.cfi.oral at seq=50 — m/paliad#135). Without topological
|
||||
// ordering the parent-state checks below (courtSet[parent] /
|
||||
// computed[parent_code]) read stale empty maps when a child appears
|
||||
// before its parent, and the engine falls back to the trigger date
|
||||
// → fabricates dates before the SoC.
|
||||
//
|
||||
// Original sequence_order is restored at the end of the walk so the
|
||||
// wire shape and the timeline view's render order stay identical to
|
||||
// the legacy behaviour modulo the bug fix.
|
||||
sequenceIndex := make(map[uuid.UUID]int, len(rules))
|
||||
for i, r := range rules {
|
||||
sequenceIndex[r.ID] = i
|
||||
}
|
||||
walkRules := topoSortByParentDepth(rules)
|
||||
|
||||
computed := make(map[string]time.Time, len(rules))
|
||||
courtSet := make(map[uuid.UUID]bool, len(rules))
|
||||
deadlines := make([]TimelineEntry, 0, len(rules))
|
||||
@@ -197,7 +215,7 @@ func Calculate(
|
||||
hiddenCount := 0
|
||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||
|
||||
for _, r := range rules {
|
||||
for _, r := range walkRules {
|
||||
// Phase-3 unified gate: evaluate condition_expr (jsonb).
|
||||
// Suppression semantic preserved: when the gate fires false
|
||||
// AND no alt_* values exist, the rule is dropped from the
|
||||
@@ -249,6 +267,10 @@ func Calculate(
|
||||
appellantContext[r.ID] = ctxVal
|
||||
}
|
||||
|
||||
ruleTiming := ""
|
||||
if r.Timing != nil {
|
||||
ruleTiming = *r.Timing
|
||||
}
|
||||
d := TimelineEntry{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
@@ -258,6 +280,9 @@ func Calculate(
|
||||
AppellantContext: ctxVal,
|
||||
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
|
||||
IsHidden: isHidden,
|
||||
DurationValue: r.DurationValue,
|
||||
DurationUnit: r.DurationUnit,
|
||||
Timing: ruleTiming,
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
@@ -547,6 +572,35 @@ func Calculate(
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
// Stamp AppealRole on every entry when an appeal-target filter is
|
||||
// active so the frontend column-bucketer can route primary_party=
|
||||
// 'both' rules into the user-perspective columns
|
||||
// (Berufungskläger vs Berufungsbeklagter). Court events stay empty
|
||||
// — they route on Party='court' regardless. (t-paliad-307 /
|
||||
// m/paliad#136 Bug 1)
|
||||
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
||||
for i := range deadlines {
|
||||
if deadlines[i].Code == "" {
|
||||
continue
|
||||
}
|
||||
deadlines[i].AppealRole = AppealFilerRole(deadlines[i].Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore sequence_order on the output slice. The compute walk
|
||||
// re-ordered rules topologically (parent-first) so the parent-state
|
||||
// checks resolved correctly; the wire shape and the linear timeline
|
||||
// view both rely on sequence_order being the surface render order.
|
||||
// (m/paliad#135)
|
||||
sort.SliceStable(deadlines, func(i, j int) bool {
|
||||
a, errA := uuid.Parse(deadlines[i].RuleID)
|
||||
b, errB := uuid.Parse(deadlines[j].RuleID)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
return sequenceIndex[a] < sequenceIndex[b]
|
||||
})
|
||||
|
||||
// t-paliad-296: within consecutive runs of rules sharing the same
|
||||
// trigger group (parent_id + trigger_event_id), reorder by duration
|
||||
// ascending so optional events following the same anchor render in
|
||||
@@ -555,6 +609,31 @@ func Calculate(
|
||||
// same-group rows. Court-set / conditional rows sort LAST.
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// Synthetic trigger-event row for appeal timelines (t-paliad-307 /
|
||||
// m/paliad#136 Bug 2). The decision being appealed (Endentscheidung
|
||||
// R.118, Kostenentscheidung, Anordnung, …) isn't a rule in the
|
||||
// upc.apl catalog — it's the anchor the user picked. Lawyers expect
|
||||
// it to surface as the first row of the timeline so the chain reads
|
||||
// decision → appeal filings → next decision. Emitted only when an
|
||||
// appeal_target is in play and the helper returns a non-empty label.
|
||||
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
||||
nameDE := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de")
|
||||
nameEN := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en")
|
||||
if nameDE != "" || nameEN != "" {
|
||||
trig := TimelineEntry{
|
||||
Name: nameDE,
|
||||
NameEN: nameEN,
|
||||
Party: PrimaryPartyCourt,
|
||||
Priority: "informational",
|
||||
DueDate: triggerDateStr,
|
||||
OriginalDate: triggerDateStr,
|
||||
IsRootEvent: true,
|
||||
IsTriggerEvent: true,
|
||||
}
|
||||
deadlines = append([]TimelineEntry{trig}, deadlines...)
|
||||
}
|
||||
}
|
||||
|
||||
resp := &Timeline{
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
@@ -571,6 +650,21 @@ func Calculate(
|
||||
if pickedProceeding.TriggerEventLabelEN != nil {
|
||||
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
|
||||
}
|
||||
// t-paliad-301 / m/paliad#132 Bug B — appeal_target-driven trigger
|
||||
// label. When the request narrows to a specific appeal target, the
|
||||
// "Auslösendes Ereignis" label describes the underlying decision
|
||||
// (Endentscheidung / Kostenentscheidung / Anordnung /
|
||||
// Schadensbemessung / Bucheinsicht) rather than the appeal
|
||||
// proceeding itself. Overrides the proceeding's own
|
||||
// trigger_event_label set above.
|
||||
if opts.AppealTarget != "" {
|
||||
if de := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de"); de != "" {
|
||||
resp.TriggerEventLabel = de
|
||||
}
|
||||
if en := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en"); en != "" {
|
||||
resp.TriggerEventLabelEN = en
|
||||
}
|
||||
}
|
||||
if hasSubTrackNote {
|
||||
resp.ContextualNote = subTrackNote.NoteDE
|
||||
resp.ContextualNoteEN = subTrackNote.NoteEN
|
||||
@@ -656,6 +750,9 @@ func calculateByTriggerEvent(
|
||||
OriginalDate: original.Format("2006-01-02"),
|
||||
WasAdjusted: wasAdj,
|
||||
AdjustmentReason: reason,
|
||||
DurationValue: r.DurationValue,
|
||||
DurationUnit: r.DurationUnit,
|
||||
Timing: timing,
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
@@ -925,3 +1022,60 @@ func AllFlagsSet(required []string, set map[string]struct{}) bool {
|
||||
func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
||||
return wireFlagsFromPriority(priority)
|
||||
}
|
||||
|
||||
// topoSortByParentDepth returns a copy of `rules` ordered so every rule
|
||||
// appears after its parent_id ancestor. Ties (rules at the same depth)
|
||||
// preserve their input order — which the catalog returns in
|
||||
// sequence_order. Used by Calculate to ensure the parent-state checks
|
||||
// (courtSet[parent], computed[parent_code]) see populated entries even
|
||||
// when sequence_order lists a "before"-timed child BEFORE its parent
|
||||
// (e.g. upc.inf.cfi.translation_request at seq=45 with parent
|
||||
// upc.inf.cfi.oral at seq=50 — m/paliad#135).
|
||||
//
|
||||
// Rules whose parent_id is missing from the rule slice (cross-tree
|
||||
// references that the per-proceeding filter dropped) are treated as
|
||||
// depth 0 — they walk in their original sequence position.
|
||||
//
|
||||
// The algorithm is depth-via-memoised-recursion. Cycle protection: a
|
||||
// rule chain that revisits a node is broken at depth 0; production
|
||||
// data shouldn't contain cycles, but a corrupted catalog mustn't hang
|
||||
// the calculator.
|
||||
func topoSortByParentDepth(rules []Rule) []Rule {
|
||||
byID := make(map[uuid.UUID]Rule, len(rules))
|
||||
inSlice := make(map[uuid.UUID]bool, len(rules))
|
||||
for _, r := range rules {
|
||||
byID[r.ID] = r
|
||||
inSlice[r.ID] = true
|
||||
}
|
||||
|
||||
depth := make(map[uuid.UUID]int, len(rules))
|
||||
var resolve func(id uuid.UUID, seen map[uuid.UUID]bool) int
|
||||
resolve = func(id uuid.UUID, seen map[uuid.UUID]bool) int {
|
||||
if d, ok := depth[id]; ok {
|
||||
return d
|
||||
}
|
||||
if seen[id] {
|
||||
depth[id] = 0
|
||||
return 0
|
||||
}
|
||||
seen[id] = true
|
||||
r, ok := byID[id]
|
||||
if !ok || r.ParentID == nil || !inSlice[*r.ParentID] {
|
||||
depth[id] = 0
|
||||
return 0
|
||||
}
|
||||
d := resolve(*r.ParentID, seen) + 1
|
||||
depth[id] = d
|
||||
return d
|
||||
}
|
||||
for _, r := range rules {
|
||||
resolve(r.ID, map[uuid.UUID]bool{})
|
||||
}
|
||||
|
||||
out := make([]Rule, len(rules))
|
||||
copy(out, rules)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return depth[out[i].ID] < depth[out[j].ID]
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,65 @@ type ProceedingType struct {
|
||||
// — 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
|
||||
@@ -371,6 +430,33 @@ type TimelineEntry struct {
|
||||
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"`
|
||||
|
||||
// AppealRole carries the rule's appeal-filer role (t-paliad-307 /
|
||||
// m/paliad#136 Bug 1) when the timeline was computed under an
|
||||
// appeal_target filter. One of AppealRoleAppellant /
|
||||
// AppealRoleAppellee, or empty for court events / non-appeal
|
||||
// timelines. The frontend column-bucketer reads this to route
|
||||
// primary_party='both' rules to Berufungskläger vs
|
||||
// Berufungsbeklagter columns once the user picks a side.
|
||||
AppealRole string `json:"appealRole,omitempty"`
|
||||
|
||||
// IsTriggerEvent marks the synthetic root row that represents the
|
||||
// decision being appealed (t-paliad-307 / m/paliad#136 Bug 2).
|
||||
// Distinct from IsRootEvent in that the row carries no real rule
|
||||
// id — it's a UI marker dated to the trigger date with the
|
||||
// per-appeal-target label from TriggerEventLabelForAppealTarget.
|
||||
IsTriggerEvent bool `json:"isTriggerEvent,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculation is the single-rule calc response that backs the
|
||||
|
||||
Reference in New Issue
Block a user