Compare commits
102 Commits
mai/ritchi
...
mai/atlas/
| Author | SHA1 | Date | |
|---|---|---|---|
| 3219bff4d4 | |||
| 109946edff | |||
| 528fe35540 | |||
| 9c2788ed8c | |||
| c56859058d | |||
| 6acb1167dd | |||
| 4cd28bc896 | |||
| 568eac0aff | |||
| 733d21c930 | |||
| b05bcf7eeb | |||
| 71e8023784 | |||
| d190fbe0a4 | |||
| e0a82d9f9e | |||
| d326f9aa4a | |||
| 026ad2d5ee | |||
| 13a65a6d6e | |||
| bd7896ef68 | |||
| 946f373651 | |||
| 94310ba498 | |||
| 5834e3dc66 | |||
| 677849784c | |||
| b27d402156 | |||
| 14290294b4 | |||
| 6b970da774 | |||
| 9359e99a6b | |||
| 2c0efc396c | |||
| 5c6a0095e3 | |||
| 6e0961cc30 | |||
| ee98db94fa | |||
| 987db27831 | |||
| 1129baba7a | |||
| c20e935a4b | |||
| f963b0df34 | |||
| 6cd340300b | |||
| 557f9a4cce | |||
| 3af71e772b | |||
| 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 | |||
| 4f94697377 | |||
| 2a56b7817c | |||
| 75833082fc | |||
| ce28ea972e | |||
| 6f8b4eabb1 | |||
| e2d75c391d | |||
| 932b177779 | |||
| 989941c648 | |||
| db8e8ba6fd | |||
| d5bf82314a | |||
| 426b90bb88 | |||
| 07acf7b4a2 | |||
| 3e1644820a | |||
| c4c0a82abb | |||
| 5ab14f8b37 | |||
| acf5743fa3 | |||
| d1d0cf9c1d | |||
| 5f0a85fa83 | |||
| 6e585951ee | |||
| 8240717b5a | |||
| 593e6243e0 | |||
| 15cc5e418c | |||
| abf0328dcd | |||
| cc13a5b857 | |||
| abef74fe63 | |||
| 1bd2ebb4ae | |||
| f6c8eb5bcf |
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))
|
||||
}
|
||||
342
cmd/seed-orphan-concept-drafts/main.go
Normal file
342
cmd/seed-orphan-concept-drafts/main.go
Normal file
@@ -0,0 +1,342 @@
|
||||
// Command seed-orphan-concept-drafts stages draft sequencing_rules for
|
||||
// deadline_concepts that have rule_count=0 ("orphans"). It calls the
|
||||
// same services.RuleEditorService.Create that POST
|
||||
// /admin/api/procedural-events runs internally, so the audit trigger
|
||||
// + INSTEAD-OF view trigger fan-out into procedural_events +
|
||||
// sequencing_rules + legal_sources fire identically. No HTTP/auth
|
||||
// shell, no direct SQL writes by this command.
|
||||
//
|
||||
// All rules are created with lifecycle_state='draft' (forced by the
|
||||
// service). The admin still reviews + publishes via
|
||||
// /admin/procedural-events.
|
||||
//
|
||||
// t-paliad-320: editorial backlog from t-paliad-193, four remaining
|
||||
// orphan concepts: counterclaim-for-revocation, versaeumnisurteil-
|
||||
// einspruch, schriftsatznachreichung, weiterbehandlung. The
|
||||
// weiterbehandlung concept gets two drafts (EPC Art. 121 + R. 135
|
||||
// versus DPatG § 123a) since the two regimes have different durations
|
||||
// and jurisdictions.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// DATABASE_URL=postgres://… go run ./cmd/seed-orphan-concept-drafts \
|
||||
// [-dry-run] [-reason "free-text audit reason"]
|
||||
//
|
||||
// Idempotency: the command refuses to insert if any rule for a given
|
||||
// (concept, proceeding_type, rule_code) already exists. Safe to re-run
|
||||
// after a partial failure.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// draftSpec captures one CreateRuleInput plus the metadata the command
|
||||
// needs to resolve concept_id + proceeding_type_id from human-readable
|
||||
// slugs/codes. ProceedingCode == "" means event-rooted
|
||||
// (proceeding_type_id = NULL), used for cross-cutting rules whose
|
||||
// jurisdiction has no matching proceeding_type yet.
|
||||
type draftSpec struct {
|
||||
Label string // human label for log output
|
||||
ConceptSlug string
|
||||
ProceedingCode string // "" → NULL proceeding_type_id (event-rooted)
|
||||
SubmissionCode string
|
||||
Name string
|
||||
NameEN string
|
||||
EventKind string
|
||||
PrimaryParty string // "" → omit (NULL)
|
||||
DurationValue int
|
||||
DurationUnit string
|
||||
Timing string
|
||||
Priority string
|
||||
IsCourtSet bool
|
||||
RuleCode string
|
||||
LegalSource string
|
||||
DeadlineNotes string
|
||||
DeadlineNotesEn string
|
||||
}
|
||||
|
||||
func drafts() []draftSpec {
|
||||
return []draftSpec{
|
||||
// ─── 1. counterclaim-for-revocation (UPC R.25.1 ∧ R.23) ───────
|
||||
{
|
||||
Label: "counterclaim-for-revocation → upc.ccr.cfi",
|
||||
ConceptSlug: "counterclaim-for-revocation",
|
||||
ProceedingCode: "upc.ccr.cfi",
|
||||
SubmissionCode: "upc.ccr.cfi.lodge",
|
||||
Name: "Widerklage auf Nichtigkeit (CCR)",
|
||||
NameEN: "Counterclaim for Revocation (CCR)",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "defendant",
|
||||
DurationValue: 3,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "RoP.025",
|
||||
LegalSource: "UPC.RoP.25.1",
|
||||
DeadlineNotes: "Die Widerklage auf Nichtigkeit (Counterclaim for Revocation, CCR) ist gemeinsam mit der Klageerwiderung (Statement of Defence) einzureichen — d. h. innerhalb von 3 Monaten ab Zustellung der Klageschrift " +
|
||||
"(R. 23 i. V. m. R. 25.1 RoP). Inhaltliche Anforderungen folgen R. 25-30 RoP (insbes. R. 25.1(a)-(c) zu Antrag, Tatsachen und Beweismitteln; R. 27 zu Verfahren nach Einreichung; R. 30 zu einem Antrag auf Änderung des Patents).",
|
||||
DeadlineNotesEn: "The Counterclaim for Revocation (CCR) must be lodged together with the Statement of Defence — i.e. within 3 months of service of the Statement of Claim " +
|
||||
"(Rule 23 in conjunction with Rule 25.1 RoP). Substantive requirements follow Rules 25-30 RoP (in particular R. 25.1(a)-(c) on the application, facts and evidence; R. 27 on post-filing procedure; R. 30 on any application to amend the patent).",
|
||||
},
|
||||
|
||||
// ─── 2. versaeumnisurteil-einspruch (ZPO § 339) ───────────────
|
||||
{
|
||||
Label: "versaeumnisurteil-einspruch → de.inf.lg",
|
||||
ConceptSlug: "versaeumnisurteil-einspruch",
|
||||
ProceedingCode: "de.inf.lg",
|
||||
SubmissionCode: "de.inf.lg.einspruch_vu",
|
||||
Name: "Einspruch gegen Versäumnisurteil",
|
||||
NameEN: "Objection to default judgment",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "defendant",
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "§ 339 ZPO",
|
||||
LegalSource: "DE.ZPO.339.1",
|
||||
DeadlineNotes: "Notfrist von 2 Wochen ab Zustellung des Versäumnisurteils (§ 339(1) ZPO). " +
|
||||
"Bei Auslandszustellung oder öffentlicher Bekanntmachung bestimmt das Gericht die Einspruchsfrist gesondert im Versäumnisurteil oder durch nachträglichen Beschluss (§ 339(2) ZPO) — in diesem Fall die gerichtlich festgesetzte Frist mit „Datum setzen“ überschreiben. " +
|
||||
"Form: schriftlich oder zu Protokoll der Geschäftsstelle (§ 340(1) ZPO); die Einspruchsbegründung kann bis zum Verhandlungstermin nachgereicht werden (§ 340(3) ZPO).",
|
||||
DeadlineNotesEn: "Statutory two-week emergency period (Notfrist) from service of the default judgment (§ 339(1) ZPO). " +
|
||||
"If service is abroad or by public notice, the court sets the objection period separately in the default judgment or by a subsequent order (§ 339(2) ZPO) — in that case override with the court-set date. " +
|
||||
"Form: in writing or before the registry clerk (§ 340(1) ZPO); substantive grounds may be filed up to the oral hearing (§ 340(3) ZPO).",
|
||||
},
|
||||
|
||||
// ─── 3. schriftsatznachreichung (ZPO § 283) ───────────────────
|
||||
{
|
||||
Label: "schriftsatznachreichung → de.inf.lg",
|
||||
ConceptSlug: "schriftsatznachreichung",
|
||||
ProceedingCode: "de.inf.lg",
|
||||
SubmissionCode: "de.inf.lg.nachreichung",
|
||||
Name: "Schriftsatznachreichung",
|
||||
NameEN: "Subsequent written submission",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "", // concept.party = "both" → no default
|
||||
DurationValue: 3,
|
||||
DurationUnit: "weeks",
|
||||
Timing: "after",
|
||||
Priority: "optional",
|
||||
IsCourtSet: true,
|
||||
RuleCode: "§ 283 ZPO",
|
||||
LegalSource: "DE.ZPO.283",
|
||||
DeadlineNotes: "Vom Gericht in der mündlichen Verhandlung gesetzte Schriftsatzfrist gem. § 283 ZPO. " +
|
||||
"Sie wird nur auf Antrag einer Partei bestimmt, die sich auf neues Vorbringen des Gegners nicht erklären konnte. " +
|
||||
"Die konkrete Frist (in der Praxis 2-3 Wochen) und der nachfolgende Verkündungstermin werden im Sitzungsprotokoll bzw. in der prozessleitenden Verfügung festgelegt — Default-Frist hier 3 Wochen, mit „Datum setzen“ überschreiben, sobald die Verfügung vorliegt. " +
|
||||
"Nach Fristablauf darf das Gericht keine weiteren Erklärungen mehr berücksichtigen (§ 283 S. 2, § 296a ZPO).",
|
||||
DeadlineNotesEn: "Court-set written-submission period under § 283 ZPO, granted on a party's application when it could not respond at the oral hearing to the opponent's new submissions. " +
|
||||
"The actual period (in practice 2-3 weeks) and the announcement date are set in the hearing record / case-management order — default 3 weeks here, override via „set date“ once the order is on the file. " +
|
||||
"After expiry, the court will disregard further submissions (§ 283 sent. 2, § 296a ZPO).",
|
||||
},
|
||||
|
||||
// ─── 4. weiterbehandlung — EPC variant (Art. 121 + R. 135) ────
|
||||
{
|
||||
Label: "weiterbehandlung (EPC) → epa.grant.exa",
|
||||
ConceptSlug: "weiterbehandlung",
|
||||
ProceedingCode: "epa.grant.exa",
|
||||
SubmissionCode: "epa.grant.exa.weiterbeh",
|
||||
Name: "Antrag auf Weiterbehandlung",
|
||||
NameEN: "Request for further processing",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "claimant",
|
||||
DurationValue: 2,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "Art. 121 EPÜ",
|
||||
LegalSource: "EU.EPC-R.135.1",
|
||||
DeadlineNotes: "Antrag auf Weiterbehandlung gem. Art. 121 EPÜ i. V. m. R. 135(1) EPÜ — 2 Monate ab Zustellung der Mitteilung über die Fristversäumung bzw. den eingetretenen Rechtsverlust. " +
|
||||
"Der Antrag wird durch Zahlung der vorgeschriebenen Weiterbehandlungsgebühr gestellt; die versäumte Handlung muss innerhalb derselben 2-Monats-Frist nachgeholt werden (R. 135(1) EPÜ). " +
|
||||
"Die Frist ist nicht verlängerbar. Ausgeschlossen sind insbesondere die Frist für die Weiterbehandlung selbst sowie die in R. 135(2) EPÜ ausdrücklich aufgeführten Fristen (u. a. die Beschwerdefrist nach Art. 108 EPÜ, die Prioritätsfrist nach Art. 87 EPÜ und die Frist zur Wiedereinsetzung).",
|
||||
DeadlineNotesEn: "Request for further processing under Article 121 EPC in conjunction with Rule 135(1) EPC — two months from notification of the communication concerning the missed time limit or the loss of rights. " +
|
||||
"The request is made by payment of the further-processing fee; the omitted act must be completed within the same two-month period (Rule 135(1) EPC). " +
|
||||
"The period is non-extendable. Excluded: the further-processing period itself and the periods listed in Rule 135(2) EPC (notably the appeal period under Art. 108 EPC, the priority period under Art. 87 EPC, and the re-establishment period).",
|
||||
},
|
||||
|
||||
// ─── 5. weiterbehandlung — DPatG § 123a variant ───────────────
|
||||
// No `dpma.grant.*` proceeding_type exists yet, so this rule is
|
||||
// event-rooted (proceeding_type_id NULL) — same pattern as 78
|
||||
// other cross-cutting rules. Editorial follow-up: create a
|
||||
// `dpma.grant.dpma` proceeding_type and reassign.
|
||||
{
|
||||
Label: "weiterbehandlung (DPatG § 123a) → event-rooted (NULL proceeding_type)",
|
||||
ConceptSlug: "weiterbehandlung",
|
||||
ProceedingCode: "", // event-rooted
|
||||
SubmissionCode: "dpma.grant.weiterbeh",
|
||||
Name: "Antrag auf Weiterbehandlung (DPMA)",
|
||||
NameEN: "Request for further processing (DPMA, § 123a PatG)",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "claimant",
|
||||
DurationValue: 1,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "§ 123a PatG",
|
||||
LegalSource: "DE.PatG.123a.1",
|
||||
DeadlineNotes: "Antrag auf Weiterbehandlung einer DPMA-Patentanmeldung gem. § 123a PatG — 1 Monat ab Zustellung der Mitteilung über die Rechtsfolge der Fristversäumung. " +
|
||||
"Innerhalb dieser Frist müssen (i) der Antrag schriftlich gestellt, (ii) die versäumte Handlung nachgeholt und (iii) die Weiterbehandlungsgebühr nach Patentkostengesetz (PatKostG) gezahlt werden. " +
|
||||
"§ 123a PatG erfasst ausschließlich Anmeldungsfristen, deren Versäumung kraft Gesetzes die Zurückweisung der Anmeldung zur Folge hat. Für sonstige Fristversäumnisse kommt nur die Wiedereinsetzung nach § 123 PatG in Betracht (1 Monat ab Wegfall des Hindernisses, max. 1 Jahr ab Fristablauf). " +
|
||||
"HINWEIS — Taxonomie: bisher kein dpma.grant.* proceeding_type vorhanden; Regel daher event-rooted (proceeding_type_id NULL). Editorial follow-up: dpma.grant.dpma proceeding_type anlegen und diese Regel umhängen.",
|
||||
DeadlineNotesEn: "Request for further processing of a DPMA patent application under § 123a PatG — 1 month from notification of the consequence of the missed deadline. " +
|
||||
"Within this period the applicant must (i) file the written request, (ii) complete the omitted act, and (iii) pay the further-processing fee under the German Patent Costs Act (PatKostG). " +
|
||||
"§ 123a PatG covers only application-stage deadlines whose statutory consequence is rejection. For other missed deadlines, re-establishment under § 123 PatG is the only route (1 month from removal of the obstacle, max 1 year from the missed deadline). " +
|
||||
"TAXONOMY NOTE: no dpma.grant.* proceeding_type exists yet; this rule is event-rooted (proceeding_type_id NULL). Editorial follow-up: create a dpma.grant.dpma proceeding_type and reassign this rule.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "log the planned drafts but do not write")
|
||||
reason := flag.String("reason", "t-paliad-320: editorial seed of orphan deadline-concept rules (researcher darwin + lex)", "audit reason recorded with each Create()")
|
||||
flag.Parse()
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
log.Fatal("DATABASE_URL not set — export the paliad postgres URL before running")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := sqlx.Connect("postgres", dbURL)
|
||||
if err != nil {
|
||||
log.Fatalf("connect db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
rules := services.NewDeadlineRuleService(conn)
|
||||
editor := services.NewRuleEditorService(conn, rules)
|
||||
|
||||
conceptIDs := map[string]uuid.UUID{}
|
||||
proceedingIDs := map[string]int{}
|
||||
specs := drafts()
|
||||
|
||||
for _, s := range specs {
|
||||
if _, ok := conceptIDs[s.ConceptSlug]; ok {
|
||||
continue
|
||||
}
|
||||
var id uuid.UUID
|
||||
if err := conn.GetContext(ctx, &id,
|
||||
`SELECT id FROM paliad.deadline_concepts WHERE slug = $1`, s.ConceptSlug); err != nil {
|
||||
log.Fatalf("lookup concept %q: %v", s.ConceptSlug, err)
|
||||
}
|
||||
conceptIDs[s.ConceptSlug] = id
|
||||
}
|
||||
for _, s := range specs {
|
||||
if s.ProceedingCode == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := proceedingIDs[s.ProceedingCode]; ok {
|
||||
continue
|
||||
}
|
||||
var id int
|
||||
if err := conn.GetContext(ctx, &id,
|
||||
`SELECT id FROM paliad.proceeding_types WHERE code = $1`, s.ProceedingCode); err != nil {
|
||||
log.Fatalf("lookup proceeding_type %q: %v", s.ProceedingCode, err)
|
||||
}
|
||||
proceedingIDs[s.ProceedingCode] = id
|
||||
}
|
||||
|
||||
fmt.Printf("Seeding %d drafts (dry-run=%v)\n", len(specs), *dryRun)
|
||||
|
||||
for i, s := range specs {
|
||||
conceptID := conceptIDs[s.ConceptSlug]
|
||||
var procID *int
|
||||
if s.ProceedingCode != "" {
|
||||
p := proceedingIDs[s.ProceedingCode]
|
||||
procID = &p
|
||||
}
|
||||
|
||||
// Idempotency: refuse if a rule with the same (concept, proceeding,
|
||||
// rule_code) already exists in any lifecycle state.
|
||||
if existing, err := findExisting(ctx, conn, conceptID, procID, s.RuleCode); err != nil {
|
||||
log.Fatalf("[%d] idempotency check failed for %s: %v", i+1, s.Label, err)
|
||||
} else if existing != uuid.Nil {
|
||||
fmt.Printf(" [%d] SKIP %s — already exists as %s\n", i+1, s.Label, existing)
|
||||
continue
|
||||
}
|
||||
|
||||
input := services.CreateRuleInput{
|
||||
Name: s.Name,
|
||||
NameEN: s.NameEN,
|
||||
ProceedingTypeID: procID,
|
||||
DurationValue: s.DurationValue,
|
||||
DurationUnit: s.DurationUnit,
|
||||
Priority: s.Priority,
|
||||
IsCourtSet: s.IsCourtSet,
|
||||
}
|
||||
input.ConceptID = &conceptID
|
||||
code := s.SubmissionCode
|
||||
input.SubmissionCode = &code
|
||||
ek := s.EventKind
|
||||
input.EventType = &ek
|
||||
t := s.Timing
|
||||
input.Timing = &t
|
||||
rc := s.RuleCode
|
||||
input.RuleCode = &rc
|
||||
ls := s.LegalSource
|
||||
input.LegalSource = &ls
|
||||
dn := s.DeadlineNotes
|
||||
input.DeadlineNotes = &dn
|
||||
dne := s.DeadlineNotesEn
|
||||
input.DeadlineNotesEn = &dne
|
||||
if s.PrimaryParty != "" {
|
||||
pp := s.PrimaryParty
|
||||
input.PrimaryParty = &pp
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
fmt.Printf(" [%d] DRY %s (concept=%s, proc=%s, code=%s, %d %s, %s)\n",
|
||||
i+1, s.Label, conceptID, codeOrNil(procID), code, s.DurationValue, s.DurationUnit, s.RuleCode)
|
||||
continue
|
||||
}
|
||||
|
||||
row, err := editor.Create(ctx, input, *reason)
|
||||
if err != nil {
|
||||
log.Fatalf(" [%d] CREATE failed for %s: %v", i+1, s.Label, err)
|
||||
}
|
||||
fmt.Printf(" [%d] OK %s → id=%s lifecycle=%s\n",
|
||||
i+1, s.Label, row.ID, row.LifecycleState)
|
||||
}
|
||||
|
||||
fmt.Println("Done.")
|
||||
}
|
||||
|
||||
func findExisting(ctx context.Context, conn *sqlx.DB, conceptID uuid.UUID, procID *int, ruleCode string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
q := `
|
||||
SELECT sr.id
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.concept_id = $1
|
||||
AND sr.rule_code IS NOT DISTINCT FROM $2
|
||||
AND sr.proceeding_type_id IS NOT DISTINCT FROM $3
|
||||
LIMIT 1`
|
||||
err := conn.GetContext(ctx, &id, q, conceptID, ruleCode, procID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func codeOrNil(p *int) string {
|
||||
if p == nil {
|
||||
return "<NULL>"
|
||||
}
|
||||
return fmt.Sprintf("%d", *p)
|
||||
}
|
||||
@@ -159,6 +159,21 @@ 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-313 Slice B — render-pipeline assembler. Reuses the
|
||||
// existing SubmissionRenderer for the final placeholder pass so
|
||||
// the {{rule.X}} alias contract stays preserved inside the
|
||||
// composed body.
|
||||
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
|
||||
// t-paliad-315 Slice C — building-block library.
|
||||
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, 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 +185,11 @@ func main() {
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
SubmissionBase: submissionBaseSvc,
|
||||
SubmissionSection: submissionSectionSvc,
|
||||
SubmissionComposer: submissionComposerSvc,
|
||||
SubmissionBuildingBlock: submissionBuildingBlockSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -221,6 +240,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 +358,11 @@ func main() {
|
||||
log.Printf("CalDAV start: %v", err)
|
||||
}
|
||||
reminderSvc.Start(bgCtx)
|
||||
// Slice B.4 (mig 140, t-paliad-305): legacy paliad.deadline_rules
|
||||
// dropped. The B.2 dual-write drift-check loop is retired — the
|
||||
// procedural_events / sequencing_rules / legal_sources tables
|
||||
// are now the source of truth and there is no parallel side to
|
||||
// compare against. Pre-drop drift was verified clean in mig 140.
|
||||
go func() {
|
||||
<-bgCtx.Done()
|
||||
log.Println("background services: shutdown signal received")
|
||||
|
||||
@@ -421,7 +421,7 @@ The editor is the **largest single surface** in Phase 3. ~3-4 PRs of work depend
|
||||
| `POST /api/admin/rules` | POST | global_admin | Create a new rule from scratch (starts as `lifecycle_state='draft'`). |
|
||||
| `GET /admin/rules/{id}/audit` | GET | global_admin | Audit log for this rule. |
|
||||
| `POST /admin/rules/{id}/preview` | POST | global_admin | Preview-on-trigger-date — runs calculator with this draft replacing its published peer; returns the resulting timeline (no persistence). |
|
||||
| `POST /admin/rules/export-migration` | POST | global_admin | Export pending (draft + audit-since-last-export) rules as a `*.up.sql` blob the human can paste into `internal/db/migrations/`. Sets `migration_exported=true` on the audit rows. |
|
||||
| _(removed t-paliad-297)_ migration-export endpoint | — | — | Was a SQL-export tool generating `*.up.sql` from audit rows. Workflow shifted to hand-written numbered migrations; tool removed in m/paliad#129. |
|
||||
|
||||
### 4.2 Draft → published lifecycle
|
||||
|
||||
|
||||
553
docs/design-fristenrechner-overhaul-2026-05-26.md
Normal file
553
docs/design-fristenrechner-overhaul-2026-05-26.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# Design — Fristenrechner complete UX overhaul (dual-mode + project write-back)
|
||||
|
||||
**Task:** t-paliad-322
|
||||
**Gitea:** m/paliad#146
|
||||
**Inventor:** cronus (shift-1)
|
||||
**Date:** 2026-05-26
|
||||
**Status:** Draft for m's ratification — coder gate held
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Verified against the live youpc Postgres (port 11833, paliad schema) and the live source tree on `mai/cronus/inventor-fristenrechner` @ HEAD.
|
||||
|
||||
### 0.1 Rule-and-event corpus today
|
||||
|
||||
| Table | Active+published rows | Notes |
|
||||
|---|---|---|
|
||||
| `paliad.procedural_events` | 222 (236 total) | The events that anchor a deadline. 4 `event_kind` buckets: `filing`, `hearing`, `decision`, `order`, plus `NULL` for legacy/dpma stragglers. |
|
||||
| `paliad.sequencing_rules` | 231 | The deadlines themselves, anchored on `procedural_event_id` and (sometimes) `trigger_event_id`. 80 carry a `trigger_event_id`, 4 are `is_spawn=true`, 45 are `is_court_set=true`, 18 carry a `condition_expr`. |
|
||||
| `paliad.deadline_concepts` | 57 | Hub layer above events (Klageerhebung, Wiedereinsetzung, …). |
|
||||
| `paliad.proceeding_types` | 46 fristenrechner | 4 jurisdictions: UPC (35), DE (5), EPA (3), DPMA (3). |
|
||||
| `paliad.event_categories` | 125 (103 leaves) | The current cascade tree — 5 user-bucket roots (`cms-eingang`, `muendl-verhandlung`, `beschluss-entscheidung`, `frist-verpasst`, `ich-moechte-einreichen`) + `sonstiges` leaf. UI hides the forward-workflow root (`HIDDEN_CASCADE_ROOTS` in `client/fristenrechner.ts:2605`). |
|
||||
| `paliad.deadlines` | 10 (8 with `sequencing_rule_id`) | Demand-side still tiny. The 2 without `sequencing_rule_id` are manual entries. |
|
||||
|
||||
Live `primary_party` vocabulary on `sequencing_rules`: `claimant`, `defendant`, `both`, `court`, `NULL`. Live `priority` vocabulary: `mandatory`, `recommended`, `optional` (no `informational` rows yet — Phase 2 reserved the slot but seeding is deferred).
|
||||
|
||||
### 0.2 The legacy `deadline_rules` reader is a view
|
||||
|
||||
`paliad.deadline_rules_unified` (mig 139, Slice B.3) is a **view** over `sequencing_rules ⋈ procedural_events ⋈ legal_sources`. All Go calculator paths read through it (see `deadline_rule_service.go:70`). The physical `paliad.deadline_rules` table was dropped in mig 140; the view is the canonical legacy-shape reader. Important for this design: there is no "trigger event" table parallel to events — the rule rows themselves are the things the wizard must land on. `trigger_events` (110 rows) is the youpc-parity legacy table used by `/api/tools/event-deadlines` only.
|
||||
|
||||
### 0.3 The frontend today (`/tools/fristenrechner`)
|
||||
|
||||
Two server-rendered surfaces share the same page (`frontend/src/fristenrechner.tsx`, 657 lines) — the legacy "Procedure mode" (R1 step-list, proceeding picker, trigger date, flag checkboxes) and the **Pathway-B row stack** (`buildRowStack` at `client/fristenrechner.ts:2848`, 4009 lines total). Row stack composes three row kinds via a single `.fristen-row` primitive:
|
||||
|
||||
| Row | Source | Filter or qualifier today |
|
||||
|---|---|---|
|
||||
| R1 Perspective (Beide / Klägerseite / Beklagtenseite) | `currentPerspective`, prefilled from `project.our_side` | hybrid — narrows party-tagged cascade chips AND is used as a column-bucket hint in the result view |
|
||||
| R2 Inbox channel (CMS / beA / Postal / Alle) | `currentInboxChannel` | filter — narrows cascade by forum (CMS → upc, beA → de, …) |
|
||||
| R3..Rn Cascade chain | `event_categories` tree | each step narrows children by `inboxFilterAllowsForums` + `perspectiveAllowsParty` + `cascadeChildAllowsProject` |
|
||||
|
||||
The cascade auto-walks single-child branches under a project context and stops at the first branching point. The user picks a leaf; the leaf's slug feeds `/api/tools/fristenrechner/search?event_category_slug=…` which returns concept cards. Each card expands inline to a calc panel (trigger-date input + flags + computed deadline + "in Akte" CTA).
|
||||
|
||||
### 0.4 What is broken in this UI (m's verdict, 2026-05-26 21:21)
|
||||
|
||||
m's brief in m/paliad#146 enumerates four visible bugs:
|
||||
|
||||
1. **"Beide" as default perspective** is incoherent for the headline use case ("file a deadline because something happened" — you ARE one side).
|
||||
2. **R2 (inbox) does not constrain R3 (cascade)** the way a user expects — picking CMS still leaves "Mündliche Verhandlung" / "Frist verpasst" on the next step. (Cause: those roots have `forums=NULL` in the seed → neutral → visible from every inbox.)
|
||||
3. **Mixed axes** — the form layers filters (forum, inbox channel) on top of qualifiers (event-kind, perspective, proceeding_type) without making the difference visible. The user can't tell which picks narrow and which define.
|
||||
4. **Trigger vs follow-up confusion** — the wizard's purpose is to identify the *trigger event*, then surface the *follow-up deadlines*. Today that split is not reflected in the form. After landing on a leaf, the user gets a flat list of concept cards and has to figure out which one is "the thing that happened" vs "the thing I have to file next".
|
||||
|
||||
m's verdict: "complete overhaul. Should be easy to use."
|
||||
|
||||
### 0.5 Anchor files for the eventual coder
|
||||
|
||||
- `frontend/src/client/fristenrechner.ts` (4009 LoC) — page brain. `buildRowStack` @ L2848, `renderRowStack` @ L3112, `runB1Search` (concept-card render) downstream, `expandCardCalc` @ L1337 (inline calc panel), `openSaveModal` @ L290 + `submitSave` @ L374 (project write-back).
|
||||
- `frontend/src/fristenrechner.tsx` (657 LoC) — server-rendered shell. Contains both the Procedure-mode form **and** the Pathway-B row-stack scaffold. The new design replaces the row-stack scaffold; the procedure-mode form survives.
|
||||
- `internal/handlers/fristenrechner.go` + `_search.go` + `_event_categories.go` — three handler files. `POST /api/tools/fristenrechner` (procedure-mode calc), `GET /search` (concept cards), `GET /event-categories` (cascade tree).
|
||||
- `internal/services/fristenrechner.go` (661 LoC) — calculator adapter to `pkg/litigationplanner`. The calculator is **not** touched by this design.
|
||||
- `internal/handlers/deadlines.go:167` + `services/deadline_service.go:411` (`CreateBulk`) — the project write-back endpoint (`POST /api/projects/{id}/deadlines/bulk`). This survives; the design extends its caller.
|
||||
|
||||
### 0.6 Adjacent design docs to read alongside
|
||||
|
||||
- `docs/design-determinator-row-cascade-2026-05-13.md` — the row-cascade pillars (Project-driven narrowing / Visual hierarchy overhaul / Persistent row stack). This overhaul **keeps** Pillars 2 and 3 and reworks Pillar 1's contract.
|
||||
- `docs/design-fristen-phase2-2026-05-15.md` — the unified `sequencing_rules` model the calculator already runs on.
|
||||
- `docs/audit-fristen-logic-2026-05-13.md` — the trigger-event / Pipeline-A-vs-C distinction.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
**One page, two complementary entry paths, one result surface, one write-back.**
|
||||
|
||||
```text
|
||||
┌───────────────────────── /tools/fristenrechner ─────────────────────────┐
|
||||
│ │
|
||||
│ ╭──────── Akte / kontextfrei ────────╮ (Step 0 — unchanged today) │
|
||||
│ │ HL-2024-001 ▼ | ohne Akte │ │
|
||||
│ ╰─────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ╭────── Entry mode tabs ──────╮ │
|
||||
│ │ [⚡ Direkt suchen] │ ◀── A: power user, search + chips │
|
||||
│ │ [🧭 Geführt] │ ◀── B: 3-5 question wizard │
|
||||
│ ╰─────────────────────────────╯ │
|
||||
│ │
|
||||
│ ┌── Mode A: Suche ──────────────┐ ┌── Mode B: Wizard ────────────────┐│
|
||||
│ │ search-box ▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢ │ │ R1 Was ist passiert? ✓ filing ││
|
||||
│ │ filter chips: │ │ R2 Forum? ✓ UPC ││
|
||||
│ │ Forum · Proceeding · Event- │ │ R3 Verfahren? ✓ INF ││
|
||||
│ │ Kind · Partei │ │ R4 Welcher Schritt? [active] ││
|
||||
│ │ ┌ Ergebnis-Liste ────────────┐│ │ R5 Welche Seite? ✓ Kläger ││
|
||||
│ │ │ procedural_event hits ││ │ ││
|
||||
│ │ │ [Trigger einrasten →] ││ │ (Direkt-suchen ←) ││
|
||||
│ │ └────────────────────────────┘│ └───────────────────────────────────┘│
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ════ shared from here ═══════════════════════════════════════════════ │
|
||||
│ │
|
||||
│ ┌── Trigger event (locked) ──────────────────────────────────────────┐ │
|
||||
│ │ 📥 Klageschrift wurde eingereicht │ │
|
||||
│ │ upc.inf.cfi · Verletzungsverfahren · Klägerseite │ │
|
||||
│ │ Trigger-Datum: [📅 2026-05-20] (heute) │ │
|
||||
│ │ ändern ↩ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌── Folge-Fristen ────────────────────────────────────────────────────┐ │
|
||||
│ │ ◉ MANDATORY (auto-checked) │ │
|
||||
│ │ ☑ Klageerwiderung (3 Monate) — 20.08.2026 — RoP 23 ✏ Datum │ │
|
||||
│ │ ☑ ... │ │
|
||||
│ │ ◇ OPTIONAL │ │
|
||||
│ │ □ Wiedereinsetzungsantrag (R.320) — bei Versäumnis │ │
|
||||
│ │ ◊ CONDITIONAL │ │
|
||||
│ │ □ Erwiderung auf Nichtigkeitswiderklage nur wenn CCR │ │
|
||||
│ │ ⇲ SPAWNED │ │
|
||||
│ │ ☑ Berufung gegen Endurteil (kein Datum) │ │
|
||||
│ │ ╭────────────────────────────╮ │ │
|
||||
│ │ │ 4 ausgewählt → in Akte ▶ │ │ │
|
||||
│ │ ╰────────────────────────────╯ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The two modes never compete: they're two front doors into the **same** locked-trigger-event → follow-up-list → write-back flow.
|
||||
|
||||
---
|
||||
|
||||
## 2. Axis taxonomy — ratified (filters vs qualifiers)
|
||||
|
||||
The headline source of today's UX confusion is the unmarked mixing of *filters* (narrowing the question space without committing to an answer) and *qualifiers* (parts of the eventual deadline definition).
|
||||
|
||||
| Axis | Role | Source | Constrains | Visual in new UI |
|
||||
|---|---|---|---|---|
|
||||
| `forum` | **filter** | derived from `proceeding_types.jurisdiction` (UPC/DE/EPA/DPMA), or from `project.proceeding_type_id`, or user pick | which `proceeding_types` are reachable; which `event_categories` are visible | Mode A: filter chip strip. Mode B: explicit wizard row (R2). Pre-filled + collapsed when there's a project. |
|
||||
| `proceeding_type` | **qualifier** | `project.proceeding_type_id` (binds via mig 096 codes) OR user pick during wizard | the set of `sequencing_rules` rows that can apply | Mode A: filter chip strip. Mode B: explicit wizard row (R3). Pre-filled + collapsed when there's a project. |
|
||||
| `event_kind` | **filter** | `procedural_events.event_kind` (filing / hearing / decision / order) | which `procedural_events` are reachable as triggers | Mode A: filter chip strip. Mode B: explicit wizard row (R1 — the headline question). |
|
||||
| `inbox channel` | **filter** (today) → **out of scope row** (new) | user pick | nothing the user can see (the rule corpus has no "inbox" column; it was only used to recolour the cascade) | Removed from the primary wizard. Pushed into Mode A's secondary chips (off by default). See §3.3. |
|
||||
| `perspective (our_side)` | **qualifier in file-mode**, **filter in explore-mode** | `project.our_side` OR user pick OR implicit-via-event-kind | `sequencing_rules.primary_party`; result-view column bucketing | Wizard tail (R5) **only when** the trigger event's follow-ups actually differ by side. Pre-filled when project has `our_side`. |
|
||||
| `instance_level` (first / appeal / cassation) | qualifier | `project.instance_level` (mig 084) — sparse | rare — used to disambiguate APP+DE | Surfaced only when the wizard hits APP+DE-style ambiguity. |
|
||||
|
||||
**Rule:** a **filter** narrows the visible options without locking in a deadline answer; it can be cleared and re-applied. A **qualifier** is part of the resulting deadline calculation and is locked the moment it's picked. Filters must propagate forward (Mode A's forum-chip narrows the proceeding-chip's options). Qualifiers are picked once and the answer view reads them.
|
||||
|
||||
The "Beide" perspective default (today's bug) is wrong because perspective is a *qualifier* in the headline use case ("file a deadline because something happened — you are one side"), not a *filter*. New default in Mode B: derive from the project's `our_side`, otherwise force a R5 pick (no "Beide"). See Q8 for the explore-mode exception.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mode taxonomy
|
||||
|
||||
### 3.1 Mode A — "⚡ Direkt suchen" (power user)
|
||||
|
||||
Two visually distinct strips (per m §11.Q3):
|
||||
|
||||
```text
|
||||
┌── Filter (eingrenzen) ─────────────────────────────────────────────────┐
|
||||
│ Forum: [UPC] [DE] [EPA] [DPMA] [Alle] │
|
||||
│ Verfahren: [upc.inf.cfi] [...] [Alle] │
|
||||
│ Was passierte: [📥 Eingereicht] [🏛️ Termin] [⚖️ Entscheidung] [📜 Verfügung] [Alle] │
|
||||
│ Partei: [Klägerseite] [Beklagtenseite] [Beide] │
|
||||
├── Suchen ──────────────────────────────────────────────────────────────┤
|
||||
│ 🔎 [_______________________________________________________________] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
┌── Ergebnisse (klicken = als Trigger einrasten) ────────────────────────┐
|
||||
│ 📥 Klageerhebung · upc.inf.cfi · UPC · 3 Folge-Fristen → │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Single text input, four filter chip strips above it (Forum · Proceeding · Event-Kind · Partei), and a ranked result list of `procedural_events` underneath. The "Filter" strip is visibly grouped (e.g. light background + "Filter (eingrenzen)" header) so users see at a glance that those picks narrow but don't commit; clicking a result row IS the commit (the qualifier action).
|
||||
|
||||
- Search hits `/api/tools/fristenrechner/search` (extended to return events, not just concepts — see §6.1).
|
||||
- Filter chips compose with the text query (`?forum=upc&pt=upc.inf.cfi&kind=filing&party=defendant&q=Klageerwiderung`).
|
||||
- Result rows are individual `procedural_events` (not aggregated concept-cards). Each row shows: name (DE/EN), proceeding_type code, jurisdiction badge, event_kind icon, the rule-count it triggers ("3 Folge-Fristen").
|
||||
- Click a row → "lock as trigger event" → page transitions to the §4 result view.
|
||||
- Power affordance: a row with multiple linked rules can be locked in **per-rule** ("nur diese Frist") via a kebab menu on the row. (Sane default: lock the whole event; the kebab is for the lawyer who only wants one specific reactive deadline.)
|
||||
|
||||
### 3.2 Mode B — "🧭 Geführt" (the wizard)
|
||||
|
||||
A 3-5 question row stack that lands on one `procedural_events` row.
|
||||
|
||||
**Question order (strawman; m to ratify in Q5):**
|
||||
|
||||
1. **R1 — Was ist passiert?** Chips: 📥 Eingereicht (`filing`) · 🏛️ Termin (`hearing`) · ⚖️ Entscheidung (`decision`) · 📜 Verfügung (`order`) · 🕒 Frist versäumt (special bucket, routes to Wiedereinsetzung). One chip = one `event_kind` (or the special). Always asked. ~6 chips, fits one row.
|
||||
2. **R2 — Vor welchem Gericht / bei welchem Amt?** Chips: UPC · LG/OLG/BGH · EPA · DPMA. Pre-filled from `project.proceeding_type → jurisdiction` (or `project.court` substring). **Skipped if R1 narrows to a single forum** (e.g. "Termin" + project has UPC → R2 is implied).
|
||||
3. **R3 — In welchem Verfahren?** Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind. Pre-filled from `project.proceeding_type_id`. **Auto-skipped** when the narrowed scope has only one candidate.
|
||||
4. **R4 — Welches Schriftstück / Welcher Termin?** This is the wizard's landing question. Chips = `procedural_events` filtered by (R2 forum, R3 proceeding_type, R1 event_kind). Typical scope: 1-12 events. If the user types into this row, the chip layout flips to a search list (same widget as Mode A's result list, narrowed to the wizard's filters).
|
||||
5. **R5 — Vertreten Sie Kläger- oder Beklagtenseite?** Asked **only when** the selected event's `sequencing_rules` have follow-ups that differ by `primary_party` (a quick "are there both claimant- and defendant-tagged rules among the follow-ups?" check on the catalog). Pre-filled from `project.our_side`. **Skipped otherwise.**
|
||||
|
||||
**Row badges** (per m §11.Q3): each wizard row carries a small "Filter" or "Qualifier" tag next to its row-number badge. R1 (event_kind), R2 (forum) → "Filter". R3 (proceeding_type), R4 (procedural_event), R5 (perspective) → "Qualifier". A user can tell at a glance which picks lock in vs which narrow.
|
||||
|
||||
Branching policy (locked):
|
||||
|
||||
- Pre-fill + collapse a row when the answer is implied by the project (Determinator §4 pattern, unchanged).
|
||||
- Auto-skip a row when the narrowed scope has exactly one option (the user has effectively no choice). Show the skipped row as a compact `.fristen-row.is-prefilled` line with "(aus Akte)" or "(implizit aus R1)" annotation. *Don't hide the row* — m's "see your selections" pillar from the row-cascade design demands every decision stays visible.
|
||||
- A user-edited upstream answer **preserves compatible downstream picks** (m §11.Q10): if a re-picked R2 (forum) keeps the existing R1 (event_kind) legal, R1 stays; if it makes R3 (proceeding_type) illegal, R3 resets to active. Rows whose pick was carried across an upstream change render with a one-render "erhalten" annotation so the user notices.
|
||||
- "Welches Schriftstück?" (R4) is the landing question. Once R4 is answered, the wizard exits and the §4 result view takes over.
|
||||
|
||||
### 3.3 The dropped `inbox channel` row
|
||||
|
||||
R2-inbox in today's row stack is removed from the primary surface for both modes. Rationale:
|
||||
|
||||
- The rule corpus has no `inbox` column. The cascade's `forums=['cms']` etc. tags were a presentation-layer reflection of which forum naturally arrives on which channel — but the rule itself doesn't change based on whether a UPC document arrived via CMS or by post (it can't; only CMS is legal). So the only honest role for "inbox" is to nudge the forum filter on Mode A.
|
||||
- Mode A keeps inbox as a *secondary* chip strip ("Erweitert" toggle, off by default). Picking CMS auto-sets the forum chip to UPC; picking beA auto-sets it to DE. The user can override.
|
||||
- Mode B never asks. The wizard derives forum from project context or from R2.
|
||||
|
||||
This collapses one bug class entirely (R2-not-constraining-R3) by retiring R2 from the headline path.
|
||||
|
||||
---
|
||||
|
||||
## 4. Shared result view — "follow-up deadlines"
|
||||
|
||||
Once a trigger event is locked (via Mode A click or Mode B R4 pick), the same result view renders.
|
||||
|
||||
### 4.1 Trigger card (sticky header)
|
||||
|
||||
```text
|
||||
┌─ Trigger-Ereignis ─────────────────────────────────────────────────────┐
|
||||
│ 📥 Klageerhebung │
|
||||
│ upc.inf.cfi · Verletzungsverfahren · UPC │
|
||||
│ ⓘ "Einreichung der Klageschrift gemäß R.13 RoP" │
|
||||
│ Trigger-Datum: 📅 2026-05-20 [ändern ↩] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`Trigger-Datum` defaults to today. The user can change it inline (date picker). Changing it re-renders the follow-ups with new computed dates.
|
||||
|
||||
The "ändern" link drops back to whichever mode the user came from with R1-R4 still answered. (Per Q4: the wizard preserves compatible upstream picks rather than rebooting.)
|
||||
|
||||
### 4.2 Follow-up groups
|
||||
|
||||
Group `sequencing_rules` rows that have the trigger event as **anchor** (i.e. `sr.procedural_event_id = trigger.id`) into 4 visible groups:
|
||||
|
||||
1. **MANDATORY** (`priority='mandatory'`) — pre-checked. The bread-and-butter follow-ups.
|
||||
2. **RECOMMENDED** (`priority='recommended'`) — pre-checked. Best-practice fillings (R.19 EPÜ Einspruch, replication briefs).
|
||||
3. **OPTIONAL** (`priority='optional'`) — unchecked. Discretionary actions (R.320 Wiedereinsetzung).
|
||||
4. **CONDITIONAL** (`condition_expr IS NOT NULL`) — unchecked, with the condition rendered ("nur wenn CCR im Verfahren"). Lawyer ticks if applicable.
|
||||
|
||||
Plus a fifth implicit bucket:
|
||||
|
||||
5. **SPAWNED / CROSS-PROCEEDING** (`is_spawn=true`, `spawn_proceeding_type_id IS NOT NULL`) — surfaced as a separate sub-section with a clear "leitet ein neues Verfahren ein" annotation. Pre-checked when mandatory.
|
||||
|
||||
Recommendation (Q6): **4 visible groups, with SPAWNED inlined into whichever priority bucket it belongs to but tagged with a "⇲ neues Verfahren" badge.** Five groups is too many for a one-page result; folding SPAWNED into its priority keeps the math right (mandatory spawned = mandatory) while still flagging the cross-proceeding implication.
|
||||
|
||||
### 4.3 Per-rule row
|
||||
|
||||
```text
|
||||
☑ Klageerwiderung ✏ Datum
|
||||
3 Monate nach Klageerhebung 20.08.2026
|
||||
RoP 23 · Beklagtenseite
|
||||
ⓘ Schriftlich, mit Vollmacht. Erstmaliges Bestreiten der Patentverletzung.
|
||||
```
|
||||
|
||||
Columns: checkbox · title (DE/EN) · duration phrase · computed due date · rule citation · party stance · expandable notes.
|
||||
|
||||
Inline date editor (✏ Datum) lets the lawyer override the computed date for this rule (same affordance as today's `wireDateEditClicks`). The override flows into the write-back payload.
|
||||
|
||||
`is_court_set=true` rules render with the "wird vom Gericht bestimmt" placeholder instead of a date and are unchecked-by-default (matches the current `openSaveModal` behaviour).
|
||||
|
||||
### 4.4 Result-view footer (write-back CTA)
|
||||
|
||||
```text
|
||||
┌─ Auswahl ──────────────────────────────────────────────────────────────┐
|
||||
│ 4 Fristen ausgewählt → In Akte HL-2024-001 eintragen ▶ │
|
||||
│ (oder: 2 mit eigenem Datum, 2 mit Standardberechnung) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The CTA opens a **confirm-and-edit-dates modal** (per m §11.Q6) where the lawyer can revise each selected deadline's due date one last time, then commits via `POST /api/projects/{id}/deadlines/bulk` (today's endpoint).
|
||||
|
||||
**Kontextfrei mode (no Akte)** — per m §11.Q7, the entire write-back footer **does not render** when `project == null`. The result view stays informational. In its place, an inline nudge appears above the deadline groups:
|
||||
|
||||
```text
|
||||
ⓘ Tipp: Wähle oben eine Akte, um diese Fristen einzutragen.
|
||||
```
|
||||
|
||||
The "oben" link focuses the Akte picker. Once a project is picked, the nudge collapses and the footer materialises; no page reload, no result-view rebuild (the trigger event and date persist across the project pick).
|
||||
|
||||
Modal payload per deadline (extends today's `CreateDeadlineInput`):
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Klageerwiderung",
|
||||
"rule_code": "RoP 23",
|
||||
"due_date": "2026-08-20",
|
||||
"original_due_date": "2026-08-20",
|
||||
"source": "fristenrechner",
|
||||
"rule_id": "<sequencing_rules.id>", /* maps to deadlines.sequencing_rule_id */
|
||||
"notes": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**audit_reason wording (per Q12):** every row inserted via this flow carries an audit-log breadcrumb on the project (matches the deadline `Verlauf` pattern). Default reason string:
|
||||
|
||||
> `Aus Fristenrechner — Trigger: {trigger_event_name} ({trigger_date_iso})`
|
||||
|
||||
e.g. `Aus Fristenrechner — Trigger: Klageerhebung (2026-05-20)`. Falls into `paliad.project_events` with `kind='deadline_created'` via the existing `DeadlineService.CreateBulk` audit hook; no schema change needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. URL / state representation
|
||||
|
||||
The new flow keeps Pathway-B's URL-as-state contract, simplified:
|
||||
|
||||
| Param | Owner | Meaning |
|
||||
|---|---|---|
|
||||
| `project` | Step 0 | Active project UUID. Drives the prefills. |
|
||||
| `mode` | mode tab | `wizard` (default) or `search`. |
|
||||
| `q` | Mode A | Free text query. |
|
||||
| `forum` | Mode A | Comma-separated forum codes (`upc,de`). Mode B writes this only when the wizard derives it. |
|
||||
| `pt` | Mode A | Selected proceeding_type code. |
|
||||
| `kind` | Mode A | event_kind chip pick. |
|
||||
| `party` | both | Perspective. Mode A's chip; Mode B's R5. |
|
||||
| `wizard` | Mode B | Dotted state cursor encoding which row is active and the picks made: `wizard=kind:filing,forum:upc,pt:upc.inf.cfi,active:event`. |
|
||||
| `event` | both | The locked trigger `procedural_events.code`. Once set, the result view renders. |
|
||||
| `trigger_date` | result | ISO date. Default = today; URL only carries it when overridden. |
|
||||
| `selected` | result | Comma-separated `sequencing_rules.id` checkbox state. Only carried when it differs from the priority default. |
|
||||
|
||||
Deep links work end-to-end: `?project=…&event=upc.inf.cfi.soc&trigger_date=2026-05-20&selected=…` jumps a colleague straight to the result view with the same picks. (Per Q11 — query string, not pathname.)
|
||||
|
||||
`popstate` rebuilds the page from the params alone (same pattern as today). The wizard state cursor lets browser back/forward step the wizard rows instead of dropping back to the page root.
|
||||
|
||||
---
|
||||
|
||||
## 6. Backend contract changes
|
||||
|
||||
### 6.1 Extend `/api/tools/fristenrechner/search`
|
||||
|
||||
Today returns concept-cards. Add an alternate response shape (or a `?kind=events` flag) that returns `procedural_events` rows directly:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "Klageerhebung",
|
||||
"filters": { "forum": "upc", "pt": null, "kind": "filing", "party": null },
|
||||
"events": [
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"code": "upc.inf.cfi.soc",
|
||||
"name_de": "Klageerhebung",
|
||||
"name_en": "Statement of Claim",
|
||||
"event_kind": "filing",
|
||||
"proceeding_type": { "code": "upc.inf.cfi", "jurisdiction": "UPC", "name": "..." },
|
||||
"follow_up_count": 3,
|
||||
"concept_id": "<uuid>",
|
||||
"score": 0.92
|
||||
}
|
||||
],
|
||||
"total": 12
|
||||
}
|
||||
```
|
||||
|
||||
The concept-card shape stays available for the legacy Pathway-B-filter route (kept as a deep-link compat surface, not user-facing).
|
||||
|
||||
### 6.2 New `/api/tools/fristenrechner/follow-ups`
|
||||
|
||||
Given a trigger event id + trigger date + optional party qualifier, return the follow-up `sequencing_rules` rows, grouped + with computed dates. Wire shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"trigger": { "id": "...", "code": "upc.inf.cfi.soc", "name_de": "Klageerhebung", "event_kind": "filing", "proceeding_type": { "code": "upc.inf.cfi", "name_de": "Verletzungsverfahren", "jurisdiction": "UPC" } },
|
||||
"trigger_date": "2026-05-20",
|
||||
"party": "claimant",
|
||||
"follow_ups": [
|
||||
{
|
||||
"rule_id": "<uuid>",
|
||||
"title_de": "Klageerwiderung",
|
||||
"title_en": "Defence",
|
||||
"priority": "mandatory",
|
||||
"primary_party": "defendant",
|
||||
"duration_phrase": "3 Monate",
|
||||
"due_date": "2026-08-20",
|
||||
"is_court_set": false,
|
||||
"is_spawn": false,
|
||||
"condition_expr": null,
|
||||
"rule_code": "RoP 23",
|
||||
"notes_de": "...",
|
||||
"spawn_label": null,
|
||||
"spawn_proceeding_type": null,
|
||||
"appeal_target": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: `FristenrechnerService.LookupFollowUps(ctx, eventID, triggerDate, party)` — wraps `catalog.LookupEvents(axes={EventID:…, Depth:Next})` (already implemented for the Litigation Planner per `services/fristenrechner.go:251`) and runs the result through `pkg/litigationplanner.Calculate` to fill the dates. The calculator is unchanged.
|
||||
|
||||
### 6.3 No schema changes
|
||||
|
||||
This design is pure UX + handler shape. The unified `sequencing_rules` model already has every column needed (priority, condition_expr, spawn_*, is_court_set, primary_party, applies_to_target). No migration accompanies this design.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration plan — from current row stack to the overhaul
|
||||
|
||||
Drop nothing on day one; co-exist for one release. The cutover is by URL flag.
|
||||
|
||||
| Phase | What changes | What survives | Branch |
|
||||
|---|---|---|---|
|
||||
| **S1 — Backend** | Add `GET /search?kind=events`. Add `GET /follow-ups`. Both feature-flagged behind a request header (off by default). | Existing endpoints. | one PR |
|
||||
| **S2 — Result view** | New `frontend/src/client/fristenrechner-result.ts` module — given a trigger event + date, render the §4 result view. Mount under a `?overhaul=1` query flag on /tools/fristenrechner. The legacy `renderProcedureResults` stays. | All today's UI. | one PR |
|
||||
| **S3 — Mode A** | New search-with-filter-chips UI. Mount alongside the row stack under `?overhaul=1`. | Row stack still primary. | one PR |
|
||||
| **S4 — Mode B (wizard)** | New `frontend/src/client/fristenrechner-wizard.ts` — the 3-5 row stack. Replaces today's `buildRowStack` only when `?overhaul=1`. Project prefill logic from `buildRowStack` ports 1:1. | The legacy row stack stays in place under no flag. | one PR |
|
||||
| **S5 — Flip the flag** | `?overhaul=1` becomes the default. Legacy row stack and `event_categories`-based cascade rendered with a hard-coded `?legacy=1` for two weeks. | Procedure mode (the upper half of `fristenrechner.tsx`) is unchanged throughout. | one PR |
|
||||
| **S6 — Cleanup** | Drop the `buildRowStack` function tree and the `event_categories`-served cascade endpoint (the table can stay — it's still semantically a useful taxonomy for future tools, just not the Fristenrechner's UI). Drop the `HIDDEN_CASCADE_ROOTS` constant and the cascade-segment bridge. | None of today's row-stack code. | one PR |
|
||||
|
||||
Single project per slice; each PR rebases off main; no shared branches.
|
||||
|
||||
The `event_categories` table itself **stays** — `audit-fristen-logic-2026-05-13.md` §2.4 already calls it "a config layer" useful for taxonomy work. The Fristenrechner just no longer reads it. Future tools (the "Ich möchte einreichen" forward-workflow surface m hid in `HIDDEN_CASCADE_ROOTS`) can resurrect it without DB migration.
|
||||
|
||||
---
|
||||
|
||||
## 8. Worked example — "PA at LG Düsseldorf bekommt einen Hinweisbeschluss via CMS in einer aktiven Akte"
|
||||
|
||||
Project: `HL-2024-001`, proceeding_type=`de.inf.lg` (Verletzungsverfahren LG), `our_side='defendant'`, `court='LG Düsseldorf'`.
|
||||
|
||||
### 8.1 Wizard path (Mode B, default)
|
||||
|
||||
User opens /tools/fristenrechner with that project in Step 0. Mode tab defaults to "🧭 Geführt".
|
||||
|
||||
Wizard rows render top-to-bottom, pre-filled where the project implies:
|
||||
|
||||
```text
|
||||
[1] Was ist passiert? [ active — chips for filing/hearing/decision/order/missed ]
|
||||
[2] Vor welchem Gericht? ✓ LG (aus Akte: HL-2024-001) ← prefilled+collapsed
|
||||
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled+collapsed
|
||||
```
|
||||
|
||||
User clicks ⚖️ Entscheidung in R1.
|
||||
|
||||
Row stack updates:
|
||||
```text
|
||||
[1] Was ist passiert? ✓ Entscheidung ← answered
|
||||
[2] Vor welchem Gericht? ✓ LG (aus Akte) ← prefilled
|
||||
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled
|
||||
[4] Welche Entscheidung konkret? [ active — chips: Urteil, Beschluss, Hinweisbeschluss, ... ]
|
||||
```
|
||||
|
||||
R4 chip set is the `procedural_events` whose `proceeding_type_id` = de.inf.lg AND `event_kind` = 'decision'. (Hinweisbeschluss is in this set — `de.inf.lg.hinweisbeschluss` or similar.)
|
||||
|
||||
User clicks Hinweisbeschluss. The wizard checks: do the follow-up rules differ by `primary_party`? In this case yes (the Hinweis triggers a reply window for the defendant only). So R5 fires:
|
||||
|
||||
```text
|
||||
[5] Welche Seite vertreten Sie? ✓ Beklagtenseite (aus Akte) ← prefilled
|
||||
```
|
||||
|
||||
R5 is pre-filled from `project.our_side='defendant'`. The user could click ändern to override, but doesn't.
|
||||
|
||||
Wizard transitions to the §4 result view. Trigger card: "📜 Hinweisbeschluss · de.inf.lg · LG · Beklagtenseite". Trigger date defaults to today.
|
||||
|
||||
### 8.2 Result view
|
||||
|
||||
Three follow-ups in scope (illustrative):
|
||||
|
||||
```text
|
||||
MANDATORY
|
||||
☑ Stellungnahme zum Hinweisbeschluss (Frist 4 Wochen) — 24.06.2026 — ZPO §139
|
||||
RECOMMENDED
|
||||
☑ Anpassung der Klageerwiderung — 24.06.2026 — best practice
|
||||
OPTIONAL
|
||||
□ Antrag auf Fristverlängerung (begründet) — auf Antrag
|
||||
```
|
||||
|
||||
User unchecks "Anpassung", changes the Stellungnahme date inline to 2026-06-20 (one weekday earlier), clicks "In Akte HL-2024-001 eintragen ▶".
|
||||
|
||||
Modal opens with the 1 selected deadline + the user's date override. User confirms.
|
||||
|
||||
### 8.3 Write-back
|
||||
|
||||
Server-side: `POST /api/projects/HL-2024-001/deadlines/bulk` with one `CreateDeadlineInput`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Stellungnahme zum Hinweisbeschluss",
|
||||
"rule_code": "ZPO §139",
|
||||
"due_date": "2026-06-20",
|
||||
"original_due_date": "2026-06-24",
|
||||
"source": "fristenrechner",
|
||||
"rule_id": "<sr-uuid>",
|
||||
"notes": null
|
||||
}
|
||||
```
|
||||
|
||||
`DeadlineService.CreateBulk` inserts the row into `paliad.deadlines` (with `sequencing_rule_id` populated from `rule_id`), creates the audit event with the wording "Aus Fristenrechner — Trigger: Hinweisbeschluss (2026-05-26)", and the user is redirected to `/deadlines?project_id=…` with a green success toast.
|
||||
|
||||
### 8.4 Mode A path for the same user
|
||||
|
||||
User flips the mode tab to "⚡ Direkt suchen". Filter chips auto-load to Forum=DE + Proceeding=de.inf.lg (from project context). User types "Hinweis" — the result list shows `de.inf.lg.hinweisbeschluss` (and maybe `upc.inf.cfi.hinweis` filtered out because Forum=DE narrows it). User clicks. Same result view appears.
|
||||
|
||||
Total clicks Mode A: 2 (type + click). Mode B: 2 (R1 chip + R4 chip; R2/R3/R5 prefilled). The wizard wins for trainees who don't know vocabulary; search wins for power users who know "Hinweisbeschluss" and can type 4 chars.
|
||||
|
||||
---
|
||||
|
||||
## 9. What's NOT in scope
|
||||
|
||||
- **Replacing the `sequencing_rules` model.** Phase 3 schema is already what the calculator runs on.
|
||||
- **Paliadin (LLM) integration into the wizard.** A "Frist-Extraktion aus Dokument" path is filed elsewhere (memory `b6a11b55…`) and stays out of this design. The wizard could later call out to Paliadin for "the user typed something we don't know" — Phase 2 of *this* overhaul, not Phase 1.
|
||||
- **Calendar / Outlook sync** of created deadlines. Separate t-paliad ticket per project-status.md long-term goals.
|
||||
- **Editing `sequencing_rules`** from the result view. Read-only here. The admin surface at `/admin/procedural-events` handles editing.
|
||||
- **The Procedure-mode surface** (upper half of `fristenrechner.tsx`). The proceeding-picker + trigger-date + flag-checkbox UI stays exactly as it is today. That surface answers a different question ("show me the full procedural ablauf for upc.inf.cfi") and is the right tool for that question; the overhaul targets only the Pathway-B / row-stack half of the page.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions for m (12 questions, batched for `AskUserQuestion`)
|
||||
|
||||
All 12 questions tracked in m/paliad#146 § "Open design questions". Each gets a recommended option (listed first in the AskUserQuestion call). Bundled into 3 batches of 4.
|
||||
|
||||
| # | Topic | Recommended pick |
|
||||
|---|---|---|
|
||||
| Q1 | Single page or stepper? | Single page with mode-tabs + collapsible rows. |
|
||||
| Q2 | Mode switcher placement | Tab pair under Step-0 ("Akte / kontextfrei"). |
|
||||
| Q3 | Filter-vs-qualifier UX | Qualifiers carry a small "(Pflichtangabe)" tag; filters render in a slimmer pill. |
|
||||
| Q4 | Cascade tree (keep/replace) | Replace with the 5-question wizard. Drop `event_categories` from the Fristenrechner UI (table stays). |
|
||||
| Q5 | Result grouping | 4 visible groups (Mandatory / Recommended / Optional / Conditional), SPAWNED folded with badge. |
|
||||
| Q6 | Project write-back UX | Confirm-and-edit-dates modal (revise each date once before commit). |
|
||||
| Q7 | No-project mode | CTA disabled with hint ("Wähle eine Akte oben"). Match today's pattern. |
|
||||
| Q8 | Perspective semantics by mode | Mode B (file): qualifier — required pick. Mode A (search): filter — optional. |
|
||||
| Q9 | Trigger-date input timing | In the result-view trigger card; default today; inline editable. |
|
||||
| Q10 | Backward navigation | Preserve compatible downstream picks; reset only those invalidated. |
|
||||
| Q11 | Deep-link encoding | Query string (`?event=…&trigger_date=…`). |
|
||||
| Q12 | Audit reason wording | `Aus Fristenrechner — Trigger: {name} ({date})`. |
|
||||
|
||||
(Recommendations land as the "first option" in each AskUserQuestion call per the inventor SKILL contract.)
|
||||
|
||||
---
|
||||
|
||||
## 11. m's decisions (2026-05-26)
|
||||
|
||||
All 12 questions answered via `AskUserQuestion` on 2026-05-26 21:30. Recording each pick + the reasoning where it diverges from the inventor's recommendation. Sections of the design that are now load-bearing on these answers carry a "(m §11.Q{n})" cross-reference.
|
||||
|
||||
- **Q1 (Page layout): Single page, mode-tabs.** [= recommendation] Both modes share /tools/fristenrechner; the mode-tabs swap the question surface in place. Result view is shared. **Locks §3, §4, §5.**
|
||||
- **Q2 (Mode switcher): Tab pair under Step-0.** [= recommendation] "⚡ Direkt suchen" / "🧭 Geführt" tabs render directly below the Akte picker. Project context survives the tab flip; compatible filter picks (forum, proceeding) carry across.
|
||||
- **Q3 (Filter-vs-qualifier UX): Section split — Filter above, Qualifier below.** [≠ recommendation; m picked option 2.] Mode A's filter chips render in a "Filter (eingrenzen)" strip on top; below it, the result list is the qualifier surface (clicking a row locks). Mode B wizard rows carry a small "Filter" / "Qualifier" badge in the row badge area (e.g. R1/R2 = Filter, R3/R5 = Qualifier). The "(Pflichtangabe)" tag from the original recommendation is replaced by this section-level visual hierarchy. **Updates §3.1 (Mode A layout) and §3.2 (wizard row badges).**
|
||||
- **Q4 (Cascade tree): Replace with wizard, keep table.** [= recommendation] The Fristenrechner UI stops reading `paliad.event_categories`. The table stays for future tools (the hidden "Ich möchte einreichen" forward-workflow). **Locks §3.2 and the cleanup in §7 S6.**
|
||||
- **Q5 (Result grouping): 4 groups + SPAWNED badge.** [= recommendation] Mandatory / Recommended / Optional / Conditional are the four sub-sections; spawned rules fold into their priority bucket with a `⇲ neues Verfahren` badge. **Locks §4.2.**
|
||||
- **Q6 (Write-back UX): Confirm-and-edit-dates modal.** [= recommendation] Inline checkbox selection in the result view → "In Akte eintragen ▶" → modal with editable due-date fields per row + Akte picker. **Locks §4.4.**
|
||||
- **Q7 (No-project mode): Hide the CTA entirely.** [≠ recommendation; m picked option 3.] In kontextfrei mode the result view renders without the write-back footer at all — no disabled-with-hint button. Rationale (inferred from m's pick): the result view is informational by design in explore mode, and a permanently-disabled CTA is visual noise. **Updates §4.4** — the CTA is conditional on `project != null`, not on `disabled`. The hint message moves into the Step-0 picker: when a user is in kontextfrei mode and reaches a result view, a one-line nudge appears above the result groups ("Tipp: Wähle oben eine Akte, um diese Fristen einzutragen") with a link to focus the Akte picker. This preserves the affordance discovery without polluting the footer.
|
||||
- **Q8 (Perspective semantics): Mode B qualifier, Mode A filter.** [= recommendation] Wizard Mode B's R5 is required and Klagerseite/Beklagtenseite only (no "Beide"); Mode A's perspective chip is a filter with a "Beide" option, off by default. **Locks §2 axis table and §3.2 R5 description.**
|
||||
- **Q9 (Trigger-date input): In the result-view trigger card.** [= recommendation] The sticky header card on the result view shows the date; default today; inline editable. Changing it re-renders follow-up dates live. **Locks §4.1.**
|
||||
- **Q10 (Backward navigation): Preserve compatible picks.** [= recommendation] Re-opening any wizard row keeps downstream picks that are still legal under the new upstream value; resets only the picks the new value invalidates. A small chip-strip annotation ("erhalten") appears for one render-cycle on rows whose pick was carried so the user notices. **Updates §3.2 branching policy.**
|
||||
- **Q11 (Deep-link encoding): Query string.** [= recommendation] `?project=…&mode=…&event=…&trigger_date=…&selected=…&forum=…&pt=…&kind=…&party=…` — every state piece is a query param. `popstate` rebuilds the page from params. **Locks §5.**
|
||||
- **Q12 (Audit reason wording): `Aus Fristenrechner — Trigger: {name} ({date})`.** [= recommendation] German-locale, includes the trigger event name and its ISO date. Stored as `paliad.project_events.metadata->>'audit_reason'` via the existing `DeadlineService.CreateBulk` audit hook. **Locks §4.4.**
|
||||
|
||||
### 11.1 What changed from the strawman as a result
|
||||
|
||||
Two follow-on edits flow from m's picks:
|
||||
|
||||
1. **§3.1 Mode A layout** — top strip is "Filter (eingrenzen)" with the four filter chip groups (Forum · Proceeding · Event-Kind · Partei); the result list directly below carries the implicit "click here to lock" qualifier action. No "(Pflichtangabe)" tag.
|
||||
2. **§4.4 Write-back footer** — the footer is rendered conditionally on `project != null`. The kontextfrei-mode informational nudge moves into the result view body above the deadline groups.
|
||||
|
||||
These edits don't change the §7 migration plan or the §6 backend contracts.
|
||||
|
||||
---
|
||||
|
||||
## 12. Synthesis links
|
||||
|
||||
- mBrian topic: `topic-fristenrechner` (existing) — file this design as a `[synthesis]` node linked `triggered_by` t-paliad-322 and `related_to` the row-cascade + Phase 2 designs.
|
||||
- Related memories: row-cascade design `0fbd2c1a-…`, Phase 2 design `a454dc86-…`, audit logic `f6c0c3a2-…`.
|
||||
1618
docs/design-litigation-planner-2026-05-26.md
Normal file
1618
docs/design-litigation-planner-2026-05-26.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ A full org export today is **< 600 rows of user content** plus reference data
|
||||
|
||||
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
|
||||
|
||||
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) — admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
|
||||
**Existing export precedent.** _(Originally pointed at the admin rule-migration export. That tool was deleted in m/paliad#129 / t-paliad-297. The gating pattern — `adminGate(users, …)` on a download endpoint that streams a generated artifact — still lives on other admin handlers, e.g. `handleAdminDownloadBackup` for `/api/admin/backups/{id}/file`.)_ Re-use the gating helper.
|
||||
|
||||
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
|
||||
|
||||
@@ -591,7 +591,7 @@ No other slice deltas. v1 still ships slices 1+2+3.
|
||||
- `docs/design-data-model-v2.md` — projects + mandanten + ltree path + can_see_project predicate.
|
||||
- `docs/design-approval-policy-ui-2026-05-07.md` — 5-source audit union (this design adds the 6th source).
|
||||
- `docs/design-profession-vs-project-role-2026-05-07.md` — profession ladder for the §4 project gate.
|
||||
- `internal/handlers/admin_rules.go:303` — `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
|
||||
- `internal/handlers/backups.go` — `handleAdminDownloadBackup` (precedent for admin-gated artifact download; the older rule-migration export precedent was removed in t-paliad-297).
|
||||
- `internal/services/project_service.go:15` — visibility predicate.
|
||||
- `internal/services/derivation_service.go` — `EffectiveProjectRole` for the project gate.
|
||||
- `github.com/xuri/excelize/v2` — chosen xlsx library.
|
||||
|
||||
277
docs/design-procedural-events-b0-findings-2026-05-26.md
Normal file
277
docs/design-procedural-events-b0-findings-2026-05-26.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Slice B.0 — Live DB re-validation findings (t-paliad-273)
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-26
|
||||
**Branch:** `mai/curie/researcher-slice-b-zero`
|
||||
**Predecessor:** `docs/design-procedural-events-model-2026-05-25.md` (cronus, t-paliad-262)
|
||||
**Scope:** READ-ONLY re-validation of the design doc's §1 premises against the live youpc Supabase `paliad` schema. No migration SQL written, no writes to `deadline_rules` or any table. B.1 (additive migration) remains blocked pending m's greenlight.
|
||||
|
||||
This document does **not** redesign the schema. It does **not** propose new structural changes. It records what the live DB looks like ~24 hours after the design was authored, flags every claim that drifted, and gives the eventual B.1 coder a current-as-of-2026-05-26 baseline to plan against.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
The design doc's §1 premises were sound on 2026-05-25. **All numeric premises drifted in the 24 hours since.** The qualitative model (`deadline_rules` conflates three concepts; live `deadlines.rule_id` FK; snapshot precedent established; no `proceeding_event*` tables) still holds.
|
||||
|
||||
The Q5 default ("10 archived multi-row submission_codes collapse safely") is now **moot**: those rows were removed from the live DB between 2026-05-25 15:30 and 2026-05-26 13:30. There are now **zero** multi-row submission codes; every active submission_code maps 1:1 to one rule row. B.1 backfill no longer needs the multi-row collapse logic that §5 of the design doc anticipated.
|
||||
|
||||
The Q6 default ("concept_id attaches to procedural event, not sequencing rule") is **directionally correct but needs refinement**. The empirical attachment is **above** the procedural-event level — `deadline_concepts` rows cluster legal meaning *across* jurisdictional procedural-event variants. One concept_id can span 15 distinct submission_codes (e.g. "Berufungsfrist" across BGH / BPatG / LG / OLG for both PatG and ZPO paths). The FK in §4.1's draft schema (`procedural_events.concept_id REFERENCES deadline_concepts(id)`, N:1) is **already correctly shaped** for this — no schema change needed. The verbal claim in the design doc should be tightened to "one `deadline_concept` row may be referenced by many procedural events; the FK lives on `procedural_events`."
|
||||
|
||||
Migration tracker drift: the design's "next available mig = 124" is stale; live head is 133 (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27 — applied **after** the design was written). **Next available is 134.** Ten migrations landed since the doc was authored — 124..133. None of them touched `deadline_rules` schema, but they did mutate row content (the missing 23 rows and the new event_type/legal_source distribution come from migs 127/128/132/133).
|
||||
|
||||
The design's claimed migration tracker `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 native counter (stuck at v106). The **canonical** tracker is `paliad.applied_migrations` (one row per applied migration, with checksum + applied_at). `internal/db/migrate.go:9-21` is the source of truth. Project CLAUDE.md still says `paliad.paliad_schema_migrations`; that's a stale doc, not a B.0-scope fix.
|
||||
|
||||
One doc-side bug fixed by this slice: design doc §1 + m/paliad#93 issue body referenced `paliad.deadlines.deadline_rule_id`. Live column is `paliad.deadlines.rule_id`. Both files patched on this branch.
|
||||
|
||||
---
|
||||
|
||||
## §1 Headline-count drift table
|
||||
|
||||
All numbers taken 2026-05-26 ~13:30 UTC against the live `paliad` schema.
|
||||
|
||||
| Metric | Design (2026-05-25) | Live (2026-05-26) | Δ | Notes |
|
||||
|---|--:|--:|--:|---|
|
||||
| `deadline_rules` row count | 254 | **231** | -23 | All rows `is_active = true`. No soft-deletes in flight. |
|
||||
| Rows with `submission_code` | 177 | **153** | -24 | |
|
||||
| Distinct `submission_code` values | 158 | **153** | -5 | **All 5 lost are the multi-row `_archived_litigation.*` codes** — see §2. |
|
||||
| Rows with `legal_source` | 102 | **112** | +10 | |
|
||||
| Distinct `legal_source` values | 70 | **87** | +17 | New jurisdictional variants seeded by recent migs (127/132/133). |
|
||||
| Rows with `concept_id` (linked to `deadline_concepts`) | 125 | **129** | +4 | 56% of the corpus is concept-linked, vs 49% in the design. |
|
||||
| `paliad.deadlines` rows | 1 | **5** | +4 | Still tiny — destructive cutover stays cheap. |
|
||||
| `paliad.submission_drafts` rows | 4 | **7** | +3 | |
|
||||
| Rules in `lifecycle_state = 'draft'` | 4 | **0** | -4 | All 4 design-era drafts were published or discarded. |
|
||||
|
||||
### event_type distribution
|
||||
|
||||
| `event_type` | Design | Live | Δ |
|
||||
|---|--:|--:|--:|
|
||||
| `filing` | 130 | 105 | -25 |
|
||||
| NULL | 77 | 89 | +12 |
|
||||
| `decision` | 25 | 21 | -4 |
|
||||
| `hearing` | 21 | 15 | -6 |
|
||||
| `order` | 1 | 1 | 0 |
|
||||
| **Total** | **254** | **231** | -23 |
|
||||
|
||||
The -23 row delta lands almost entirely in `filing` (-25) and `hearing` (-6), offset by +12 NULL — consistent with the disappearance of the `_archived_litigation.*` filings and a few archived `hearing` rows, plus seeding of new structural / parent-only rows by recent migrations.
|
||||
|
||||
### What did NOT drift (qualitative claims, still valid)
|
||||
|
||||
- `paliad.deadline_rules` carries 39 columns (design said 38 — drift +1; likely from mig 128 `deadline_rules_unit_check` which adds a CHECK without adding a column — or one of migs 124-133 added a column. Not investigated further; out of B.0 scope).
|
||||
- `paliad.deadlines.rule_id` (uuid, nullable) is the FK column to `paliad.deadline_rules.id`. **Confirmed via `information_schema.referential_constraints`** — `rule_id → paliad.deadline_rules(id)`. The doc-side mention of `deadline_rule_id` was always a typo.
|
||||
- `paliad.deadlines.rule_code` + `paliad.deadlines.custom_rule_text` both still present (the denormalized-display columns from mig 122).
|
||||
- `paliad.submission_drafts` uses `(project_id uuid nullable, submission_code text NOT NULL)` as its key — **no FK to deadline_rules**. Confirms the design's claim that the Schriftsätze surface filters on a text key, not on `deadline_rules.id`.
|
||||
- No `paliad.proceeding_event*` tables exist (einstein's 2026-05-08 graph design was never built — still the case).
|
||||
|
||||
---
|
||||
|
||||
## §2 Archived submission_code audit (Q5 re-confirm)
|
||||
|
||||
**Premise re-checked:** "10 archived multi-row submission_codes (`_archived_litigation.*`) collapse safely into single procedural events with multiple sequencing variants."
|
||||
|
||||
**Finding:** the premise is **moot in the live DB**.
|
||||
|
||||
```sql
|
||||
SELECT submission_code, COUNT(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code LIKE '_archived_litigation.%'
|
||||
GROUP BY submission_code;
|
||||
-- 0 rows
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT submission_code, COUNT(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
GROUP BY submission_code
|
||||
HAVING COUNT(*) > 1;
|
||||
-- 0 rows
|
||||
```
|
||||
|
||||
Every active submission_code in the live corpus is 1:1 with its `deadline_rules` row. The 10 multi-row codes the design anticipated no longer exist.
|
||||
|
||||
**Consequence for B.1 backfill:**
|
||||
|
||||
- The §5.1 / §5.2 backfill SQL the design sketched (collapsing N rows-with-same-submission_code into 1 procedural_event + N sequencing_rules) is **simpler than expected**: a straight 1:1 backfill, no GROUP-BY-and-collapse step needed.
|
||||
- B.1's `INSERT INTO paliad.procedural_events ... SELECT DISTINCT submission_code ...` becomes equivalent to `INSERT ... SELECT submission_code, ... FROM deadline_rules WHERE submission_code IS NOT NULL`. No deduplication needed.
|
||||
- The 78 rows where `submission_code IS NULL` (231 - 153) still need a B.1 decision: do they become `procedural_events` rows (with synthetic codes), do they become free-standing `sequencing_rules` with `procedural_event_id` NULL, or do they get parked? This was implicit in the design (the 77 NULLs were framed as "structural / parent-only rows in the proceeding tree"); B.1 should make the decision explicit and document it in the migration's `.up.sql` comments.
|
||||
|
||||
---
|
||||
|
||||
## §3 concept_id attachment shape (Q6 re-confirm)
|
||||
|
||||
**Premise re-checked:** "concept_id attaches to procedural event, not sequencing rule."
|
||||
|
||||
**Finding:** **partly true.** The FK direction the design proposes (`procedural_events.concept_id → deadline_concepts.id`, N:1) is correct. The verbal phrasing in Q6's default needs refinement — the empirical attachment is **above** the procedural-event level, not "at" it.
|
||||
|
||||
### Empirical pattern
|
||||
|
||||
129 of 231 rows carry a `concept_id`. Those 129 rows reference **53 distinct `deadline_concepts`** rows. Averages: 2.43 rows-per-concept, 2.42 submission-codes-per-concept (the two are nearly identical because today's corpus has no multi-row submission codes — see §2). Span distribution:
|
||||
|
||||
- 33 of 53 concepts (62%) attach to exactly 1 submission_code → procedural-event-scoped.
|
||||
- 20 of 53 concepts (38%) attach to >1 submission_code → cross-procedural-event scoped.
|
||||
- Maximum: 1 concept attaches to **15 distinct submission_codes**.
|
||||
|
||||
### Example: one concept, four procedural events
|
||||
|
||||
The concept `b85b2e5a-4064-40b2-b862-24b7abaa5b94` ("Berufungsfrist / Berufungsschrift") is referenced by 4 `deadline_rules` rows that today carry these 4 distinct submission_codes:
|
||||
|
||||
| rule_code | submission_code | court | name |
|
||||
|---|---|---|---|
|
||||
| § 110 PatG | `de.null.bgh.berufung` | BGH | Berufungsschrift |
|
||||
| § 110 PatG | `de.null.bpatg.berufung` | BPatG | Berufungsfrist |
|
||||
| § 517 ZPO | `de.inf.lg.berufung` | LG | Berufungsfrist |
|
||||
| § 517 ZPO | `de.inf.olg.berufung` | OLG | Berufungsfrist |
|
||||
|
||||
Under Slice B's target schema (§4.1), each of these four rows becomes a separate `procedural_events` row (different `code`s, different jurisdiction-specific names, different `legal_source_id`s), but **all four reference the same `deadline_concepts.id`**.
|
||||
|
||||
### Implication for B.1
|
||||
|
||||
- `procedural_events.concept_id` should be **nullable** (62% of rows today have no concept link — the §4.1 sketch already allows this).
|
||||
- The constraint must be **N:1, not 1:1** (one `deadline_concept` may be referenced by many `procedural_events`). The §4.1 sketch (`concept_id uuid REFERENCES paliad.deadline_concepts(id)`) is already correctly N:1; a hypothetical "UNIQUE INDEX on `procedural_events.concept_id`" would break the existing data. **Do not add UNIQUE.**
|
||||
- The design doc's Q6 phrasing can be tightened to: "concept_id attaches to procedural event (N procedural events → 1 concept). Sequencing rules do not carry concept_id." — but this is a wording nit, not a structural change. It does **not** block B.1.
|
||||
|
||||
---
|
||||
|
||||
## §4 Snapshot precedent audit
|
||||
|
||||
**Premise re-checked:** the `paliad.deadline_rules_pre_<N>` snapshot pattern is established and ready for B.4's destructive drop.
|
||||
|
||||
**Finding:** confirmed and consistent.
|
||||
|
||||
Snapshot tables in `paliad`:
|
||||
|
||||
| Snapshot table | Origin migration |
|
||||
|---|---|
|
||||
| `deadlines_pre_089` | mig 089 |
|
||||
| `deadline_rules_pre_091` | mig 091 (destructive drop of legacy columns) |
|
||||
| `event_deadlines_pre_092` | mig 092 |
|
||||
| `event_deadline_rule_codes_pre_092` | mig 092 |
|
||||
| `deadline_rules_pre_093` | mig 093 |
|
||||
| `proceeding_types_pre_093` | mig 093 |
|
||||
| `projects_pre_094` | mig 094 |
|
||||
| `deadline_rules_pre_095` | mig 095 |
|
||||
| `proceeding_types_pre_096` | mig 096 |
|
||||
| `deadline_rules_pre_098` | mig 098 |
|
||||
|
||||
Pattern: `<original_table>_pre_<migration_number>`. Always created in the `.up.sql` of the destructive migration as `CREATE TABLE paliad.<t>_pre_<N> AS TABLE paliad.<t>;` (followed by the destructive DROP / ALTER).
|
||||
|
||||
**B.4's template:** before `DROP TABLE paliad.deadline_rules;` (and `ALTER TABLE paliad.deadlines DROP COLUMN rule_id;`), `mig <N>.up.sql` must include:
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;
|
||||
-- (optional) CREATE TABLE paliad.deadlines_pre_<N> AS TABLE paliad.deadlines;
|
||||
```
|
||||
|
||||
This is non-negotiable per m's snapshot policy and the precedent of migs 089-098. B.4 should not enter the deploy queue without it.
|
||||
|
||||
---
|
||||
|
||||
## §5 deadlines.rule_id doc bug — verified + patched
|
||||
|
||||
**Premise re-checked:** the live column on `paliad.deadlines` referencing `deadline_rules` is named `rule_id`, not `deadline_rule_id`.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```sql
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema='paliad' AND table_name='deadlines' AND column_name LIKE '%rule%';
|
||||
-- rule_id (uuid, nullable)
|
||||
-- rule_code (text, nullable)
|
||||
-- custom_rule_text (text, nullable)
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT kcu.column_name, ccu.table_name, ccu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu ON ...
|
||||
JOIN information_schema.constraint_column_usage ccu ON ...
|
||||
WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_schema='paliad' AND tc.table_name='deadlines';
|
||||
-- rule_id → paliad.deadline_rules.id
|
||||
```
|
||||
|
||||
**Fix applied on this branch:**
|
||||
|
||||
- `docs/design-procedural-events-model-2026-05-25.md` — §1 row 51 already says "the column is `rule_id` (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo)". §1 row 63 (the "Doc-side bug flagged" line) already names the fix target. **No change needed to the design doc — the inventor already flagged and described the bug; B.0 just re-confirms it.**
|
||||
- `m/paliad#93` issue body — line 56 says `paliad.deadlines.deadline_rule_id` in the Q3 migration shape. Patched via Gitea API on this slice. See §6 of this report.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration tracker drift (out-of-scope context)
|
||||
|
||||
The design doc said "next available mig number is 124 (mig 123 = Backup Mode Slice A, just shipped)". Live state on 2026-05-26 13:30:
|
||||
|
||||
- Latest applied migration: **133** (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27).
|
||||
- Next available: **134**.
|
||||
- Migrations 124-133 (all applied after the design was authored):
|
||||
|
||||
```
|
||||
124 de_inf_lg_replik_duplik_sequencing (2026-05-25 13:49)
|
||||
125 cross_cutting_filter_legal_source (2026-05-25 14:13)
|
||||
126 users_inbox_seen_at (2026-05-25 13:51)
|
||||
127 wave0_tier0_deadline_fixes (2026-05-25 14:13)
|
||||
128 deadline_rules_unit_check (2026-05-25 14:13)
|
||||
129 project_event_choices (2026-05-25 15:02)
|
||||
130 submission_drafts_language (2026-05-25 15:05)
|
||||
131 submission_drafts_party_selection (2026-05-25 15:02)
|
||||
132 wave1_tier1_rule_additions (2026-05-25 15:40)
|
||||
133 upc_dmgs_pi_court_followup (2026-05-25 15:27)
|
||||
```
|
||||
|
||||
These touched `deadline_rules` content (wave0/wave1 rule additions, sequencing fixes, unit checks) and adjacent tables, but did not change the conflated-three-concepts shape that motivates Slice B. The structural premise of the design holds; the row-level numbers shifted.
|
||||
|
||||
**Side observation (not a B.0 fix scope):** the project's `CLAUDE.md` says "Migration tracker is `paliad.paliad_schema_migrations` (avoids collision with other apps on the shared `public.schema_migrations`)." That sentence is stale. The **canonical tracker is `paliad.applied_migrations`** (per `internal/db/migrate.go:9-21,53,105`). `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 counter, frozen at v106; the migrate runner uses it only to bootstrap `applied_migrations` on first deploy of the new runner (`internal/db/migrate.go:219-240`). Recommend a separate doc-fix slice (out of B.0 scope) to update `.claude/CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## §7 Updated B.1 brief (no-op / minor adjustments only)
|
||||
|
||||
What the live data means for the design's §5 migration plan:
|
||||
|
||||
1. **Backfill is simpler.** No multi-row collapse logic needed (§2). One-to-one `INSERT INTO paliad.procedural_events SELECT submission_code, name, name_en, description, event_type AS event_kind, primary_party, ... FROM paliad.deadline_rules WHERE submission_code IS NOT NULL` against 153 rows.
|
||||
2. **The 78 NULL-submission_code rows need an explicit decision in B.1.** Either:
|
||||
- (a) Skip them — they remain `deadline_rules`-only and become orphan-once-deadline_rules-is-dropped. Not acceptable; B.4 would lose them.
|
||||
- (b) Mint synthetic codes (`null.<uuid8>` or similar) for the structural rows and create `procedural_events` for them.
|
||||
- (c) Treat them as "sequencing-rule-only" (a `sequencing_rules` row with NULL `procedural_event_id`) — would require `sequencing_rules.procedural_event_id` to be nullable, which contradicts §4.1's NOT NULL FK.
|
||||
- Default recommendation: **(b)** — mint codes, preserve every row. B.1 must document the mint rule in the `.up.sql`. Surface this to head before scheduling B.1.
|
||||
3. **concept_id stays N:1 on procedural_events.** No UNIQUE constraint. §4.1's sketch already does this; just don't accidentally tighten it.
|
||||
4. **Use migration number 134** (or whatever's the live `MAX(version)+1` at B.1-write-time; re-check at the moment of writing the file).
|
||||
5. **Snapshot before drop in B.4:** `CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;` per §4 precedent. **This is the hard-stop pre-condition for B.4 entering the deploy queue.**
|
||||
6. **Submission_drafts.submission_code → procedural_events.code text join** continues to work unchanged through B.1-B.3 because both names match. No B.5 dual-write needed for `submission_drafts`. (The design's §6.3 already noted this.)
|
||||
|
||||
None of these change the **shape** of the design — they tighten the backfill SQL and surface one explicit decision (point 2) for head.
|
||||
|
||||
---
|
||||
|
||||
## §8 Outputs of this slice (B.0)
|
||||
|
||||
| Artifact | Status |
|
||||
|---|---|
|
||||
| `docs/design-procedural-events-b0-findings-2026-05-26.md` (this file) | created on `mai/curie/researcher-slice-b-zero` |
|
||||
| `docs/design-procedural-events-model-2026-05-25.md` | cherry-picked from `mai/cronus/inventor-procedural` onto this branch (design doc was never merged to main; B.0 brings it onto a branch off main so the doc bug fix has somewhere to land) |
|
||||
| m/paliad#93 issue body — `deadline_rule_id` → `rule_id` correction | patched via Gitea API |
|
||||
| Gitea comment on m/paliad#93 summarizing this report | posted (see §6 trailing summary on the issue) |
|
||||
|
||||
**Nothing migrated, nothing written to `paliad.deadline_rules` or any other live data table.** Only `mai.reports` (progress) and the GitHub issue body / repo files were touched.
|
||||
|
||||
---
|
||||
|
||||
## §9 Hard-stop status
|
||||
|
||||
**B.0 COMPLETE. AWAITING B.1 GREENLIGHT.**
|
||||
|
||||
Per the original instruction:
|
||||
|
||||
- B.1 (additive migration creating `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources` + backfill) requires explicit m approval before any new tables get created.
|
||||
- B.4 (destructive drop of `paliad.deadline_rules` + `paliad.deadlines.rule_id`) requires m's downtime-window approval AND a `paliad.deadline_rules_pre_<N>` snapshot table in the same migration.
|
||||
- This researcher (curie) stays parked until head re-hires.
|
||||
|
||||
---
|
||||
|
||||
## §10 Decisions worth surfacing to m before B.1 starts
|
||||
|
||||
1. **NULL-submission_code rows (78 of them) — what to do during backfill?** Recommendation (b): mint synthetic codes. m should confirm or pick (a)/(c).
|
||||
2. **B.5 deprecation header window length** — the design (§8.2) says "one slice". For 7 active submission_drafts that's safe; the question is whether external integrations (Word templates with `{{rule.X}}`) need a longer window. The variable-bag alias contract (`submission_vars.go`) covers Word templates without a wire-format change, so "one slice" is defensible. m should confirm.
|
||||
3. **Migration number reservation** — by the time B.1 ships, the live head may be 135+. The B.1 coder must re-check `MAX(version)` at write-time. (Not a decision; just a process note.)
|
||||
|
||||
These are the only open questions the B.0 audit surfaced. Everything else in the design holds.
|
||||
571
docs/design-procedural-events-model-2026-05-25.md
Normal file
571
docs/design-procedural-events-model-2026-05-25.md
Normal file
@@ -0,0 +1,571 @@
|
||||
# Design — Procedural-Events Data Model (t-paliad-262)
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-25
|
||||
**Issue:** m/paliad#93 (mai task t-paliad-262)
|
||||
**Branch:** `mai/cronus/inventor-procedural`
|
||||
**Status:** DESIGN — read-only, no schema or code changes in this branch.
|
||||
**B.0 re-validation:** see `docs/design-procedural-events-b0-findings-2026-05-26.md` (curie, 2026-05-26) for the live-DB premise re-check. Numeric §1 claims drifted; Q5 multi-row collapse premise is moot (no `_archived_litigation.*` rows remain); Q6 N:1 attachment confirmed; mig number target updated 124 → 134.
|
||||
**Prior art read:**
|
||||
- `docs/design-deadline-data-model-2026-05-08.md` (einstein, t-paliad-158) — proposed `proceeding_event_types` + `proceeding_event_edges`; the **graph-shape recommendation has not been built** (no `proceeding_event*` tables exist in the live DB as of 2026-05-25, verified via `information_schema.tables`).
|
||||
- `docs/design-fristen-phase2-2026-05-15.md` (Phase 2/3 unified-rule columns — migs 078/079/091, **shipped**).
|
||||
- `docs/design-submission-generator-2026-05-19.md` and `docs/design-submission-page-2026-05-22.md` (Slice 1 → Slice A of the Schriftsätze stack — shipped on top of today's `deadline_rules`).
|
||||
|
||||
This doc names a single conflation in the schema and proposes a two-slice fix (cosmetic immediate, structural follow-up). It is intentionally narrower than einstein's 2026-05-08 graph proposal — it does **not** re-litigate the proceeding-as-DAG question.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
`paliad.deadline_rules` today is **one row that wears three hats**:
|
||||
|
||||
1. **The procedural-event template** — `submission_code`, `name`, `name_en`, `description`, `event_type`, `primary_party`. This is "what kind of step is this in the proceeding": Rechtsbeschwerdebegründung, mündliche Verhandlung, Entscheidung, etc.
|
||||
2. **The legal-norm citation** — `legal_source`, `rule_code`, `alt_rule_code`, `rule_codes[]`. This is "the source-of-law anchor": § 102 PatG, UPC RoP R.220(1).
|
||||
3. **The sequencing rule** — `parent_id`, `trigger_event_id`, `duration_value`, `duration_unit`, `timing`, `alt_duration_*`, `combine_op`, `condition_expr`, `is_spawn`, `spawn_*`, `sequence_order`, `is_court_set`, `priority`, `anchor_alt`, `proceeding_type_id`. This is "how and when does it fire relative to other events".
|
||||
|
||||
The conflation surfaces most painfully in the submission-draft editor's variable sidebar (m's report 2026-05-25 15:02), where the lawyer sees field labels like `{{rule.submission_code}}` for what is plainly a *procedural-event code*, `{{rule.event_type}}` for what is plainly the *procedural-event kind*, and `{{rule.legal_source_pretty}}` for what is plainly the *legal norm* — all under a `rule.*` namespace that reads as if the lawyer were filling in arithmetic.
|
||||
|
||||
**Recommendation = Q1 option (C):**
|
||||
|
||||
- **Slice A (immediate, this design's coder shift):** cosmetic rename — placeholders, i18n labels, Go struct-comment naming, admin-UI page titles all shift to `procedural_event.*` as the canonical name. **Database schema, table name, column names, FK directions, JSON envelope keys on the wire all stay exactly as they are.** Old `{{rule.*}}` placeholders remain emitted in the variable bag as legacy aliases so existing Word templates and saved drafts keep working.
|
||||
- **Slice B (planned follow-up, separate mai task, separate slice plan):** structural rework — extract `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources`, with a phased dual-write migration. **Not shipped here.** This doc defines the target shape (§4) and the migration shape (§5) so the eventual coder has a brief, not so the eventual coder is hired today.
|
||||
|
||||
**Umbrella term lock = Q2 option (R):** **"procedural event"** (DE: **"Verfahrensschritt"**) as the umbrella covering filings, hearings, decisions, orders. Justification in §2.
|
||||
|
||||
Both Slice A and the eventual Slice B preserve the Schriftsätze surface (t-paliad-238/242/243): the submissions list query changes its predicate from `dr.event_type = 'filing'` to `pe.event_kind IN ('filing', 'reply')` (Slice B only) — same rows, cleaner predicate.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-25)
|
||||
|
||||
Every load-bearing claim was checked against the running paliad codebase + youpc Supabase. Numbers and schema facts are point-in-time as of 2026-05-25 15:30.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| `paliad.deadline_rules` carries the 38 columns listed in §0's three-hats decomposition. | `information_schema.columns WHERE table_schema='paliad' AND table_name='deadline_rules'` — 38 rows; columns confirmed verbatim. |
|
||||
| Live row count = 254. | `SELECT COUNT(*) FROM paliad.deadline_rules` → 254. |
|
||||
| 177 rows carry a `submission_code` (procedural-event identity); 158 distinct values. | `COUNT(*) FILTER (WHERE submission_code IS NOT NULL)` → 177; `COUNT(DISTINCT submission_code)` → 158. |
|
||||
| 102 rows carry a `legal_source`; 70 distinct citations. | Same query, `legal_source` column. |
|
||||
| 125 rows are linked to a `deadline_concepts` row via `concept_id`. | `COUNT(*) FILTER (WHERE concept_id IS NOT NULL)` → 125 (49 % of the corpus). |
|
||||
| `event_type` distribution: 130 `filing` · 77 NULL · 25 `decision` · 21 `hearing` · 1 `order`. | `SELECT event_type, count(*) GROUP BY event_type` — confirmed; the 77 NULL rows are structural / parent-only rows in the proceeding tree. |
|
||||
| 10 `submission_code` values appear on more than one row (jurisdictional / bilateral variants). | All 10 today are `_archived_litigation.*` codes (claimant/defendant splits + multi-stage hearing rows). Live non-archived codes are 1:1 with rows in the current corpus. |
|
||||
| `paliad.deadlines` joins to `deadline_rules` via column `rule_id` (uuid, FK). The text `rule_code` and free-text `custom_rule_text` (mig 122, t-paliad-258) are denormalized for display when the rule row is deleted. | `internal/services/deadline_service.go:69-127`; live column list confirms `rule_id`, `rule_code`, `custom_rule_text` — there is **no** `deadline_rule_id` column on deadlines (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo; the column is `rule_id`). |
|
||||
| `paliad.submission_drafts` keys to a procedural event via `submission_code` text — **no FK** to `deadline_rules`. | `information_schema.columns` for `submission_drafts`: `submission_code text` plus `(project_id, submission_code)` as the joint identifier. Confirms the Schriftsätze surface filters on the *text key*, not on `deadline_rules.id`. |
|
||||
| The Schriftsätze list (t-paliad-238) filters `deadline_rules` by `event_type='filing'` and `submission_code IS NOT NULL`. | `internal/handlers/submissions.go:193-211` — verbatim. |
|
||||
| The variable bag emits exactly 8 `rule.*` placeholders. | `internal/services/submission_vars.go:349-364` — `rule.submission_code`, `rule.name`, `rule.name_de`, `rule.name_en`, `rule.legal_source`, `rule.legal_source_pretty`, `rule.primary_party`, `rule.event_type`. Frontend i18n labels at `frontend/src/client/submission-draft.ts:158-185`. |
|
||||
| Admin rule-edit form binds the same `rule.X` fields. | `frontend/src/admin-rules-edit.tsx:74-110` + `frontend/src/client/admin-rules-edit.ts:253-278` — same eight columns surfaced as form inputs. |
|
||||
| The Fristenrechner client surface refers to `calc.rule.nameDE` / `calc.rule.nameEN`. | `frontend/src/client/fristenrechner.ts:1592,1655`. |
|
||||
| einstein's 2026-05-08 `proceeding_event_types` + `proceeding_event_edges` are **not** in the DB. | `SELECT table_name FROM information_schema.tables WHERE table_schema='paliad' AND table_name LIKE '%proceeding_event%'` → 0 rows. The graph-shape proposal was never built. |
|
||||
| `paliad.deadline_concepts` (57 rows in the original einstein audit; live count not directly queried this shift) still exists and is referenced via `deadline_rules.concept_id`. | `information_schema.tables` confirms `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_types`, `trigger_events`, `event_categories` all still present — the deadline-knowledge graph from the einstein design lives on alongside the unified rule columns. |
|
||||
| Phase 2/3 columns (`priority`, `condition_expr`, `is_court_set`, `lifecycle_state`, `draft_of`, `published_at`, `rule_codes[]`) are live and load-bearing. | `internal/models/models.go:622-684` + mig 091. Slice B's structural rework must preserve every one of these on the new `sequencing_rules` table — they are not legacy. |
|
||||
| Live `paliad.deadlines` references to rules are sparse (1 row in prod). | `SELECT COUNT(*) FROM paliad.deadlines` → 1. The 4 `submission_drafts` rows reference a procedural event by `submission_code` text only. Tiny live FK surface → migrations can be aggressive without losing user data. |
|
||||
| Migration tracker is `paliad.paliad_schema_migrations`; next available number is 124 (mig 123 = Backup Mode Slice A, just shipped). | `internal/db/migrations/` directory listing; latest applied = 123. |
|
||||
|
||||
**Doc-side bug flagged for this issue's body:** the deliverable spec writes `paliad.deadlines.deadline_rule_id` in §3 (Q3 migration shape). The live column is `paliad.deadlines.rule_id`. Slice B's rename target is therefore `paliad.deadlines.procedural_event_id`, renamed directly from `paliad.deadlines.rule_id` — there is no intermediate `deadline_rule_id` step (no such column exists). Updating the issue body is m's call — flagged here so it doesn't propagate into a coder brief. *(B.0 update 2026-05-26: issue body patched. See `docs/design-procedural-events-b0-findings-2026-05-26.md` §5.)*
|
||||
|
||||
---
|
||||
|
||||
## §2 m's vocabulary call (Q2 — lock the umbrella term)
|
||||
|
||||
m proposed "procedural event" in the report. Options weighed:
|
||||
|
||||
| Option | Reads as | Collisions | Verdict |
|
||||
|---|---|---|---|
|
||||
| **"procedural event"** (DE: "Verfahrensschritt") | Umbrella that naturally covers filings, hearings, decisions, orders. Matches lawyer mental model: "the next thing that happens in the proceeding". | None — no `paliad.procedural_event*` table or column today (verified). | **(R) — adopt as canonical.** |
|
||||
| "submission" | Today the Schriftsätze surface uses this for *filings only* (`event_type='filing'`). Expanding the meaning would silently change Slice A's semantics for an existing UI. | Surface-level collision with the Schriftsätze nomenclature already in production. | Reject — would lose precision for an existing concept. |
|
||||
| "event" / "event_type" | Existing `deadline_rules.event_type` column. | **Hard collision** with `paliad.events` (audit feed, distinct table, distinct meaning). Renaming around it would be worse than the conflation we're trying to fix. | Reject. |
|
||||
| "Verfahrensschritt" only (no English) | Cleanest German but no English fallback. | Bilingual UI (DE primary, EN secondary per project CLAUDE.md) requires both. | Reject in isolation — but **adopt as the canonical German rendering** of "procedural event". |
|
||||
| "Verfahrensereignis" | Closer literal translation of "procedural event". | None. | Reject in favor of "Verfahrensschritt" — m's broader vocabulary uses "Schritt" (e.g. "Antragsschritt") more naturally than "Ereignis", which already maps to `paliad.events` in the audit-feed sense. |
|
||||
|
||||
**Lock:**
|
||||
|
||||
| Surface | Canonical |
|
||||
|---|---|
|
||||
| English | **procedural event** (lowercase except sentence-initial) |
|
||||
| German | **Verfahrensschritt** (m. — der Verfahrensschritt) |
|
||||
| Plural EN | procedural events |
|
||||
| Plural DE | Verfahrensschritte |
|
||||
| Code identifier (Go struct names, TS types) | `ProceduralEvent`, `ProceduralEventKind`, `ProceduralEventTemplate` |
|
||||
| Snake-case (DB columns, JSON keys, i18n keys, placeholders) | `procedural_event`, `procedural_event_kind`, `procedural_events` (table) |
|
||||
| Slice A: variable-bag placeholder namespace | `procedural_event.*` (with `rule.*` kept as legacy alias) |
|
||||
| Slice B: table name (if shipped) | `paliad.procedural_events` |
|
||||
|
||||
`event_type` (the column) becomes `event_kind` in Slice B — using "kind" rather than "type" to free up the word "type" for the proceeding-level taxonomy (`paliad.proceeding_types`, untouched) and to mirror the "event_type vs event_kind" disambiguation einstein hit in the 2026-05-08 doc. In Slice A the column stays `event_type` (no DB change).
|
||||
|
||||
**Q2 is locked by inventor recommendation.** It costs nothing structurally and clears noise across every downstream conversation. If m disagrees in the head round-trip, the only thing that flips is the term — Slice A's scope shape stays.
|
||||
|
||||
---
|
||||
|
||||
## §3 Scope decision (Q1 — A vs B vs C)
|
||||
|
||||
**Recommendation = (C) — cosmetic rename now, structural rework as a planned follow-up.**
|
||||
|
||||
### Why not (A) — cosmetic only and stop
|
||||
|
||||
(A) leaves the model wrong forever. The conflation isn't just a labelling annoyance — it makes future questions harder to answer cleanly:
|
||||
|
||||
- "How many distinct procedural events does paliad model?" Today: ambiguous (rows vs distinct `submission_code`s vs distinct `(submission_code, proceeding_type_id)` tuples).
|
||||
- "Where can we attach a per-procedural-event Word template that's independent of which proceeding it appears in?" Today: nowhere — the FK chain forces a per-row template registry, see `internal/handlers/files.go` template fallback.
|
||||
- "Show me every sequencing rule that triggers a given procedural event across all proceedings." Today: requires joining `deadline_rules` to itself on `submission_code` + `parent_id`, brittle.
|
||||
|
||||
If m signals (A) anyway — fine; the cosmetic-only slice is a strict subset of (C)'s Slice A and ships the same value (label clarity in the editor). But the recommendation is to write down the structural target now while the analysis is fresh.
|
||||
|
||||
### Why not (B) — restructure immediately
|
||||
|
||||
(B) means: one slice plan, one cutover. With:
|
||||
- 254 live rule rows,
|
||||
- 1 live `paliad.deadlines` row,
|
||||
- 4 live `submission_drafts` rows,
|
||||
- 12 Go services + 6 handlers touching `deadline_rules` + 8 placeholder strings on the wire + the admin rule-editor UI bound to the column shape,
|
||||
|
||||
…doing this in one cutover means a big-bang migration during a downtime window. m has granted exactly one such window in recent memory (2026-05-15 for mig 091's destructive drops), and that one was constrained to a 4-column drop. A four-table restructure has a meaningfully larger blast radius; it warrants its own task with its own slice plan and its own risk review.
|
||||
|
||||
### Why (C) — cosmetic-rename Slice A this design, structural Slice B as a separate task
|
||||
|
||||
Three properties of (C) make it the safe call:
|
||||
|
||||
1. **Slice A is reversible at any time** — every change is in i18n strings, Go struct comments, admin-UI page titles, and the variable-bag aliases. No DB migration. No drop. A revert is a `git revert` of the Slice A commit.
|
||||
2. **Slice B is fully designed but uncommitted** — §4 and §5 below define the target shape and migration plan, but the design doc itself ships in Slice A. m can read it, redirect it, or park it without pressure to ship it now.
|
||||
3. **The Schriftsätze surface doesn't care which slice we ship** — Slice A leaves it on `event_type='filing'`; Slice B flips it to `event_kind IN ('filing', 'reply')` over a dual-write window. Either way, the lawyer-facing behavior is unchanged.
|
||||
|
||||
### Slice A's deliverable boundary (what gets renamed, what stays)
|
||||
|
||||
**Renamed in Slice A:**
|
||||
|
||||
- **i18n keys** for the admin rule-editor field labels: `admin.rules.edit.field.submission_code` → `admin.rules.edit.field.procedural_event_code`, etc. (16 keys total — `name`, `name_en`, `description`, `submission_code`, `rule_code`, `legal_source`, `primary_party`, `event_type` × DE/EN — full list in §7.1.)
|
||||
- **Variable-bag placeholder labels** in `submission-draft.ts:158-185`: the *visible label* (`{ de: "Schriftsatz-Code", en: "Submission code" }`) is unchanged for filings (filings are still Schriftsätze on that surface), but the **namespace shown next to the placeholder string** changes: lawyer sees `{{procedural_event.code}}` in the placeholder column with the same Schriftsatz-Code label and same value. The old `{{rule.submission_code}}` stays in the catalog as an "(alt)" entry pointing at the same field.
|
||||
- **Variable-bag emission** (`internal/services/submission_vars.go:351-364`): the bag emits **both** key-names for every value, so any Word template / saved draft holding `{{rule.X}}` keeps working without a touch. New templates and the in-app catalog show the canonical `{{procedural_event.X}}` name.
|
||||
- **Admin page titles + section headings**: "Regel bearbeiten" → "Verfahrensschritt bearbeiten" (DE), "Edit rule" → "Edit procedural event" (EN). "Regeln verwalten" → "Verfahrensschritte verwalten" / "Procedural events". The URL path `/admin/rules` stays — URL renames have downstream cost (bookmarks, audit log entries) and would need their own redirect slice (out of scope here).
|
||||
- **Go struct comments + service docstrings + worker-facing log lines** that refer to "the rule" → "the procedural event" where the referent is the procedural-event aspect (not the sequencing-rule aspect). Function names, type names, table name stay (Slice B handles those).
|
||||
- **The "Submission Code / Einreichung-Kennung" label** itself stays (it's the lawyer's anchor — they recognize it). The framing around it changes: it now reads as "the code that identifies this *procedural event*", not "the code attached to this *rule*".
|
||||
|
||||
**Untouched in Slice A:**
|
||||
|
||||
- Database schema. Table name (`paliad.deadline_rules`). Column names. FK directions. Indexes. RLS policies. Triggers. Audit log column `rule_id`.
|
||||
- Go struct names: `DeadlineRule` stays. The renames here are *prose*, not *code*. Renaming `DeadlineRule` to `ProceduralEvent` couples Slice A to Slice B's table rename — keep them decoupled.
|
||||
- JSON envelope keys on the wire (`POST /api/admin/rules/:id` still accepts `submission_code` in the body — Slice B's API rename is a breaking change with its own deprecation window).
|
||||
- URL paths (`/admin/rules`, `/api/admin/rules/:id`, `/api/projects/:id/submissions` etc.).
|
||||
- `paliad.deadlines.rule_id` FK column name.
|
||||
- The variable-bag's legacy `{{rule.X}}` keys — kept forever as aliases (cheap, zero rot).
|
||||
- The `submission_drafts` table's `submission_code` text key.
|
||||
|
||||
This boundary makes Slice A a one-day coder shift: scoped, reversible, label-only.
|
||||
|
||||
### What Slice B inherits
|
||||
|
||||
Slice B inherits a codebase + a UI where every prose surface already speaks "procedural event". It also inherits a *legacy alias contract* (the dual emission in the variable bag) that gives it freedom to rename the JSON keys on the wire and the Go struct in two separate sub-slices without rushing.
|
||||
|
||||
---
|
||||
|
||||
## §4 Restructure schema (Q3 — if/when we ship Slice B)
|
||||
|
||||
This is the target the eventual Slice B coder would land. **Nothing here ships in this task.**
|
||||
|
||||
### §4.1 Three new tables (plus the rename of `deadline_rules`)
|
||||
|
||||
```sql
|
||||
-- 1. Procedural event templates — one row per (procedural-event identity)
|
||||
-- For now the live corpus is 1:1 with non-archived submission_codes
|
||||
-- (148 of the 158 distinct codes), so we get ~177 rows minus the 10
|
||||
-- multi-row codes' duplicates. Bilateral / jurisdictional variants
|
||||
-- are modeled at the sequencing_rules layer.
|
||||
CREATE TABLE paliad.procedural_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE, -- former submission_code
|
||||
name text NOT NULL, -- DE
|
||||
name_en text NOT NULL,
|
||||
description text,
|
||||
event_kind text NOT NULL, -- filing|reply|hearing|decision|order|other
|
||||
primary_party_default text, -- claimant|defendant|both|court
|
||||
legal_source_id uuid REFERENCES paliad.legal_sources(id),
|
||||
concept_id uuid REFERENCES paliad.deadline_concepts(id),
|
||||
lifecycle_state text NOT NULL DEFAULT 'published', -- draft|published|archived
|
||||
draft_of uuid REFERENCES paliad.procedural_events(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 2. Legal sources — the source-of-law citations the procedural event
|
||||
-- anchors against. ~70 distinct values today (live corpus).
|
||||
CREATE TABLE paliad.legal_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
citation text NOT NULL UNIQUE, -- "DE.PatG.102", "UPC.RoP.220.1", …
|
||||
jurisdiction text NOT NULL, -- DE|UPC|EPA|DPMA|other
|
||||
pretty_de text NOT NULL, -- "§ 102 PatG"
|
||||
pretty_en text NOT NULL, -- "Section 102 PatG"
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 3. Sequencing rules — the timing / trigger / condition mechanics that
|
||||
-- today live alongside the procedural-event identity on deadline_rules.
|
||||
-- One row per (procedural_event × proceeding × variant). The 10
|
||||
-- "_archived_litigation.*" codes that today have 2-5 rows become
|
||||
-- 2-5 sequencing_rules rows for the same procedural_events row.
|
||||
CREATE TABLE paliad.sequencing_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
procedural_event_id uuid NOT NULL REFERENCES paliad.procedural_events(id),
|
||||
proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
parent_id uuid REFERENCES paliad.sequencing_rules(id), -- structural tree, today's parent_id
|
||||
trigger_event_id bigint REFERENCES paliad.trigger_events(id), -- event-rooted variant
|
||||
duration_value integer NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months',
|
||||
timing text DEFAULT 'after',
|
||||
alt_duration_value integer,
|
||||
alt_duration_unit text,
|
||||
alt_rule_code text, -- legacy free-text alt citation, retained
|
||||
anchor_alt text,
|
||||
combine_op text, -- max|min
|
||||
condition_expr jsonb,
|
||||
primary_party text, -- per-rule override of the procedural_event default
|
||||
sequence_order integer NOT NULL DEFAULT 0,
|
||||
is_spawn boolean NOT NULL DEFAULT false,
|
||||
spawn_label text,
|
||||
spawn_proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
is_bilateral boolean NOT NULL DEFAULT false,
|
||||
is_court_set boolean NOT NULL DEFAULT false,
|
||||
priority text NOT NULL DEFAULT 'mandatory',
|
||||
rule_code text, -- legacy short-form citation, retained on the rule
|
||||
rule_codes text[], -- multi-citation array (mig pre-091)
|
||||
deadline_notes text,
|
||||
deadline_notes_en text,
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.sequencing_rules(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 4. Rename downstream FK + add the link to procedural_events.
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id),
|
||||
ADD COLUMN sequencing_rule_id uuid REFERENCES paliad.sequencing_rules(id);
|
||||
-- (rule_id stays as a transitional alias during the dual-write window;
|
||||
-- dropped at end of Slice B)
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 5. Submission drafts: add procedural_event_id FK alongside submission_code.
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id);
|
||||
-- (submission_code stays — it's the cosmetic anchor lawyers recognize
|
||||
-- in URLs and chat, and it doubles as the procedural_events.code value)
|
||||
```
|
||||
|
||||
### §4.2 What goes where (column-by-column map)
|
||||
|
||||
Every column on today's `paliad.deadline_rules` lands on exactly one of the three new tables:
|
||||
|
||||
| Today's `deadline_rules` column | Lands on | Notes |
|
||||
|---|---|---|
|
||||
| `id`, `created_at`, `updated_at` | `sequencing_rules` | The current row's identity becomes a sequencing-rule row. `procedural_events.id` is **new** — backfilled from `submission_code`. |
|
||||
| `submission_code` | `procedural_events.code` | Promoted up. Multi-row codes (10 in corpus, all `_archived_litigation.*`) collapse to one row on the new table; the 2-5 sequencing rows hang off it. |
|
||||
| `name`, `name_en`, `description` | `procedural_events` | Procedural-event identity. |
|
||||
| `primary_party` | `procedural_events.primary_party_default` AND `sequencing_rules.primary_party` | Both. The procedural event has a default party (claimant for Klage etc.); the sequencing rule can override per-jurisdiction (bilateral variants — e.g. `litigation.reply` claimant vs defendant become two sequencing rows with overridden party). |
|
||||
| `event_type` | `procedural_events.event_kind` | Hat 1, with rename to `event_kind` (term lock §2). |
|
||||
| `legal_source` | `legal_sources.citation` + FK from `procedural_events.legal_source_id` | The citation moves to its own row; the procedural event points at it. `pretty_de` / `pretty_en` materialize the existing `legalSourcePretty()` function output as columns (with the function retained as the migration source). |
|
||||
| `rule_code`, `alt_rule_code`, `rule_codes[]` | `sequencing_rules` | Short-form citation arrays stay on the sequencing rule — they're rule-specific. |
|
||||
| `proceeding_type_id`, `parent_id`, `trigger_event_id`, `spawn_proceeding_type_id`, `is_spawn`, `spawn_label`, `is_bilateral`, `is_court_set`, `combine_op` | `sequencing_rules` | Hat 3 (mechanics) — exact copies. |
|
||||
| `duration_value`, `duration_unit`, `timing`, `alt_duration_value`, `alt_duration_unit`, `anchor_alt` | `sequencing_rules` | Hat 3 (mechanics). |
|
||||
| `condition_expr` (jsonb) | `sequencing_rules` | Hat 3. The grammar from mig 091 stays. |
|
||||
| `priority`, `sequence_order` | `sequencing_rules` | Hat 3. |
|
||||
| `is_active`, `lifecycle_state`, `draft_of`, `published_at` | **BOTH** `procedural_events` AND `sequencing_rules` | A procedural event can be retired independently of any one of its sequencing variants. Backfill: copy onto both during dual-write; new rows go through the rule-editor service which writes both sides together. |
|
||||
| `concept_id` (FK to `deadline_concepts`) | `procedural_events.concept_id` | The concept layer (einstein 2026-05-08) attaches to the procedural event, not the sequencing rule. |
|
||||
| `deadline_notes`, `deadline_notes_en` | `sequencing_rules` | They're rule-specific notes ("filing the appeal in DE costs €X if you also did Y") — not procedural-event-wide. |
|
||||
|
||||
Three columns disappear:
|
||||
|
||||
- The semantically-overloaded part of `event_type` (renamed to `event_kind` and moved).
|
||||
- The "what is this thing" vs "how does it fire" name conflict — gone by construction.
|
||||
- Any column that exists only because of the conflation (none of today's columns are pure overhead — they all carry data — so the count stays at 38 across the three new tables).
|
||||
|
||||
### §4.3 Indexes + RLS
|
||||
|
||||
`paliad.can_see_project()` is the canonical RLS predicate (mig 055). None of the three new tables hold project-scoped data — they're firm-wide reference tables. RLS = none, same posture as today's `deadline_rules` (which is firm-wide and unrestricted at the row level; access control is via the `lifecycle_state='published'` filter in the read paths).
|
||||
|
||||
Indexes inherited from today:
|
||||
|
||||
- `paliad.legal_sources(citation)` — UNIQUE.
|
||||
- `paliad.procedural_events(code)` — UNIQUE.
|
||||
- `paliad.procedural_events(concept_id)` — for the deadline-concept join.
|
||||
- `paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state)` — primary read path for the calculator.
|
||||
- `paliad.sequencing_rules(parent_id)` — tree walk.
|
||||
- `paliad.sequencing_rules(trigger_event_id)` — event-rooted variant.
|
||||
|
||||
---
|
||||
|
||||
## §5 Migration plan (Slice B — when it ships, not in this task)
|
||||
|
||||
Phased dual-write, so the cutover is **never** a single instant where the wire format flips. m gets to roll back any one phase with a `git revert` + an `ALTER TABLE` if a phase misbehaves in prod.
|
||||
|
||||
### §5.1 Phase 1 — Additive (no down-time)
|
||||
|
||||
1. Create `procedural_events`, `sequencing_rules`, `legal_sources`.
|
||||
2. Backfill `legal_sources` from `DISTINCT legal_source` on `deadline_rules` (70 rows). Populate `pretty_de`/`pretty_en` by calling the existing `legalSourcePretty()` function in a one-shot SQL/Go shim during the migration. Verify `COUNT(DISTINCT legal_source FROM deadline_rules) = COUNT(*) FROM legal_sources`.
|
||||
3. Backfill `procedural_events` from `DISTINCT submission_code` on `deadline_rules WHERE submission_code IS NOT NULL`. Take `name`, `name_en`, `event_type → event_kind`, `primary_party`, `concept_id`, `description` from the lowest-`id` rule row for each code (tie-breaker: lowest `sequence_order`). Verify `COUNT(*) FROM procedural_events = COUNT(DISTINCT submission_code FROM deadline_rules WHERE submission_code IS NOT NULL)` (= 158).
|
||||
4. Backfill `sequencing_rules` 1:1 from `deadline_rules` (254 rows). FK `procedural_event_id` resolved by code lookup; sequencing-rule row inherits the `deadline_rules.id` (so existing `deadlines.rule_id` FKs continue to resolve via the new column for the dual-write window — see Phase 3).
|
||||
5. Add `paliad.deadlines.procedural_event_id` + `sequencing_rule_id` columns, backfill from `deadlines.rule_id` join.
|
||||
6. Add `paliad.submission_drafts.procedural_event_id`, backfill from `submission_code` join.
|
||||
|
||||
This phase ships behind a feature flag (or just behind unused code) — readers + writers stay on `deadline_rules`. No behavior change.
|
||||
|
||||
### §5.2 Phase 2 — Dual-write (no down-time)
|
||||
|
||||
7. Update `RuleEditorService` to write to both `deadline_rules` (legacy) and (`procedural_events`, `sequencing_rules`, `legal_sources`) on every Create/Update/Publish/Archive. Audit log writes one row per side.
|
||||
8. Update read paths to **read from the new tables**, falling back to `deadline_rules` if the new row is missing (defense-in-depth during backfill catch-up).
|
||||
9. Run for ≥ 1 week (m's call on length). Compare row counts and a hash digest of the union daily — if drift, surface.
|
||||
|
||||
### §5.3 Phase 3 — Cutover (no down-time, but reversible only via re-application of the dual-write)
|
||||
|
||||
10. Flip read paths to **only** the new tables (`SubmissionVarsService.loadPublishedRule`, `DeadlineRuleService.*`, `SubmissionService.list`, `ProjectionService`, `FristenrechnerCalc`, etc.).
|
||||
11. Stop writing to `deadline_rules`.
|
||||
12. `paliad.deadlines.rule_id` is kept as a no-op alias for one more week; new writes go to `procedural_event_id` + `sequencing_rule_id`.
|
||||
13. `submission_drafts.submission_code` is kept as the URL anchor; the FK `procedural_event_id` is the primary join key going forward.
|
||||
|
||||
### §5.4 Phase 4 — Drop legacy (downtime window, destructive)
|
||||
|
||||
14. `paliad.deadline_rules_pre_<slice-B-mig>` snapshot of the entire table.
|
||||
15. DROP TABLE paliad.deadline_rules (after CASCADE-safe FK rewires).
|
||||
16. DROP COLUMN paliad.deadlines.rule_id (keep `rule_code` + `custom_rule_text` as the human-readable denormalized columns — they're the safety net for orphaned deadlines per t-paliad-258).
|
||||
|
||||
m grants this destructive phase its own window (precedent: mig 091 on 2026-05-15). Until then, the legacy table sits dormant.
|
||||
|
||||
### §5.5 Migration tracker
|
||||
|
||||
- Slice B uses migration numbers 124 (Phase 1 — create tables + backfill) and onward — a 4-5 migration sequence, one per phase boundary, mirroring the Phase 2/3 slicing that shipped under t-paliad-195.
|
||||
- Each migration includes a `paliad.audit_reason = 'mig <n>: <slice-B-phase>'` set_config like mig 091 did, so the audit log captures the schema journey.
|
||||
|
||||
---
|
||||
|
||||
## §6 Service-layer impact
|
||||
|
||||
### §6.1 Slice A — prose-only changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `internal/services/submission_vars.go` | `addRuleVars` → also emit `procedural_event.code`, `procedural_event.name`, `procedural_event.name_de`, `procedural_event.name_en`, `procedural_event.legal_source`, `procedural_event.legal_source_pretty`, `procedural_event.primary_party`, `procedural_event.event_kind` (8 new keys, 1:1 with the 8 existing `rule.*` keys, same values). Rename docstrings + the package-level placeholder map comment ("`rule.*`" → "`procedural_event.*` (with legacy alias `rule.*`)"). |
|
||||
| `internal/services/deadline_rule_service.go` | Top-of-file comment + struct comment renames only. Method names stay (`DeadlineRuleService`, `GetByID`, etc.). |
|
||||
| `internal/services/rule_editor_service.go` | Same. |
|
||||
| `internal/services/projection_service.go`, `deadline_service.go`, `fristenrechner.go`, `submission_draft_service.go`, `event_trigger_service.go`, `event_deadline_service.go`, `proceeding_mapping.go`, `export_service.go` | No code changes. Comments mentioning "the rule"/"rules" stay accurate as long as the file is about sequencing — only services that surface the **identity** aspect of the rule (`submission_vars.go`) need a prose pass. |
|
||||
| `internal/handlers/submissions.go` | No SQL change. Type+comment renames: the catalog response type stays `submissionListEntry` (it's still a Schriftsatz-level list); doc comments speak of "procedural events whose kind is filing" instead of "rules of type filing". |
|
||||
| `internal/handlers/admin_rules.go` | URL path stays. JSON envelope stays. Page-render comments + log-line text shift to "procedural event". |
|
||||
| `internal/handlers/submission_drafts.go`, `deadlines.go`, `fristenrechner.go` | No service-layer change. |
|
||||
|
||||
### §6.2 Slice B — structural
|
||||
|
||||
Mostly load-bearing; not enumerated here in detail (out of scope per (R)=C). The shape:
|
||||
|
||||
- `RuleEditorService` splits into `ProceduralEventService` + `SequencingRuleService` + `LegalSourceService`. The Save / Publish / Archive flow on the editor coordinates all three.
|
||||
- `DeadlineRuleService.GetByID` becomes `SequencingRuleService.GetByID`; the `submission_code` lookup moves to `ProceduralEventService.GetByCode`.
|
||||
- `SubmissionVarsService.loadPublishedRule` becomes `loadPublishedProceduralEvent` and returns a triple (`event`, `defaultSequencingRule`, `legalSource`); the variable-bag emission consumes all three.
|
||||
- `ProjectionService` and the Fristenrechner calculator read from `sequencing_rules` (same column set, same logic — only the table name changes).
|
||||
- `SubmissionService.list` (handlers/submissions.go) filters `procedural_events.event_kind IN ('filing', 'reply')`.
|
||||
- Backfill orphans + audit triggers (mig 079 / 089) are re-pointed at `sequencing_rules` + a new `procedural_events_audit`.
|
||||
|
||||
---
|
||||
|
||||
## §7 UI / i18n impact
|
||||
|
||||
### §7.1 i18n keys (Slice A)
|
||||
|
||||
Existing keys (DE + EN) at `frontend/src/client/i18n.ts` lines ~2834-2920 and ~5800-5890 — surface area is *labels*, not *placeholders-in-Word*:
|
||||
|
||||
| Old key | New key (Slice A) | DE label | EN label |
|
||||
|---|---|---|---|
|
||||
| `admin.rules.list.title` | `admin.procedural_events.list.title` | "Verfahrensschritte verwalten — Paliad" | "Manage procedural events — Paliad" |
|
||||
| `admin.rules.list.heading` | `admin.procedural_events.list.heading` | "Verfahrensschritte verwalten" | "Manage procedural events" |
|
||||
| `admin.rules.list.subtitle` | `admin.procedural_events.list.subtitle` | "Verfahrensschritte anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived." | "Create, edit and publish procedural events. Lifecycle: draft → published → archived." |
|
||||
| `admin.rules.list.new` | `admin.procedural_events.list.new` | "+ Neuer Verfahrensschritt" | "+ New procedural event" |
|
||||
| `admin.rules.col.submission_code` | `admin.procedural_events.col.code` | "Code" (drop "/ Einreichung-Kennung" — the new heading already disambiguates) | "Code" |
|
||||
| `admin.rules.col.legal_citation` | `admin.procedural_events.col.legal_source` | "Rechtsgrundlage" | "Legal source" |
|
||||
| `admin.rules.col.name` | `admin.procedural_events.col.name` | "Bezeichnung" | "Name" |
|
||||
| `admin.rules.col.proceeding` | `admin.procedural_events.col.proceeding` | "Verfahrenstyp" | "Proceeding" |
|
||||
| `admin.rules.col.priority` | `admin.procedural_events.col.priority` | "Priorität" | "Priority" |
|
||||
| `admin.rules.col.lifecycle` | `admin.procedural_events.col.lifecycle` | "Lifecycle" | "Lifecycle" |
|
||||
| `admin.rules.col.modified` | `admin.procedural_events.col.modified` | "Zuletzt geändert" | "Last modified" |
|
||||
| `admin.rules.edit.title` | `admin.procedural_events.edit.title` | "Verfahrensschritt bearbeiten — Paliad" | "Edit procedural event — Paliad" |
|
||||
| `admin.rules.edit.heading.loading` | `admin.procedural_events.edit.heading.loading` | "Verfahrensschritt laden…" | "Loading procedural event…" |
|
||||
| `admin.rules.edit.breadcrumb` | `admin.procedural_events.edit.breadcrumb` | "← Verfahrensschritte verwalten" | "← Manage procedural events" |
|
||||
| `admin.rules.edit.field.submission_code` | `admin.procedural_events.edit.field.code` | "Code (Schriftsatz-Code / Einreichung-Kennung)" — keep the parenthetical so lawyers familiar with the old label know what they're looking at. | "Code (submission / procedural-event identifier)" |
|
||||
| `admin.rules.edit.field.rule_code` | `admin.procedural_events.edit.field.short_citation` | "Rechtsgrundlage (Kurzform)" | "Legal source (short form)" |
|
||||
| `admin.rules.edit.field.legal_source` | `admin.procedural_events.edit.field.legal_source` | "Rechtsgrundlage (Langform)" | "Legal source (long form)" |
|
||||
| `admin.rules.edit.field.name` | `admin.procedural_events.edit.field.name` | "Bezeichnung (DE)" | "Name (DE)" |
|
||||
| `admin.rules.edit.field.name_en` | `admin.procedural_events.edit.field.name_en` | "Bezeichnung (EN)" | "Name (EN)" |
|
||||
| `admin.rules.edit.field.proceeding` | `admin.procedural_events.edit.field.proceeding` | "Verfahrenstyp" | "Proceeding type" |
|
||||
| `admin.rules.edit.field.trigger` | `admin.procedural_events.edit.field.trigger` | "Trigger-Ereignis" | "Trigger event" |
|
||||
| `admin.rules.edit.field.parent` | `admin.procedural_events.edit.field.parent` | "Übergeordneter Verfahrensschritt (UUID)" | "Parent procedural event (UUID)" |
|
||||
| `admin.rules.edit.field.concept` | `admin.procedural_events.edit.field.concept` | "Konzept (UUID)" | "Concept (UUID)" |
|
||||
| `admin.rules.edit.field.sequence_order` | `admin.procedural_events.edit.field.sequence_order` | "Reihenfolge" | "Order" |
|
||||
| `admin.rules.edit.field.duration_value` | `admin.procedural_events.edit.field.duration_value` | "Dauer" | "Duration" |
|
||||
| `admin.rules.edit.field.primary_party` | `admin.procedural_events.edit.field.primary_party` | "Partei (typisch)" | "Primary party" |
|
||||
| `admin.rules.edit.field.event_type` | `admin.procedural_events.edit.field.event_kind` | "Art des Verfahrensschritts" | "Procedural-event kind" |
|
||||
| `admin.rules.edit.field.description` | `admin.procedural_events.edit.field.description` | "Beschreibung" | "Description" |
|
||||
|
||||
**Legacy keys retained as aliases** so any existing translation imports or external integrations keep working — old keys point at the same DE/EN values during a deprecation window of one full Slice B cycle.
|
||||
|
||||
### §7.2 Variable-bag placeholders (Slice A)
|
||||
|
||||
`frontend/src/client/submission-draft.ts:155-185` — the catalog of placeholders the lawyer sees in the sidebar:
|
||||
|
||||
| Old placeholder (kept as legacy alias) | New canonical placeholder | DE label | EN label |
|
||||
|---|---|---|---|
|
||||
| `{{rule.submission_code}}` | `{{procedural_event.code}}` | "Code (Verfahrensschritt)" | "Code (procedural event)" |
|
||||
| `{{rule.name}}` | `{{procedural_event.name}}` | "Bezeichnung" | "Name" |
|
||||
| `{{rule.name_de}}` | `{{procedural_event.name_de}}` | "Bezeichnung (DE)" | "Name (DE)" |
|
||||
| `{{rule.name_en}}` | `{{procedural_event.name_en}}` | "Bezeichnung (EN)" | "Name (EN)" |
|
||||
| `{{rule.legal_source}}` | `{{procedural_event.legal_source}}` | "Rechtsgrundlage (Code)" | "Legal source (code)" |
|
||||
| `{{rule.legal_source_pretty}}` | `{{procedural_event.legal_source_pretty}}` | "Rechtsgrundlage" | "Legal source" |
|
||||
| `{{rule.primary_party}}` | `{{procedural_event.primary_party}}` | "Partei (typisch)" | "Primary party" |
|
||||
| `{{rule.event_type}}` | `{{procedural_event.event_kind}}` | "Art des Verfahrensschritts" | "Procedural-event kind" |
|
||||
|
||||
The catalog renders the canonical name in the "copy-this-placeholder" button. The variable bag (`submission_vars.go`) emits both names with identical values, so any Word template the lawyer already has continues to work; new templates are encouraged to use the canonical name.
|
||||
|
||||
### §7.3 Admin rule-editor form (Slice A)
|
||||
|
||||
`frontend/src/admin-rules-edit.tsx:74-110` — i18n key rebinds + heading text update. The DOM `id` attributes (`f-submission-code`, `f-rule-code`, `f-legal-source`, …) stay — they're internal, the rename here is cosmetic, the form still POSTs the same JSON envelope (Slice A doesn't touch the API). The fieldset `legend` for the "Identität" section changes to "Verfahrensschritt-Identität" (DE) / "Procedural-event identity" (EN). The "Verfahren & Trigger" section heading stays — that section is about sequencing, and Slice A doesn't rename sequencing-level labels (those are Slice B).
|
||||
|
||||
### §7.4 Project-detail Schriftsätze tab + dashboard
|
||||
|
||||
`frontend/src/client/submissions.ts`, `submissions-index.ts`: no surface-level label change in Slice A. The Schriftsätze tab continues to show Schriftsätze (the lawyer's preferred term for *filings specifically*). The tab is a filtered view onto procedural events of kind `filing`/`reply` — that distinction surfaces only in admin contexts.
|
||||
|
||||
### §7.5 Help text + docs
|
||||
|
||||
A short addition to the in-app help: "What is a procedural event?" — one-paragraph definition explaining the umbrella term, with examples (Klage, Klageerwiderung, mündliche Verhandlung, Endurteil). Stored in `frontend/src/client/i18n.ts` under `help.procedural_events.intro`. Out of scope for the URL/router changes — added as static copy where it fits naturally.
|
||||
|
||||
---
|
||||
|
||||
## §8 Slice plan
|
||||
|
||||
### §8.1 Slice A (this design's downstream task)
|
||||
|
||||
**Scope:** prose-only rename per §3 ("renamed in Slice A" list).
|
||||
|
||||
**Mechanics:**
|
||||
|
||||
1. Add 8 new placeholder keys to the variable bag in `submission_vars.go` (1:1 with the existing 8 `rule.*` keys). Keep the legacy keys.
|
||||
2. Update `frontend/src/client/submission-draft.ts` placeholder catalog labels.
|
||||
3. Rebind admin i18n keys per §7.1 (with legacy keys retained).
|
||||
4. Update admin page titles + section headings.
|
||||
5. Update Go struct comments + service docstrings in `submission_vars.go`, `deadline_rule_service.go`, `rule_editor_service.go`, `submission_draft_service.go`, `submissions.go` handler. No code-flow change.
|
||||
6. Update `internal/handlers/submissions.go` doc comments.
|
||||
7. Add a short `docs/glossary.md` entry (or extend an existing one) for "procedural event" / "Verfahrensschritt" — single source of truth for the term.
|
||||
8. Tests: rename strings in existing test fixtures + add a regression test that the variable bag emits **both** the legacy `rule.X` and the canonical `procedural_event.X` keys with the same value. (Critical — without this test, a future commit could drop the legacy alias and silently break user templates.)
|
||||
9. Manual smoke: open the admin rule editor, confirm the new title appears. Open the submission-draft editor, confirm both `{{rule.X}}` and `{{procedural_event.X}}` placeholders are listed (with canonical first). Generate a `.docx` from a project using each placeholder name — both render identically.
|
||||
|
||||
**Risk:** very low. No DB change, no API change, fully reversible.
|
||||
|
||||
**No hours estimate per project CLAUDE.md.**
|
||||
|
||||
### §8.2 Slice B (separate mai task — designed here, hired later)
|
||||
|
||||
**Scope:** structural rework per §4 + §5.
|
||||
|
||||
**Mechanics:** Phase 1 → Phase 4 per §5.
|
||||
|
||||
**Prerequisite:** m greenlights via a new mai task with this doc + §11's open items addressed. **Not part of Slice A.**
|
||||
|
||||
**Sub-slices (suggested for Slice B's own task):**
|
||||
|
||||
- **B.0** — Re-validate this doc's premises against live DB (numbers shift over weeks).
|
||||
- **B.1** — Phase 1 additive migration + backfill (mig 124).
|
||||
- **B.2** — Phase 2 dual-write + read-fallback.
|
||||
- **B.3** — Phase 3 read cutover (no schema change).
|
||||
- **B.4** — Phase 4 destructive drop (downtime window).
|
||||
- **B.5** — Rename Go types `DeadlineRule` → `SequencingRule` + `ProceduralEvent`; rename JSON API envelope keys with a deprecation header. Independent of B.4.
|
||||
- **B.6** — Rename admin URL paths `/admin/rules` → `/admin/procedural-events` with redirects. Optional / low-priority.
|
||||
|
||||
### §8.3 Why splitting is the right call
|
||||
|
||||
The conflation is real, but the *fix* for the most-painful surface (the editor sidebar) is independent of the table restructure. Splitting lets m ship the fix this week, see whether the prose change alone resolves enough of the cognitive friction, and then decide whether the structural rework is still worth the migration cost. If after Slice A m says "this reads fine now, B isn't worth it", that's a legitimate outcome — Slice B is a *good* refactor, not an *urgent* one.
|
||||
|
||||
---
|
||||
|
||||
## §9 Risk assessment
|
||||
|
||||
### §9.1 Slice A risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Lawyer's existing Word template has `{{rule.submission_code}}` baked in; a future commit drops the legacy alias and breaks templates. | Low (Slice A keeps the alias) | High if it happens | Regression test (§8.1 step 8) asserts both keys emit. Add an audit-log line on every variable-bag call recording which keys were consumed by the merge engine — gives a 30-day window of evidence before we'd consider deprecating the legacy keys. |
|
||||
| i18n key rename misses a binding, leaving an English string visible to a DE user. | Medium | Low | The build pipeline (`bun test` / `bun build`) fails on missing i18n keys in `i18n-keys.ts`. Add the new keys to the type union; leave the old keys in the union with `@deprecated` JSDoc. |
|
||||
| Renamed admin page heading confuses returning admin users ("Where did 'Regeln verwalten' go?"). | Medium | Low | One-time changelog entry; the URL `/admin/rules` is unchanged so muscle memory still lands them on the page. Internal users only (whitelist-gated). |
|
||||
| Slice A reads as "we're done" and Slice B never ships. | Medium | Medium (the model stays wrong) | This doc files the Slice B design as a separate task entry **before** Slice A merges, so the to-do is visible. m's call whether to schedule it. |
|
||||
|
||||
### §9.2 Slice B risks (deferred; recorded for the future task)
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Backfill collapses too eagerly: 10 multi-row submission_codes today are `_archived_litigation.*` — confirm they should collapse into one procedural event with 2-5 sequencing variants, vs. each row becoming its own procedural event. | The `_archived_litigation.*` codes are archived per their prefix — collapse is safe. **Decision-flag for Slice B's own design pass.** |
|
||||
| `deadline_concepts` linkage (125 of 254 rules link to a concept) — does the concept attach to the procedural event or the sequencing rule? §4.2 says procedural event; verify this is right when re-validating premises in B.0. | Read-path audit: every consumer that joins `deadline_rules.concept_id` (rule_editor, projection, fristenrechner) operates on the rule-level today. Reconfirm none of them depend on per-jurisdiction concept-attachment. |
|
||||
| The dual-write window introduces drift if a write hits one side and fails on the other. | Atomicity via single transaction per write in `RuleEditorService`. Daily drift-check job (one SELECT pair, alert if mismatched). |
|
||||
| `paliad.deadlines.rule_id` (1 live row, but more in future) — backfilling `procedural_event_id` + `sequencing_rule_id` must not orphan the live row. | The 1 live row joins cleanly. Backfill in the same migration that adds the new columns. |
|
||||
| The submission-draft `submission_code` text key — what if two `procedural_events.code` values collide post-rename (e.g. a draft was saved against a code that we then archive)? | Slice B Phase 1 enforces `procedural_events.code UNIQUE`; the backfill verifies no collision on the existing 158 distinct values. Drafts with codes that no longer exist as published procedural events are handled by the existing `submission_drafts.submission_code` text fallback (no FK enforcement). |
|
||||
| Slice B's API-key rename (`submission_code` → `code` in JSON) breaks external integrations. | None exist today (paliad is internal-only); add a one-Slice deprecation header (`X-Deprecated-Field: submission_code`) before flipping. |
|
||||
| **Coordination risk with future fristen/calculator work.** The Fristenrechner calculator reads `deadline_rules` directly today. Slice B Phase 2's read-fallback handles this, but a parallel calculator feature in flight could land changes that need re-merging. | B.0's job: confirm no in-flight task touches `deadline_rules` table shape before scheduling. |
|
||||
|
||||
### §9.3 What rolls Slice A back
|
||||
|
||||
`git revert <slice-a-commit>` + reload. Zero data side-effects (no DB writes). 30 seconds.
|
||||
|
||||
### §9.4 What rolls Slice B back
|
||||
|
||||
Per phase — Phases 1-3 reversible via reverting code + `DROP TABLE`. Phase 4 reversible only by restoring `deadline_rules` from the `_pre_<n>` snapshot taken at the start of Phase 4. Same posture as mig 091 — m's call when to commit to this point.
|
||||
|
||||
---
|
||||
|
||||
## §10 Out of scope
|
||||
|
||||
- **Renaming `paliad.events`** (the audit feed). Distinct table, distinct concept. The umbrella-term lock (§2) deliberately uses "procedural event" not "event" to avoid colliding with it.
|
||||
- **Renaming `paliad.deadline_concepts`** to align with the procedural-event taxonomy. The concept layer is the cross-proceeding semantic bridge (einstein 2026-05-08 Q5); the relationship "procedural event has-a concept" already reads cleanly under the new term.
|
||||
- **Per-jurisdiction variations of the same procedural event** (issue body's explicit out-of-scope). The 10 multi-row codes in the corpus today stay multi-row.
|
||||
- **Multi-tenant / cross-firm sharing of procedural events** — paliad is single-tenant per deploy via `FIRM_NAME`; cross-firm is a separate design.
|
||||
- **einstein's `proceeding_event_edges` graph proposal.** That design proposed a graph of typed event-types connected by typed edges. This design's procedural-events / sequencing-rules split is **compatible** with that graph shape (the edges would attach to procedural-event-IDs rather than sequencing-rule-IDs), but the graph layer is a Slice C, not Slice B. Flagged for future continuity, not part of either slice here.
|
||||
- **Renaming Go type `DeadlineRule` to `SequencingRule` or `ProceduralEvent` in Slice A.** Slice A is prose; Slice B's B.5 sub-slice handles the type rename. Coupling them costs the reversibility property.
|
||||
- **API-envelope key renames** (`submission_code` → `code`, `event_type` → `event_kind` on the wire). Slice B only.
|
||||
- **URL path renames** (`/admin/rules` → `/admin/procedural-events`). Slice B.6, optional.
|
||||
- **Touching `paliad.trigger_events`** beyond keeping the FK path open (today `deadline_rules.trigger_event_id`; Slice B maps to `sequencing_rules.trigger_event_id`).
|
||||
- **Touching `paliad.event_categories` / Pathway-B navigation.** Independent layer.
|
||||
|
||||
---
|
||||
|
||||
## §11 Open questions for m (escalated via `mai instruct head` per project CLAUDE.md)
|
||||
|
||||
Per project CLAUDE.md "Head answers questions — NO AskUserQuestion" rule, these are surfaced to head, not picked-as-chip with the user.
|
||||
|
||||
| ID | Question | Inventor recommendation | Material to head? |
|
||||
|---|---|---|---|
|
||||
| **Q1** | Scope: cosmetic-only (A) · full restructure (B) · cosmetic now + B as planned follow-up (C). | **(R) = C** | Yes — material. Defines whether Slice B is hired today or filed as a future task. |
|
||||
| **Q2** | Umbrella term: "procedural event" (DE: Verfahrensschritt) · "submission" (filings only) · "Verfahrensereignis" · other. | **(R) = procedural event / Verfahrensschritt** | Yes — material. The term ripples through every label in §7. Inventor's pick is the canonical choice; head can override with a single message. |
|
||||
| **Q3** | Slice B migration shape: confirmed (§4 + §5) or rescope. | **(R) = §4 + §5 as written, decision deferred until Slice B is hired** | No — informational. Locked when Slice B's own design pass runs. |
|
||||
| **Q4** | Effect on Schriftsätze surface: filter `procedural_events.event_kind IN ('filing', 'reply')` is acceptable replacement for today's `event_type='filing'`. | **(R) = yes, semantically equivalent under Slice B; no behavior change to lawyer.** | No — informational. |
|
||||
| **Q5** | Are the 10 archived multi-row submission_codes (`_archived_litigation.*`) safe to collapse into single procedural events with multiple sequencing variants in Slice B? | **(R) = yes, prefix indicates archival; collapse-safe.** | No — informational, defers to Slice B. |
|
||||
| **Q6** | `concept_id` attaches to procedural event, not sequencing rule. Confirmable? | **(R) = yes, per §4.2 (one concept per identity, not per jurisdiction).** | No — informational, defers to Slice B. |
|
||||
| **Q7** | Keep the legacy `{{rule.X}}` placeholder aliases **forever**, or set a deprecation horizon (e.g. 1 year)? | **(R) = forever, with `@deprecated` annotation in the catalog. Removing them risks breaking lawyer-authored templates that paliad doesn't see.** | Yes — material to Slice A's contract (test in §8.1 step 8 asserts both keys emit). |
|
||||
| **Q8** | Document side: update m/paliad#93 issue body to fix the `deadlines.deadline_rule_id` → `deadlines.rule_id` typo (§1 last paragraph). | **(R) = yes, head's call when to edit.** | No — informational, doc hygiene. |
|
||||
| **Q9** | After Slice A ships, do we file Slice B as a new mai task **now** (so it's visible), or wait for m to ask? | **(R) = file now, status:planning, no owner. Visibility >> deferred surprise.** | Yes — material to "does the model stay wrong forever". |
|
||||
|
||||
Q1, Q2, Q7, Q9 are the four head needs to answer before the coder shift. Q3-Q6, Q8 defer cleanly.
|
||||
|
||||
---
|
||||
|
||||
## §12 Appendix — verbatim m quote
|
||||
|
||||
From m's report 2026-05-25 15:02 (paliad#93 body):
|
||||
|
||||
> This shows how our 'rule' table system may need a revision?! It feels like we are rule based not submission based. But here we have a specific submission that is connected to a rule (as in: legal norm). And of course also connected to other 'procedural events' (which is a good term for it all) by rules how they are sequenced. But it makes it sound weird in the fields...
|
||||
|
||||
The design above takes m's three-way split — *the procedural event* / *the legal norm* / *the rule by which they are sequenced* — at face value and turns it into a column-level map (§4.2), a slice plan (§8), and a deprecation contract (§9.1).
|
||||
|
||||
---
|
||||
|
||||
*End of design.*
|
||||
580
docs/design-proceeding-types-taxonomy-2026-05-26.md
Normal file
580
docs/design-proceeding-types-taxonomy-2026-05-26.md
Normal file
@@ -0,0 +1,580 @@
|
||||
# Design — `paliad.proceeding_types` taxonomy cleanup: primary proceedings vs phases vs side-actions vs meta
|
||||
|
||||
**Task:** t-paliad-324
|
||||
**Gitea:** m/paliad#147
|
||||
**Inventor:** atlas (shift-1)
|
||||
**Date:** 2026-05-26
|
||||
**Status:** Draft — coder gate held until m ratifies the 10 design questions in §9
|
||||
**Branch:** `mai/atlas/inventor-proceeding`
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Verified against live youpc Postgres (port 11833, `paliad` schema) on 2026-05-26 22:05. Findings supersede the audit grouping in m/paliad#147 wherever they diverge — the issue body was correct on shape but conservative on counts.
|
||||
|
||||
### 0.1 The 46-row table, fully classified by usage
|
||||
|
||||
`paliad.proceeding_types` has 49 rows total; 46 active, 3 inactive (`upc.apl.merits/cost/order` — superseded by `upc.apl.unified`, id 160) plus 1 archive bucket (`_archived_litigation`, id 32). Cross-references against the four downstream consumers:
|
||||
|
||||
| Consumer | Column | Active rows that point at the 46 active types |
|
||||
|---|---|---|
|
||||
| `paliad.sequencing_rules.proceeding_type_id` | rule's anchor proceeding | **18 distinct rows used** — the primaries with corpus. 28 rows have 0 rules. |
|
||||
| `paliad.sequencing_rules.spawn_proceeding_type_id` | cross-proceeding spawn target | **1 distinct row used** — `upc.apl.merits` (id=11, **inactive!**). 0 active types are spawn targets. |
|
||||
| `paliad.projects.proceeding_type_id` | project's primary type | **6 distinct rows used** (across 18 projects). All 6 are in the 18 primaries. |
|
||||
| `paliad.event_category_concepts.proceeding_type_code` | concept's owning proceeding | **18 distinct codes used.** 3 of those codes (`upc.apl.merits`, `upc.apl.order`, `upc.apl.cost`) point at **inactive** rows — pre-existing data drift from the `upc.apl.unified` merger (flagged §8, out of scope here). |
|
||||
|
||||
The audit answer in one sentence: **of the 46 active rows, only 18 have any downstream consumer pointing at them today** (the 18 primaries with corpus). The remaining 28 rows are decorative — they exist in the table but nothing references them.
|
||||
|
||||
This makes reparenting **trivially safe**: no FK invariant breaks, no SQL update touches existing data, no migration risk.
|
||||
|
||||
### 0.2 The 18 primaries with corpus (rules + concepts)
|
||||
|
||||
Ordered by `paliad.sequencing_rules` count (descending), with `event_category_concepts` count alongside:
|
||||
|
||||
| id | code | jurisdiction | rules | concepts | projects |
|
||||
|---:|---|---|---:|---:|---:|
|
||||
| 8 | `upc.inf.cfi` | UPC | 25 | 14 | 1 |
|
||||
| 9 | `upc.rev.cfi` | UPC | 17 | 10 | 0 |
|
||||
| 160 | `upc.apl.unified` | UPC | 16 | 0 *(see drift note)* | 0 |
|
||||
| 12 | `de.inf.lg` | DE | 11 | 4 | 1 |
|
||||
| 13 | `de.null.bpatg` | DE | 10 | 4 | 1 |
|
||||
| 14 | `epa.opp.opd` | EPA | 8 | 7 | 1 |
|
||||
| 15 | `epa.opp.boa` | EPA | 8 | 12 | 0 |
|
||||
| 16 | `epa.grant.exa` | EPA | 8 | 0 | 0 |
|
||||
| 17 | `upc.dmgs.cfi` | UPC | 8 | 1 | 0 |
|
||||
| 26 | `de.inf.bgh` | DE | 8 | 17 | 0 |
|
||||
| 25 | `de.inf.olg` | DE | 7 | 8 | 0 |
|
||||
| 10 | `upc.pi.cfi` | UPC | 7 | 3 | 0 |
|
||||
| 27 | `de.null.bgh` | DE | 6 | 10 | 0 |
|
||||
| 29 | `dpma.appeal.bpatg` | DPMA | 5 | 6 | 0 |
|
||||
| 30 | `dpma.appeal.bgh` | DPMA | 4 | 8 | 0 |
|
||||
| 28 | `dpma.opp.dpma` | DPMA | 4 | 3 | 1 |
|
||||
| 18 | `upc.disc.cfi` | UPC | 4 | 1 | 0 |
|
||||
| 35 | `upc.ccr.cfi` | UPC | 1 | 0 | 1 |
|
||||
|
||||
These 18 are unambiguously **primary proceedings** in the m/paliad#147 sense — self-contained matters, own filing, own deadline cascade, own ablauf. They survive every model.
|
||||
|
||||
### 0.3 The 4 unloaded primaries (Group A continued)
|
||||
|
||||
Four more active rows are conceptually primaries but carry **zero rules and zero concepts today** — seeded for catalog completeness, waiting for corpus:
|
||||
|
||||
| id | code | jurisdiction | what it is |
|
||||
|---:|---|---|---|
|
||||
| 171 | `upc.dni.cfi` | UPC | Negative Feststellungsklage — standalone declaratory action |
|
||||
| 172 | `upc.epo.review` | UPC | Überprüfung von EPA-Entscheidungen — standalone review action |
|
||||
| 179 | `upc.bsv.cfi` | UPC | Beweissicherung / saisie — standalone evidence-preservation order |
|
||||
| 188 | `upc.pl.cfi` | UPC | Schutzschrift — pre-litigation defensive filing |
|
||||
|
||||
These are **primary** by character (each has its own RoP-defined filing pathway and its own deadline tree once rules get seeded) but **unloaded** today. Decision: keep them as `kind='proceeding'` so Mode B R3 surfaces them for future rule attachment and `pkg/litigationplanner` accepts them as valid catalog codes.
|
||||
|
||||
§9 Q3.b discusses `upc.pl.cfi` (it's the only borderline — Schutzschrift is technically a pre-action filing, not a proceeding at the time of filing). m's call.
|
||||
|
||||
### 0.4 The 28 non-primary rows
|
||||
|
||||
The 28 active rows that have **zero rules + zero concepts + zero projects pointing at them** group cleanly into three categories:
|
||||
|
||||
#### Group B — Phases of a primary CFI proceeding (5 rows)
|
||||
|
||||
These describe stages *within* an existing CFI proceeding, not standalone matters. A `upc.inf.cfi` action passes through interim → oral → decision phases; the phase isn't a separately-elected proceeding type.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 173 | `upc.cfi.interim` | CFI - Zwischenverfahren |
|
||||
| 174 | `upc.cfi.oral` | CFI - Mündliche Verhandlung |
|
||||
| 175 | `upc.cfi.decision` | CFI - Endentscheidung |
|
||||
| 176 | `upc.costs.cfi` | Separate Kostenentscheidung *(post-decision sub-phase)* |
|
||||
| 185 | `upc.default.cfi` | Versäumnisentscheidung *(alt. decision outcome)* |
|
||||
|
||||
The "phase" concept already has a natural home in the data model: `paliad.procedural_events.event_kind` (filing/hearing/decision/order). What `upc.cfi.interim` actually represents is "all events with kind=filing under upc.inf.cfi/upc.rev.cfi/upc.pi.cfi/etc."; `upc.cfi.oral` is "all events with kind=hearing"; `upc.cfi.decision` is "all events with kind=decision". The proceeding-type row buys nothing the event_kind already carries.
|
||||
|
||||
#### Group C — Side-actions inside a proceeding (10 rows)
|
||||
|
||||
Applications and court orders that arise *inside* a primary proceeding. They could each become a `condition_expr`-gated rule on the parent proceeding when corpus arrives; they don't need their own proceeding row.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 178 | `upc.evidence.cfi` | Beweisanordnungen (allgemein) |
|
||||
| 182 | `upc.experiments.cfi` | Gerichtlich angeordnete Versuche |
|
||||
| 177 | `upc.security.cfi` | Sicherheitsleistung |
|
||||
| 184 | `upc.intervention.rop` | Streitbeitritt |
|
||||
| 165 | `upc.parties.change` | Parteiwechsel / Patentübergang |
|
||||
| 170 | `upc.optout.cfi` | Antrag auf Opt-out |
|
||||
| 180 | `upc.inspection.cfi` | Besichtigungsantrag |
|
||||
| 181 | `upc.freezing.cfi` | Anordnung zur Vermögenssperre |
|
||||
| 187 | `upc.withdrawal.rop` | Klagerücknahme |
|
||||
| 183 | `upc.rehearing.coa` | Wiederaufnahmeantrag |
|
||||
|
||||
A subtle distinction: `upc.bsv.cfi` (Beweissicherung) IS a standalone primary (its own RoP filing) whereas `upc.evidence.cfi` (Beweisanordnungen allgemein) is a side-action class (orders the court makes inside any proceeding). The two are not duplicates; the categorisation is structural, not nominal.
|
||||
|
||||
#### Group D — Cross-cutting administrative / meta (8 rows)
|
||||
|
||||
These describe rules-of-procedure mechanics, not matters a lawyer takes on. None of them is a "Verfahren" in any user-facing sense.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 162 | `upc.case.mgmt` | Verfahrensverwaltung |
|
||||
| 161 | `upc.general.rop` | Allgemeine Bestimmungen |
|
||||
| 163 | `upc.service.rop` | Zustellung von Schriftsätzen |
|
||||
| 168 | `upc.language.rop` | Verfahrenssprache |
|
||||
| 164 | `upc.representation.rop` | Vertretung / Anwaltsprivileg |
|
||||
| 166 | `upc.fees.court` | Gerichtsgebühren |
|
||||
| 167 | `upc.legalaid.cfi` | Prozesskostenhilfe |
|
||||
| 186 | `upc.special.cfi` | Besondere Verfahrenslagen |
|
||||
| 169 | `upc.reestablishment.rop` | Wiedereinsetzung in den vorigen Stand *(cross-cutting; applies to every proceeding)* |
|
||||
|
||||
`upc.reestablishment.rop` lands in Group D because **every** proceeding has a Wiedereinsetzung path — it isn't a kind-of-proceeding, it's a cross-cutting remedy. Today's rules already model it correctly (it's a `condition_expr`-gated rule on each primary, not a separately-elected proceeding type).
|
||||
|
||||
### 0.5 Counts reconciled
|
||||
|
||||
| Group | Count | Total of 46 |
|
||||
|---|---:|---:|
|
||||
| A.1 Primary with corpus (18 rows) | 18 | |
|
||||
| A.2 Primary, unloaded (4 rows) | 4 | |
|
||||
| B Phases (5 rows) | 5 | |
|
||||
| C Side-actions (10 rows) | 10 | |
|
||||
| D Meta / cross-cutting (9 rows) | 9 | |
|
||||
| **Total** | | **46 ✓** |
|
||||
|
||||
m/paliad#147's audit listed 8 Group-D rows; live data shows 9 once `upc.reestablishment.rop` is moved into the meta bucket (it appeared as ambiguous "cross-cutting admin / meta" — confirming this design's read).
|
||||
|
||||
---
|
||||
|
||||
## 1. Categorization — ratified
|
||||
|
||||
The taxonomy proposal: a row in `paliad.proceeding_types` has exactly one of four **structural kinds**.
|
||||
|
||||
| `kind` | What it is | Visible in Mode B R3 wizard? | In `pkg/litigationplanner` catalog? | Eligible for `projects.proceeding_type_id`? |
|
||||
|---|---|---|---|---|
|
||||
| `proceeding` | A self-contained matter with its own filing pathway and its own deadline tree | **Yes** | **Yes** (filtered by `kind='proceeding' AND is_active=true`) | **Yes** |
|
||||
| `phase` | A stage *within* a primary proceeding | No | No | No |
|
||||
| `side_action` | An application/order that arises inside a primary proceeding | No | No | No |
|
||||
| `meta` | RoP mechanics, cross-cutting rules, court administration | No | No | No |
|
||||
|
||||
This is **Model 1 from m/paliad#147** (kind discriminator on `proceeding_types`). §2 explains why it beats Models 2-4 for the actual data.
|
||||
|
||||
The 46 active rows map to the 4 kinds as follows:
|
||||
|
||||
- **`proceeding` (22 rows):** all 18 primaries-with-corpus + the 4 unloaded primaries from §0.3. Specifically the union of §0.2 + §0.3.
|
||||
- **`phase` (5 rows):** the §0.4 Group B list.
|
||||
- **`side_action` (10 rows):** the §0.4 Group C list.
|
||||
- **`meta` (9 rows):** the §0.4 Group D list (incl. `upc.reestablishment.rop`).
|
||||
|
||||
### 1.1 Edge calls
|
||||
|
||||
- **`upc.ccr.cfi` (id 35)** — stays `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1 (the determinator surfaces it, the mapping returns inf.cfi's id with `with_ccr=true`). Rationale: the routing layer is already built and m ratified it 2026-05-18. This design does not re-open that decision. §9 Q7 lets m revisit.
|
||||
- **`upc.pl.cfi` (Schutzschrift, id 188)** — borderline. Schutzschrift is filed *before* a proceeding exists; it's a defensive pre-litigation filing. Recommendation: keep as `kind='proceeding'` (it has its own RoP path + its own deadlines once seeded). The alternative — calling it `side_action` of a not-yet-existing inf.cfi — is semantically backwards. §9 Q3.b lets m revisit.
|
||||
- **`upc.bsv.cfi` (saisie, id 179)** vs **`upc.evidence.cfi` (id 178)** — bsv stays `kind='proceeding'` (own RoP filing under R.192-198), evidence stays `kind='side_action'` (the orders a court makes inside any proceeding under R.190). The codes are not duplicates.
|
||||
|
||||
### 1.2 What the categorisation buys
|
||||
|
||||
- **Mode B R3 (Fristenrechner overhaul, t-paliad-322)** queries `proceeding_types WHERE is_active AND kind='proceeding'` and gets a clean 22-row pick list — no phase/side-action/meta noise.
|
||||
- **`projects.proceeding_type_id` integrity** is enforceable: an FK + CHECK (or a triggered constraint, see §3.3) blocks setting a project's type to anything except `kind='proceeding'`.
|
||||
- **`pkg/litigationplanner` snapshot generator** filters identically; youpc.org's catalog stays UPC-primary-only with no leakage of phase/admin rows.
|
||||
- **Determinator + dropdowns** get a forward-compatible filter; future feature work (e.g. "show me all side-actions available in this proceeding") becomes a different query against the same table.
|
||||
- **Forward-compatibility for new rows** — when corpus for a side-action arrives (e.g. `upc.evidence.cfi` gains 4 sequencing_rules with `condition_expr='evidence_order_issued'`), the rules anchor on the *parent* primary, not on the side-action row. The kind classification stays correct; the side-action row remains a taxonomic label.
|
||||
|
||||
---
|
||||
|
||||
## 2. Model choice — Model 1 (kind discriminator)
|
||||
|
||||
### 2.1 The four candidate models, scored
|
||||
|
||||
| Model | Schema churn | Models phase parentage? | Mode B R3 filter | Migration risk | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **1. `kind` discriminator on `proceeding_types`** | One column + CHECK constraint | No, but doesn't need to | `WHERE kind='proceeding'` | Trivial — UPDATE only | **Recommended** |
|
||||
| 2. Self-referencing `parent_id` | One column + FK + CHECK | Yes, but parentage is wrong shape (phases are phase-of-EVERY-CFI, not of one) | `WHERE parent_id IS NULL` | Trivial | Over-modelled |
|
||||
| 3. Separate tables | Three new tables + view/JOINs | Yes, fully | Just query `proceeding_types` | Migration churn + every consumer query learns a new shape | Overkill for 28 unused rows |
|
||||
| 4. Move phases into `procedural_events` | One mass row-move + DELETE | n/a (phases vanish from `proceeding_types`) | Trivial | Highest — would touch event_kind taxonomy and Fristenrechner result-view structure | Wrong shape (phases ≠ events) |
|
||||
|
||||
### 2.2 Why Model 1 wins
|
||||
|
||||
The fundamental observation: **the 28 non-primary rows have zero downstream pressure**. No rule, no project, no concept, no spawn FK references them. They exist in the table as taxonomic placeholders — names someone wrote down so future corpus could attach. We don't need to physically restructure the table; we just need to label what's what so consumers can filter correctly.
|
||||
|
||||
Model 1 gives us exactly that with one column. The other models pay schema/migration cost to model a parent-child relationship that **no consumer queries**. Mode B R3 doesn't ask "what are the phases of upc.inf.cfi?" — it asks "what are the proceedings I can pick?". The Fristenrechner result view doesn't ask the proceeding-types table about phases — phases live inside `procedural_events.event_kind` and the priority-bucket sub-sections in the §4.2 of the Fristenrechner overhaul doc.
|
||||
|
||||
Model 2's `parent_id` is wrong in shape: `upc.cfi.interim` doesn't have ONE parent (`upc.inf.cfi`), it has SEVEN parents (every CFI proceeding). Modelling that as a self-reference would force either (a) duplicating the phase rows per primary, or (b) using NULL parent_id for "applies to all". Both options are uglier than just dropping parent_id and trusting `kind='phase'`.
|
||||
|
||||
Model 3's separate tables would create rich relations that no consumer reads. Premature relational normalisation.
|
||||
|
||||
Model 4 would force phases into `procedural_events`, but phases aren't events. A phase is a *bucket of events*. The bucket is already implicit in the `event_kind` column (filing → interim, hearing → oral, decision → decision). If anything, Model 4 is *backwards* — phases should disappear into `event_kind`, not become event rows. The way to "delete" the phase rows from proceeding_types is just to deactivate them (or mark them `kind='phase'`); we don't need to re-locate them into another table to claim that conceptual move.
|
||||
|
||||
### 2.3 What we don't do — physical deletion
|
||||
|
||||
The 28 non-primary rows are NOT dropped from the table. They:
|
||||
|
||||
- Get tagged with the right `kind` value.
|
||||
- Optionally get `is_active=false` flipped (m's call, §9 Q9).
|
||||
- Stay in the table so consumers that historically referenced them by id (admin tools, audit logs, future schema-rescue scripts) keep working.
|
||||
|
||||
`DROP` is a one-way door we don't need to walk through. The CHECK constraint + kind tagging gives us the same logical cleanliness with none of the irreversibility risk.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema sketch + migration plan
|
||||
|
||||
### 3.1 DDL — the new column
|
||||
|
||||
```sql
|
||||
-- Migration NNN_proceeding_types_kind.up.sql
|
||||
-- (NNN = whatever MAX(version) + 1 is at write time; see project-status.md
|
||||
-- for the live numbering. As of 2026-05-26 the head is mig 152 per the
|
||||
-- recent dedupe of identical sequencing_rule clones.)
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
|
||||
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.kind IS
|
||||
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
|
||||
'proceeding = self-contained matter (own filing + deadline tree); '
|
||||
'phase = stage inside a primary CFI proceeding; '
|
||||
'side_action = application/order inside a proceeding; '
|
||||
'meta = RoP mechanics, court admin, cross-cutting remedies.';
|
||||
|
||||
CREATE INDEX proceeding_types_kind_active_idx
|
||||
ON paliad.proceeding_types(kind, is_active)
|
||||
WHERE is_active = true;
|
||||
```
|
||||
|
||||
The DEFAULT keeps existing inserts (admin tooling, snapshot tests) safe: any new row defaults to `proceeding`. The CHECK enforces the vocabulary at write time.
|
||||
|
||||
### 3.2 Data move — UPDATE statements, no INSERT/DELETE
|
||||
|
||||
```sql
|
||||
-- Phases (per m's Q2 carve-out: upc.costs.cfi (176) is NOT a phase, it stays primary)
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'phase'
|
||||
WHERE id IN (173, 174, 175, 185); -- §0.4 Group B minus 176
|
||||
|
||||
-- Side-actions
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'side_action'
|
||||
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183); -- §0.4 Group C
|
||||
|
||||
-- Meta / cross-cutting
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'meta'
|
||||
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169); -- §0.4 Group D
|
||||
|
||||
-- Primaries (incl. m's Q2 carve-out for upc.costs.cfi) stay on the DEFAULT
|
||||
-- 'proceeding' value — no UPDATE needed.
|
||||
|
||||
-- Per m's Q9: deactivate the non-primary rows so the admin list surfaces only
|
||||
-- primaries. The kind column carries the semantic info; is_active controls UI
|
||||
-- visibility. Reversible — flip is_active back on if a row gains corpus.
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE kind IN ('phase', 'side_action', 'meta');
|
||||
```
|
||||
|
||||
Per m's Q9, the `is_active=false` flip is mandatory in this mig. After it: 23 active rows (all `kind='proceeding'`), 23 inactive rows (the phase/side_action/meta set), in addition to the pre-existing inactive appeal-triplet + archived bucket. The `kind` column tells consumers what each row IS; `is_active` tells consumers whether to show it.
|
||||
|
||||
### 3.3 Optional integrity constraints
|
||||
|
||||
If m wants stronger guarantees that `projects.proceeding_type_id` can only point at primaries, add a deferrable FK validator. Cleanest pattern in Postgres:
|
||||
|
||||
```sql
|
||||
-- Option A: trigger-based check (works for any kind set, deferred-friendly).
|
||||
CREATE OR REPLACE FUNCTION paliad.assert_project_type_is_proceeding()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.proceeding_type_id IS NOT NULL THEN
|
||||
PERFORM 1 FROM paliad.proceeding_types
|
||||
WHERE id = NEW.proceeding_type_id AND kind = 'proceeding';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'projects.proceeding_type_id must reference a kind=proceeding row, got id=%', NEW.proceeding_type_id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER projects_proceeding_type_kind_check
|
||||
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.assert_project_type_is_proceeding();
|
||||
```
|
||||
|
||||
Per m's Q8: **trigger on `projects` only**, no symmetric enforcement on `sequencing_rules`. Projects are written via the public app (the surface most exposed to operator error); rules are edited via the admin `/admin/procedural-events` surface which already validates against active+published lifecycle. The single trigger is enough.
|
||||
|
||||
### 3.4 Migration sequencing — single self-contained mig
|
||||
|
||||
One migration file:
|
||||
|
||||
```
|
||||
internal/db/migrations/153_proceeding_types_kind.up.sql
|
||||
internal/db/migrations/153_proceeding_types_kind.down.sql
|
||||
```
|
||||
|
||||
Up does ALTER + UPDATE + (optional) trigger creation. Down does DROP COLUMN (cascading the trigger if present). No data loss on either direction — the kind column is purely additive.
|
||||
|
||||
Mig number depends on what knuth lands first; the coder reads `MAX(version)` at write time per the project's mig conventions.
|
||||
|
||||
---
|
||||
|
||||
## 4. FK reparenting tables
|
||||
|
||||
There is no reparenting to do. Below for completeness:
|
||||
|
||||
| Source table.column | Pointing at non-primary rows? | Action |
|
||||
|---|---|---|
|
||||
| `sequencing_rules.proceeding_type_id` | **0 active rules** (verified §0.1) | None |
|
||||
| `sequencing_rules.spawn_proceeding_type_id` | **0 active rules** point at non-primaries; 4 active rules point at id=11 (inactive `upc.apl.merits`) | Pre-existing drift, out of scope (§8) |
|
||||
| `projects.proceeding_type_id` | **0 projects** (all 6 distinct values are primaries) | None |
|
||||
| `event_category_concepts.proceeding_type_code` | **0 concepts** point at non-primary codes; 30 concepts point at `upc.apl.merits/order/cost` codes (which are inactive but conceptually primaries) | Pre-existing drift, out of scope (§8) |
|
||||
|
||||
The "FK reparent" section of the acceptance criteria in m/paliad#147 is a no-op for this design: the 28 rows being re-classified have **no incoming references** to reparent. The migration is pure relabelling.
|
||||
|
||||
---
|
||||
|
||||
## 5. Worked example — `upc.cfi.interim` after the mig
|
||||
|
||||
### 5.1 Today (broken)
|
||||
|
||||
Someone created the row `upc.cfi.interim` (id 173, name "CFI - Zwischenverfahren") in `paliad.proceeding_types` with `category='fristenrechner'`. The intent was probably "we'll attach interim-phase rules here later". Result:
|
||||
|
||||
- The row appears in the Mode B R3 wizard chip strip (if R3 queries `WHERE is_active=true AND jurisdiction='UPC'`) — confusing to the user, because "Zwischenverfahren" is not a proceeding they pick; it's a stage their proceeding passes through.
|
||||
- The row could be set as `projects.proceeding_type_id` (no FK constraint forbids it today) — corrupting the SmartTimeline's lane logic, which assumes the project's type is a primary.
|
||||
- The row appears in admin /admin/proceeding-types lists, polluting the primary-proceedings overview.
|
||||
|
||||
### 5.2 After mig 153
|
||||
|
||||
The migration runs:
|
||||
|
||||
```sql
|
||||
UPDATE paliad.proceeding_types SET kind = 'phase' WHERE id = 173;
|
||||
-- Optionally: UPDATE paliad.proceeding_types SET is_active = false WHERE id = 173;
|
||||
```
|
||||
|
||||
Now:
|
||||
|
||||
- Mode B R3 query becomes `WHERE is_active=true AND jurisdiction = $1 AND kind='proceeding'`. `upc.cfi.interim` is filtered out — it is not a "Verfahren" the user can pick.
|
||||
- A future admin who tries to set a project's `proceeding_type_id = 173` either fails the optional trigger from §3.3 (with a clear error) or gets a code-level rejection from `ProjectService.SetProceedingType` (which the coder will harden to filter by `kind='proceeding'`).
|
||||
- The `pkg/litigationplanner` snapshot generator filter becomes `WHERE is_active=true AND category='fristenrechner' AND kind='proceeding' AND jurisdiction IN ('UPC')`. The row never makes it into the youpc.org catalog.
|
||||
|
||||
The row itself stays in the database. Its id is stable. Future work that wants to *use* the phase row as a taxonomic label (e.g. "show me which event_kinds map to which UPC phases") gets a clean shape: query `WHERE kind='phase' AND code LIKE 'upc.cfi.%'`.
|
||||
|
||||
### 5.3 Where interim-phase deadlines actually live
|
||||
|
||||
The user-facing concept "interim phase" is already modelled correctly, just elsewhere:
|
||||
|
||||
- A `procedural_events` row like `upc.inf.cfi.soc` (Statement of Claim) has `event_kind='filing'`. The Fristenrechner overhaul (t-paliad-322 §4.2) groups follow-ups by priority + presents them under the trigger card. There is no UI element that needs a "Zwischenverfahren" proceeding-type label to operate.
|
||||
- A future "show me the full ablauf of UPC inf, broken down by phase" feature can derive phases from `procedural_events.event_kind` ordering + the rule sequence_order. The `proceeding_types` table doesn't need to carry the phase labels.
|
||||
|
||||
---
|
||||
|
||||
## 6. Consumer impact
|
||||
|
||||
### 6.1 `projects.proceeding_type_id`
|
||||
|
||||
| Concern | Before | After mig 153 |
|
||||
|---|---|---|
|
||||
| Valid values | Any active proceeding_types row | Any `kind='proceeding'` active row (22 rows) |
|
||||
| Enforcement | None at DB level | Optional trigger (§3.3 / §9 Q8) |
|
||||
| Code-level filter in ProjectService | No filter on kind | Filter to `kind='proceeding'` when listing pickable types |
|
||||
| Existing data | 6 distinct values (all in 22) | No change — all 6 are kind='proceeding' |
|
||||
| SmartTimeline lane logic | Assumes primary-proceeding shape | Assumption now FK-enforceable |
|
||||
|
||||
**No data migration on existing projects.** The 6 currently-used proceeding types are all in the primary set.
|
||||
|
||||
### 6.2 `sequencing_rules.proceeding_type_id` + `spawn_proceeding_type_id`
|
||||
|
||||
| Concern | Before | After mig 153 |
|
||||
|---|---|---|
|
||||
| `proceeding_type_id` valid values | Any active row | Any active row (no enforcement change; admin curation suffices) |
|
||||
| `spawn_proceeding_type_id` valid values | Any active row | Same — spawns conceptually must point at a primary, but enforcement stays in admin tooling |
|
||||
| Existing data | 157 rules anchored on 18 primaries | No change — all 157 already on `kind='proceeding'` rows |
|
||||
| `id=11 spawn pressure` (`upc.apl.merits`, inactive) | 4 active spawn rules point here | Pre-existing drift, out of scope (§8) |
|
||||
|
||||
No `sequencing_rules` table changes accompany this mig. The post-mig invariant **"every active rule's `proceeding_type_id` is a `kind='proceeding'` row"** holds without any UPDATE.
|
||||
|
||||
### 6.3 Fristenrechner Mode B R3 (t-paliad-322, knuth's S3+)
|
||||
|
||||
§3.2 R3 of the Fristenrechner overhaul says:
|
||||
|
||||
> Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind.
|
||||
|
||||
After mig 153, the R3 query gains one more AND-clause:
|
||||
|
||||
```sql
|
||||
SELECT pt.id, pt.code, pt.name, pt.name_en, pt.sort_order
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.is_active = true
|
||||
AND pt.kind = 'proceeding' -- NEW
|
||||
AND pt.jurisdiction = $1 -- from R2
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE sr.proceeding_type_id = pt.id
|
||||
AND pe.event_kind = $2 -- from R1
|
||||
AND sr.is_active = true
|
||||
)
|
||||
ORDER BY pt.sort_order, pt.code;
|
||||
```
|
||||
|
||||
The `kind='proceeding'` filter is the only line that changes. Knuth's S3 implementation reads from this query; the chip pool shrinks from "all 35 active UPC types" to "the 14 primary UPC types that have rules" (still narrowed further by R1's event_kind via the EXISTS subquery).
|
||||
|
||||
No coder churn beyond adding the AND-clause. The mig 153 lands either alongside knuth's S3 work or independently (§7 sequencing decision).
|
||||
|
||||
### 6.4 Litigation Planner suite (t-paliad-292)
|
||||
|
||||
The package's catalog snapshot generator (`pkg/litigationplanner/scripts/snapshot/main.go`) currently filters:
|
||||
|
||||
```go
|
||||
// scripts/snapshot/main.go
|
||||
const proceedingTypesQuery = `
|
||||
SELECT id, code, name, name_en, jurisdiction, default_color, sort_order, display_order,
|
||||
trigger_event_label_de, trigger_event_label_en
|
||||
FROM paliad.proceeding_types
|
||||
WHERE is_active = true
|
||||
AND category = 'fristenrechner'
|
||||
AND jurisdiction = $1
|
||||
`
|
||||
```
|
||||
|
||||
After mig 153, this query gains the same `AND kind = 'proceeding'` line. The UPC snapshot shrinks from "potentially 35 rows" to a clean primary-only set. Today's snapshot probably already includes the phase/side-action/meta rows (since `is_active=true` is true for all of them) — depending on whether a snapshot has been regenerated since the 161-188 rows landed, the embedded JSON may be carrying decorative rows that the youpc.org catalog never resolves to rules. Mig 153 + a snapshot regen cleans this up.
|
||||
|
||||
The package's `Catalog.Proceeding(ctx, code, hint)` interface stays unchanged. A youpc-side call asking for `code='upc.cfi.interim'` previously returned the row + zero rules (technically valid but useless); after mig 153 the snapshot doesn't include it and the call returns `ErrUnknownProceedingType`. That's the correct shape — youpc users never had a reason to ask for a phase row.
|
||||
|
||||
The scenarios design (`paliad.scenarios.spec.proceedings[].code`) gains an integrity check at write time: the validator already asserts every code resolves to an active proceeding; now it additionally asserts `kind='proceeding'`. A user trying to compose a scenario with `code='upc.cfi.interim'` gets a clear error. (The validator is paliad-side, not library-side — see Litigation Planner doc §5 "Validatable at write time".)
|
||||
|
||||
### 6.5 Admin /admin/procedural-events list (recently shipped, t-paliad-321)
|
||||
|
||||
The proceeding-type column in the admin list (m/paliad#144 follow-up, just landed) renders one of the 46 active codes per row. Post-mig 153, the admin filter dropdown can:
|
||||
|
||||
- Default to showing only `kind='proceeding'` rows (clean primary view).
|
||||
- Offer a "show all kinds" toggle for admins triaging the non-primary rows.
|
||||
|
||||
This is presentation-only — the underlying admin queries don't need to change immediately. The kind column is a forward-compat hook.
|
||||
|
||||
### 6.6 Knowledge-platform pages (Gerichtsverzeichnis, Patentglossar)
|
||||
|
||||
Untouched. None of those pages query `proceeding_types` directly.
|
||||
|
||||
### 6.7 Fristen export / paliad data export (t-paliad-279)
|
||||
|
||||
Untouched. The exporter dumps `proceeding_types` as a whole (no kind-filter); after mig 153 it dumps the same rows with the new kind column. Forward-compat by default.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration sequencing decision vs m/paliad#146
|
||||
|
||||
m/paliad#146 (Fristenrechner overhaul, t-paliad-322 / 323) is on the S1-S6 train under knuth. m's directive at task brief time: **knuth pauses at the S1+S2 seam waiting for this taxonomy decision**.
|
||||
|
||||
Three options were on the table:
|
||||
|
||||
(a) **Pause #146 until taxonomy clean** — knuth blocked, this design lands first, then knuth resumes S3+.
|
||||
(b) **Land #146 against current shape, migrate later** — knuth ships S3-S6 against the current 46-row table, taxonomy mig follows.
|
||||
(c) **Land taxonomy in parallel, knuth re-targets if needed** — both run, knuth's S3 picks up the new filter when mig 153 is ready.
|
||||
|
||||
**Recommendation: (c) parallel-land** with the following caveats:
|
||||
|
||||
- The taxonomy mig is **additive** (ADD COLUMN with safe DEFAULT, no DROP, no data move beyond UPDATEs that touch unreferenced rows). Knuth's S3 implementation can be written with or without the `kind='proceeding'` filter — adding the filter is a one-line patch the moment mig 153 lands.
|
||||
- The R3 chip-pool query in knuth's S3 PR should be **future-proofed by also adding the `kind='proceeding'` filter behind a feature flag or an env-time SQL constant**, defaulting to "no filter" pre-mig and "filter" post-mig. (Or simpler: knuth writes the filter unconditionally; the migration lands first; ordering is mechanical.)
|
||||
- The mig 153 PR should land **before** knuth's S3 PR ships to main, so the filter is never false-positive (chipping phase rows users can't actually pick). Both PRs can be drafted in parallel; the squeeze happens at merge time.
|
||||
- Sequence on main: mig 153 → knuth S3 (with filter) → knuth S4-S6.
|
||||
|
||||
Option (c) keeps knuth productive (S3 work can start immediately after this design ratifies; doesn't have to wait for the mig to merge) and avoids the option (a) idle cost.
|
||||
|
||||
Option (b) was rejected because it leaves the Mode B R3 wizard chipping 35 UPC rows on initial release — exactly the bug m flagged in m/paliad#147 ("half of the 46 active proceeding_types are not primary proceedings"). The user would see phase rows in R3 day one of the Fristenrechner overhaul shipping; we'd be shipping the bug.
|
||||
|
||||
Option (a) was rejected as the safest but slowest path. The taxonomy mig is trivial enough (one ALTER + four UPDATE statements + optional trigger) that parallel-running has no real risk.
|
||||
|
||||
§9 Q10 gives m the chance to pick differently.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of scope (flagged for separate work)
|
||||
|
||||
- **`upc.apl.*` data drift.** 30 rows in `paliad.event_category_concepts` reference the inactive `upc.apl.merits` / `upc.apl.order` / `upc.apl.cost` codes (the pre-`upc.apl.unified` triplet). 4 active sequencing_rules reference `spawn_proceeding_type_id=11` (the inactive `upc.apl.merits` row). This is a pre-existing inconsistency from the appeal unification mig — needs its own follow-up ticket. Not blocking this design; can be cleaned up in a separate migration that retargets concepts + spawn FKs to `upc.apl.unified` (id=160).
|
||||
- **Renaming or relabelling primary proceedings.** Out per m/paliad#147 acceptance — editorial work, not structural.
|
||||
- **Adding new proceeding types beyond the existing corpus.** Out per m/paliad#147 acceptance.
|
||||
- **The Fristenrechner UI overhaul itself (m/paliad#146).** Separate track; this design only tells knuth's S3 what set to chip.
|
||||
- **The scenarios design (m/paliad#124).** Already ratified in `docs/design-litigation-planner-2026-05-26.md` §5; this design only refines the spec validator's "every code resolves to a primary" check.
|
||||
- **DROPing the non-primary rows physically.** Reversible deactivation via `kind=...` + optional `is_active=false` is enough; physical deletion adds irreversibility risk for no functional gain.
|
||||
- **Migration of `event_category_concepts.proceeding_type_code` to a real FK.** It's text today, joined softly; converting to FK is a separate hardening task.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions for m (10 decision questions)
|
||||
|
||||
Sent via `AskUserQuestion` in 3 batches per inventor SKILL contract (4+3+3). m's picks land in §10 below after the round-trip.
|
||||
|
||||
| # | Topic | Recommended pick |
|
||||
|---|---|---|
|
||||
| Q1 | Model choice | Model 1 (kind discriminator) |
|
||||
| Q2 | Phases — linear sub-phases of every CFI, or separately-elected? | Implicit: phases live in `procedural_events.event_kind`, not as proceeding_types |
|
||||
| Q3.a | Side-actions — triggered by parent event, or initiated out-of-band? | Mixed; today's data has no rules, future rules anchor on the parent primary with `condition_expr` |
|
||||
| Q3.b | `upc.pl.cfi` (Schutzschrift) — primary or side-action? | Primary (own RoP filing pathway) |
|
||||
| Q4 | Collapse `de.inf.lg`/`olg`/`bgh` into one `de.inf` with instance_level qualifier? | No — keep discrete |
|
||||
| Q5 | Collapse `de.null.bpatg`/`bgh` into one `de.null` with instance_level qualifier? | No — keep discrete |
|
||||
| Q6 | Should DE follow the `upc.apl.unified` pattern? | No (= keep discrete, locks Q4+Q5) |
|
||||
| Q7 | `upc.ccr.cfi` — proceeding row with routing (status quo), or `with_ccr` flag on `upc.inf.cfi`? | Keep as proceeding (status quo per t-paliad-204 S1) |
|
||||
| Q8 | Enforce `projects.proceeding_type_id` → `kind='proceeding'` at the DB level? | Yes, via trigger (§3.3) |
|
||||
| Q9 | Set `is_active=false` on the 28 non-primary rows after mig 153? | Yes (cleanest admin UX) |
|
||||
| Q10 | Sequencing vs m/paliad#146 — pause / parallel / re-target? | (c) parallel-land — mig first, then knuth S3 with filter |
|
||||
|
||||
Q11 in the issue body ("how many rules need new condition_expr disambiguation?") is **empirically answered, no decision needed**: 0 rules need new condition_expr — every active rule is already correctly anchored to a primary. Surfaced in §4 + §6.2.
|
||||
|
||||
---
|
||||
|
||||
## 10. m's decisions (2026-05-27)
|
||||
|
||||
All 11 questions answered via `AskUserQuestion` on 2026-05-27 09:52 (3 batches of 4+4+3). 10 of 11 picks = recommendation; Q9 diverged at the chip-picker but m's follow-up instruction ("I follow your recommendation") flips Q9 to the recommendation as well. Q2 carries a precise carve-out captured verbatim below.
|
||||
|
||||
- **Q1 (Model): Model 1 — kind discriminator.** [= recommendation] One column + CHECK constraint + UPDATE statements. **Locks §1, §2, §3.1, §3.2.**
|
||||
- **Q2 (Phases): Generally option 1 (implicit via `procedural_events.event_kind`), with carve-outs.** [≈ option 1 with carve-out] m's verbatim call:
|
||||
> Generally 1, but I agree with costs which are not only a phase but also "standalone" side proceedings. But default decision application is not.
|
||||
Concretely:
|
||||
- `upc.cfi.interim` (173) → `kind='phase'`
|
||||
- `upc.cfi.oral` (174) → `kind='phase'`
|
||||
- `upc.cfi.decision` (175) → `kind='phase'`
|
||||
- `upc.default.cfi` (185) → `kind='phase'` (m: "default decision application is not [a standalone side proceeding]")
|
||||
- **`upc.costs.cfi` (176) → `kind='proceeding'`** (m: "costs are not only a phase but also standalone side proceedings"). The Separate Kostenentscheidung can be filed as its own application under R.151 RoP independently of the parent decision; m's read is that the standalone-application character outweighs the phase-of-CFI character.
|
||||
Net: 4 phase rows (not 5 as in the strawman), 23 primary-proceeding rows (not 22). **Updates §0.4 Group B count, §0.5 totals row, §1 categorisation, §3.2 UPDATE statement IDs (drop 176 from the phase UPDATE).**
|
||||
- **Q3.a (Side-actions): kind='side_action', rules anchor on parent primary.** [= recommendation] All 10 §0.4 Group C rows get `kind='side_action'`. When corpus arrives, rules attach to the parent primary with a `condition_expr` flag. **Locks §1.1, §3.2 side-action UPDATE.**
|
||||
- **Q3.b (Schutzschrift): kind='proceeding'.** [= recommendation] `upc.pl.cfi` (188) stays in the primary set on the strength of its own RoP filing pathway. **Locks §0.3 unloaded-primary list.**
|
||||
- **Q4 (DE inf collapse): Keep discrete.** [= recommendation] `de.inf.lg/olg/bgh` stay as 3 separate primaries. No collapse, no instance_level qualifier introduction. **Locks §0.2 + §1 DE-side categorisation.**
|
||||
- **Q5 (DE null collapse): Keep discrete.** [= recommendation] `de.null.bpatg/bgh` stay separate. Symmetric with Q4. **Locks §0.2 + §1 DE-side categorisation.**
|
||||
- **Q6 (DE follow upc.apl pattern): No — keep DE discrete.** [= recommendation] Locks Q4+Q5. The `upc.apl.unified` consolidation was about same-court appeal variants; DE appeals are different-court-instance appeals — different problem. **No code-rename work falls out of this design.**
|
||||
- **Q7 (CCR shape): Keep status quo.** [= recommendation] `upc.ccr.cfi` stays as `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1. **Locks §1.1.**
|
||||
- **Q8 (DB trigger): Trigger on `projects` only.** [= recommendation] BEFORE INSERT/UPDATE trigger on `paliad.projects` enforces `proceeding_type_id → kind='proceeding'`. No trigger on `sequencing_rules` (admin tooling already gates). **Locks §3.3 — keep the `projects` trigger DDL, drop the optional `sequencing_rules` variant.**
|
||||
- **Q9 (Deactivate non-primaries): Yes — deactivate.** [m's chip-pick was "keep active"; flipped to recommendation per m's "I follow your recommendation" instruction] All `kind IN ('phase', 'side_action', 'meta')` rows get `is_active=false` in mig 153. The admin `/admin/proceeding-types` list shows only the 23 active primaries. Rows stay in the table with their `kind` tag so future tooling that wants to surface them can flip `is_active` back on. **Updates §3.2 — uncomment the optional `UPDATE … SET is_active=false` block.**
|
||||
- **Q10 (Sequencing vs #146): Parallel-land.** [= recommendation] Mig 153 + knuth's S3 PR drafted in parallel; mig merges first; knuth's S3 includes the `kind='proceeding'` filter in R3's chip query from day one. No idle cost; no bug shipped. **Locks §7.**
|
||||
|
||||
### 10.1 What changed from the strawman as a result
|
||||
|
||||
Two material edits flow from m's picks:
|
||||
|
||||
1. **§0.4 Group B (Phases) drops `upc.costs.cfi` (id 176)** — moved into the primary set. Phase count: 5 → 4. Primary count: 22 → 23. §0.2 picks up id 176 as an unloaded primary (zero rules today; future corpus will attach).
|
||||
2. **§3.2 migration includes the `is_active=false` UPDATE** (was optional in the strawman, now mandatory):
|
||||
|
||||
```sql
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE kind IN ('phase', 'side_action', 'meta');
|
||||
```
|
||||
|
||||
This is what the post-mig 153 cleanup looks like: 23 active rows (all `kind='proceeding'`), 23 inactive rows (4 phase + 10 side_action + 9 meta + the pre-existing 3 inactive appeal-triplet + 1 archived bucket = 27 inactive total, but 23 of those are the freshly-deactivated taxonomy rows).
|
||||
|
||||
These edits don't change the §7 sequencing decision or the §6 consumer-impact analysis. They tighten the mig file and shift one row's classification.
|
||||
|
||||
### 10.2 Final categorisation (post-decisions)
|
||||
|
||||
| `kind` | Count | Codes |
|
||||
|---|---:|---|
|
||||
| `proceeding` | **23** | upc.inf.cfi, upc.rev.cfi, upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, upc.ccr.cfi, upc.apl.unified, upc.dni.cfi, upc.epo.review, upc.bsv.cfi, upc.pl.cfi, **upc.costs.cfi** (m's Q2 carve-out), de.inf.lg, de.inf.olg, de.inf.bgh, de.null.bpatg, de.null.bgh, epa.opp.opd, epa.opp.boa, epa.grant.exa, dpma.opp.dpma, dpma.appeal.bpatg, dpma.appeal.bgh |
|
||||
| `phase` | **4** | upc.cfi.interim, upc.cfi.oral, upc.cfi.decision, upc.default.cfi |
|
||||
| `side_action` | **10** | upc.evidence.cfi, upc.experiments.cfi, upc.security.cfi, upc.intervention.rop, upc.parties.change, upc.optout.cfi, upc.inspection.cfi, upc.freezing.cfi, upc.withdrawal.rop, upc.rehearing.coa |
|
||||
| `meta` | **9** | upc.case.mgmt, upc.general.rop, upc.service.rop, upc.language.rop, upc.representation.rop, upc.fees.court, upc.legalaid.cfi, upc.special.cfi, upc.reestablishment.rop |
|
||||
| **Total** | **46** | ✓ |
|
||||
|
||||
Post-mig 153: 23 active (all `kind='proceeding'`), 23 deactivated (the phase/side_action/meta set).
|
||||
|
||||
---
|
||||
|
||||
## 11. Synthesis links
|
||||
|
||||
- mBrian topic: `topic-fristenrechner` — file this design as a `[synthesis]` node, link `related_to` the proceeding-code-taxonomy doc (2026-05-18) and the Fristenrechner overhaul (2026-05-26), `triggered_by` t-paliad-324.
|
||||
- Related design docs: `docs/design-proceeding-code-taxonomy-2026-05-18.md` (the code-shape doc), `docs/design-fristenrechner-overhaul-2026-05-26.md` (knuth's parent design), `docs/design-litigation-planner-2026-05-26.md` §5 (scenarios spec validator).
|
||||
- Related migrations: 095 (fristen gap-fill, spawn FK invariant), 096 (proceeding code rename), 152 (sequencing_rule dedupe + admin column).
|
||||
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
@@ -40,13 +40,13 @@ import { renderAdminTeam } from "./src/admin-team";
|
||||
import { renderAdminAuditLog } from "./src/admin-audit-log";
|
||||
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
|
||||
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
|
||||
import { renderAdminSubmissionBuildingBlocks } from "./src/admin-submission-building-blocks";
|
||||
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
|
||||
import { renderAdminEventTypes } from "./src/admin-event-types";
|
||||
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
|
||||
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
|
||||
import { renderAdminRulesList } from "./src/admin-rules-list";
|
||||
import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderAdminBackups } from "./src/admin-backups";
|
||||
@@ -279,12 +279,12 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-partner-units.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-submission-building-blocks.ts"),
|
||||
join(import.meta.dir, "src/client/admin-event-types.ts"),
|
||||
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-list.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-export.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
// t-paliad-161 — inline Paliadin widget. Loaded via the
|
||||
// PaliadinWidget component on every authenticated page, so the
|
||||
@@ -411,12 +411,12 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
|
||||
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
|
||||
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
|
||||
await Bun.write(join(DIST, "admin-submission-building-blocks.html"), renderAdminSubmissionBuildingBlocks());
|
||||
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
|
||||
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList());
|
||||
await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit());
|
||||
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules/{id}/edit — Slice 11b (t-paliad-192). Form for the full
|
||||
// /admin/procedural-events/{id}/edit — Slice 11b (t-paliad-192). Form for the full
|
||||
// 37-column rule row plus a side panel with the preview widget and the
|
||||
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
|
||||
// rule's current state (draft/published/archived). Every write goes
|
||||
@@ -26,12 +26,12 @@ export function renderAdminRulesEdit(): string {
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.edit.title">Regel bearbeiten — Paliad</title>
|
||||
<title data-i18n="admin.procedural_events.edit.title">Regel bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
<Sidebar currentPath="/admin/procedural-events" />
|
||||
<BottomNav currentPath="/admin/procedural-events" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
@@ -39,7 +39,7 @@ export function renderAdminRulesEdit(): string {
|
||||
<div className="tool-header admin-rules-edit-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">← Regeln verwalten</a>
|
||||
<a href="/admin/procedural-events" data-i18n="admin.procedural_events.edit.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
|
||||
<div className="admin-rules-edit-meta">
|
||||
@@ -71,7 +71,7 @@ export function renderAdminRulesEdit(): string {
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
|
||||
<label htmlFor="f-submission-code" data-i18n="admin.procedural_events.edit.field.code">Submission Code / Einreichung-Kennung</label>
|
||||
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
@@ -103,7 +103,7 @@ export function renderAdminRulesEdit(): string {
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
|
||||
<label htmlFor="f-parent" data-i18n="admin.procedural_events.edit.field.parent">Parent-Regel (UUID)</label>
|
||||
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
@@ -184,7 +184,7 @@ export function renderAdminRulesEdit(): string {
|
||||
<input type="text" id="f-primary-party" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
|
||||
<label htmlFor="f-event-type" data-i18n="admin.procedural_events.edit.field.event_kind">Event-Typ (frei)</label>
|
||||
<input type="text" id="f-event-type" className="admin-rules-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules/export — Slice 11b (t-paliad-192). Surfaces the
|
||||
// GET /admin/api/rules/export-migrations endpoint as a SQL preview the
|
||||
// editor can copy or download. Optional ?since=<audit-id> query lets
|
||||
// the editor scope the export to a particular audit window — empty =
|
||||
// every un-exported audit row.
|
||||
export function renderAdminRulesExport(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.export.title">Regel-Migrations exportieren — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/rules" data-i18n="admin.rules.export.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 data-i18n="admin.rules.export.heading">Regel-Migrations exportieren</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.export.subtitle">
|
||||
Generiert ein <code>*.up.sql</code>-Blob mit allen unsynchronisierten Audit-Veränderungen.
|
||||
Manuell in <code>internal/db/migrations/</code> einchecken.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-export-controls">
|
||||
<div className="form-field">
|
||||
<label htmlFor="export-since" data-i18n="admin.rules.export.field.since">Startend ab Audit-ID (optional)</label>
|
||||
<input type="text" id="export-since" className="admin-rules-input" placeholder="UUID, leer = alle un-exportierten" />
|
||||
</div>
|
||||
<button type="button" id="export-run" className="btn-primary" data-i18n="admin.rules.export.run">
|
||||
Export generieren
|
||||
</button>
|
||||
<button type="button" id="export-download" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.download">
|
||||
Als Datei herunterladen
|
||||
</button>
|
||||
<button type="button" id="export-copy" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.copy">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="export-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-rules-export-summary" id="export-summary" style="display:none">
|
||||
<span id="export-summary-count" />
|
||||
<span id="export-summary-latest" />
|
||||
</div>
|
||||
|
||||
<pre id="export-output" className="admin-rules-export-pre" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-rules-export.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules — Slice 11b (t-paliad-192). Filterable rule table + an
|
||||
// /admin/procedural-events — Slice 11b (t-paliad-192). Filterable rule table + an
|
||||
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
|
||||
// admin can hand-bind each legacy deadline to one of the candidate
|
||||
// rule_ids. Both surfaces share the same page shell to keep navigation
|
||||
@@ -21,28 +21,25 @@ export function renderAdminRulesList(): string {
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.list.title">Regeln verwalten — Paliad</title>
|
||||
<title data-i18n="admin.procedural_events.list.title">Regeln verwalten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
<Sidebar currentPath="/admin/procedural-events" />
|
||||
<BottomNav currentPath="/admin/procedural-events" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.rules.list.heading">Regeln verwalten</h1>
|
||||
<h1 data-i18n="admin.procedural_events.list.heading">Regeln verwalten</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
|
||||
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-rules-header-actions">
|
||||
<a href="/admin/rules/export" className="btn-secondary" data-i18n="admin.rules.list.export">
|
||||
Migrations exportieren
|
||||
</a>
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.procedural_events.list.new">
|
||||
+ Neue Regel
|
||||
</button>
|
||||
</div>
|
||||
@@ -104,10 +101,10 @@ export function renderAdminRulesList(): string {
|
||||
<table className="entity-table admin-rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
|
||||
<th data-i18n="admin.procedural_events.col.code">Submission Code</th>
|
||||
<th data-i18n="admin.procedural_events.col.proceeding">Verfahren</th>
|
||||
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
|
||||
<th data-i18n="admin.rules.col.name">Name</th>
|
||||
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
|
||||
<th data-i18n="admin.rules.col.priority">Priorität</th>
|
||||
<th data-i18n="admin.rules.col.lifecycle">Lifecycle</th>
|
||||
<th data-i18n="admin.rules.col.modified">Zuletzt geändert</th>
|
||||
|
||||
77
frontend/src/admin-submission-building-blocks.tsx
Normal file
77
frontend/src/admin-submission-building-blocks.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/submission-building-blocks — Composer building-blocks library
|
||||
// editor (t-paliad-315 Slice C). Three-pane layout: list on the left,
|
||||
// edit form in the middle, version log on the right. Hydrated by
|
||||
// client/admin-submission-building-blocks.ts from
|
||||
// GET /api/admin/submission-building-blocks.
|
||||
|
||||
export function renderAdminSubmissionBuildingBlocks(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.building_blocks.title">Bausteine — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/submission-building-blocks" />
|
||||
<BottomNav currentPath="/admin/submission-building-blocks" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.building_blocks.heading">Bausteine</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.building_blocks.subtitle">
|
||||
Wiederverwendbare Textbausteine für Composer-Abschnitte.
|
||||
</p>
|
||||
</div>
|
||||
<div className="tool-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
id="admin-bb-new-btn"
|
||||
className="btn-primary btn-cta-lime"
|
||||
data-i18n="admin.building_blocks.action.new">
|
||||
+ Neuer Baustein
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-bb-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-bb-layout">
|
||||
<aside className="admin-bb-list" id="admin-bb-list">
|
||||
<div className="admin-bb-loading" data-i18n="admin.building_blocks.loading">Lädt…</div>
|
||||
</aside>
|
||||
|
||||
<section className="admin-bb-editor" id="admin-bb-editor">
|
||||
<p className="admin-bb-empty" data-i18n="admin.building_blocks.editor.empty">
|
||||
Wählen Sie einen Baustein aus der Liste — oder erstellen Sie einen neuen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<aside className="admin-bb-versions" id="admin-bb-versions" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-submission-building-blocks.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export function renderAdmin(): string {
|
||||
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
|
||||
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
|
||||
</a>
|
||||
<a href="/admin/rules" className="card card-link">
|
||||
<a href="/admin/procedural-events" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
|
||||
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
|
||||
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule
|
||||
// admin-rules-edit.ts — /admin/procedural-events/{id}/edit. Loads a single rule
|
||||
// row, drives every form field, the preview widget, the audit-log
|
||||
// timeline and the lifecycle action bar. Every write is gated behind
|
||||
// a reason modal — the ≥10-char rule is enforced client-side per
|
||||
@@ -106,7 +106,7 @@ function fmtDateTime(iso: string): string {
|
||||
}
|
||||
|
||||
function parseRuleIDFromPath(): string {
|
||||
// /admin/rules/{uuid}/edit
|
||||
// /admin/procedural-events/{uuid}/edit
|
||||
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
|
||||
return m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
@@ -179,7 +179,7 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
|
||||
}
|
||||
|
||||
async function loadRule(): Promise<void> {
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`);
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 404) {
|
||||
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
|
||||
@@ -198,7 +198,7 @@ async function loadAudit(reset: boolean = true): Promise<void> {
|
||||
auditEntries = [];
|
||||
auditOffset = 0;
|
||||
}
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
|
||||
if (!resp.ok) return;
|
||||
const body = await resp.json();
|
||||
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
|
||||
@@ -508,7 +508,7 @@ async function doSaveDraft(reason: string) {
|
||||
return;
|
||||
}
|
||||
payload.reason = reason;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -530,7 +530,7 @@ async function doSaveDraft(reason: string) {
|
||||
|
||||
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, {
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/${op}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
@@ -552,7 +552,7 @@ async function doLifecycle(op: "publish" | "archive" | "restore", reason: string
|
||||
|
||||
async function doClone(reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, {
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/clone-as-draft`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
@@ -565,7 +565,7 @@ async function doClone(reason: string) {
|
||||
return;
|
||||
}
|
||||
const newRule = await resp.json() as Rule;
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
|
||||
window.location.href = `/admin/procedural-events/${encodeURIComponent(newRule.id)}/edit`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
@@ -591,7 +591,7 @@ async function runPreview() {
|
||||
if (flagsRaw) qs.set("flags", flagsRaw);
|
||||
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
|
||||
out.style.display = "";
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-export.ts — /admin/rules/export. Calls
|
||||
// GET /admin/api/rules/export-migrations[?since=<uuid>] and renders the
|
||||
// SQL blob server-side. Download builds a Blob URL and triggers a
|
||||
// fake <a> click; copy uses navigator.clipboard.
|
||||
|
||||
interface ExportResult {
|
||||
migration_sql: string;
|
||||
count: number;
|
||||
latest_audit_id: string;
|
||||
}
|
||||
|
||||
let latest: ExportResult | null = null;
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("export-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
if (!isError) setTimeout(() => { el.style.display = "none"; }, 4000);
|
||||
}
|
||||
|
||||
async function runExport() {
|
||||
const since = (document.getElementById("export-since") as HTMLInputElement).value.trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (since) qs.set("since", since);
|
||||
const url = "/admin/api/rules/export-migrations" + (qs.toString() ? "?" + qs.toString() : "");
|
||||
const out = document.getElementById("export-output") as HTMLElement;
|
||||
const summary = document.getElementById("export-summary") as HTMLElement;
|
||||
const dl = document.getElementById("export-download") as HTMLElement;
|
||||
const cp = document.getElementById("export-copy") as HTMLElement;
|
||||
out.textContent = t("admin.rules.export.running") || "Lade...";
|
||||
summary.style.display = "none";
|
||||
dl.style.display = "none";
|
||||
cp.style.display = "none";
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || (t("admin.rules.export.error") || "Export fehlgeschlagen."), true);
|
||||
out.textContent = "";
|
||||
return;
|
||||
}
|
||||
latest = await resp.json() as ExportResult;
|
||||
out.textContent = latest.migration_sql;
|
||||
summary.style.display = "";
|
||||
const countEl = document.getElementById("export-summary-count") as HTMLElement;
|
||||
const latestEl = document.getElementById("export-summary-latest") as HTMLElement;
|
||||
countEl.textContent = (t("admin.rules.export.count") || "Audit-Zeilen: {n}").replace("{n}", String(latest.count));
|
||||
if (latest.latest_audit_id) {
|
||||
latestEl.textContent = (t("admin.rules.export.latest") || "Letzte Audit-ID: {id}").replace("{id}", latest.latest_audit_id);
|
||||
} else {
|
||||
latestEl.textContent = "";
|
||||
}
|
||||
if (latest.count > 0) {
|
||||
dl.style.display = "";
|
||||
cp.style.display = "";
|
||||
showFeedback((t("admin.rules.export.ok") || "{n} Audit-Zeilen exportiert.").replace("{n}", String(latest.count)), false);
|
||||
} else {
|
||||
showFeedback(t("admin.rules.export.no_pending") || "Keine offenen Audit-Zeilen zum Export.", false);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
if (!latest) return;
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const name = `rules-export-${ts}.up.sql`;
|
||||
const blob = new Blob([latest.migration_sql], { type: "application/sql" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!latest) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(latest.migration_sql);
|
||||
showFeedback(t("admin.rules.export.copied") || "In Zwischenablage kopiert.", false);
|
||||
} catch (e) {
|
||||
showFeedback(t("admin.rules.export.copy_failed") || "Kopieren fehlgeschlagen.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
(document.getElementById("export-run") as HTMLElement).addEventListener("click", runExport);
|
||||
(document.getElementById("export-download") as HTMLElement).addEventListener("click", downloadFile);
|
||||
(document.getElementById("export-copy") as HTMLElement).addEventListener("click", copyToClipboard);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
@@ -1,16 +1,23 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-list.ts — /admin/rules. Drives the rule table (filterable
|
||||
// admin-rules-list.ts — /admin/procedural-events. Drives the rule table (filterable
|
||||
// by proceeding type, trigger event, lifecycle state, free-text query)
|
||||
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
|
||||
// a rule routes to /admin/rules/{id}/edit; orphan cards have their own
|
||||
// a rule routes to /admin/procedural-events/{id}/edit; orphan cards have their own
|
||||
// "Pick" affordance with an inline reason prompt that posts to
|
||||
// /admin/api/orphans/{id}/resolve.
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
proceeding_type_id?: number | null;
|
||||
// proceeding_type_code is the joined paliad.proceeding_types.code
|
||||
// for proceeding_type_id, populated server-side by the
|
||||
// /admin/api/procedural-events LIST handler (t-paliad-321). Lets the
|
||||
// table show the 3-segment proceeding code (e.g. "upc.inf.cfi") at
|
||||
// a glance without depending on the FILTER-dropdown's limited
|
||||
// proceeding list. NULL on event-rooted rules.
|
||||
proceeding_type_code?: string | null;
|
||||
// submission_code is the proceeding-prefixed identifier of this rule
|
||||
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
|
||||
// rule_code (the legal citation, e.g. `RoP.013.1`).
|
||||
@@ -138,6 +145,19 @@ function proceedingLabel(id: number | null | undefined): string {
|
||||
return `${pt.code} · ${name}`;
|
||||
}
|
||||
|
||||
// proceedingCodeCell renders the LIST table's Proceeding column. Uses
|
||||
// the server-side joined proceeding_type_code when available
|
||||
// (t-paliad-321), falling back to the dropdown-lookup proceedingLabel
|
||||
// for older API responses or for rules whose proceeding_type_id
|
||||
// resolves but proceeding_type_code didn't (defence-in-depth). NULL
|
||||
// proceeding_type_id renders as the em-dash placeholder used
|
||||
// elsewhere in the admin table.
|
||||
function proceedingCodeCell(r: Rule): string {
|
||||
if (r.proceeding_type_code) return r.proceeding_type_code;
|
||||
if (r.proceeding_type_id == null) return "—";
|
||||
return proceedingLabel(r.proceeding_type_id);
|
||||
}
|
||||
|
||||
function buildFilterURL(): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (activeProceeding) qs.set("proceeding_type_id", activeProceeding);
|
||||
@@ -145,7 +165,7 @@ function buildFilterURL(): string {
|
||||
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
|
||||
if (activeQuery) qs.set("q", activeQuery);
|
||||
qs.set("limit", "500");
|
||||
return "/admin/api/rules?" + qs.toString();
|
||||
return "/admin/api/procedural-events?" + qs.toString();
|
||||
}
|
||||
|
||||
async function loadProceedings(): Promise<void> {
|
||||
@@ -233,9 +253,9 @@ function renderRulesTable() {
|
||||
tbody.innerHTML = rules.map((r) => `
|
||||
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
|
||||
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
|
||||
<td class="admin-rules-col-proceeding"><code>${esc(proceedingCodeCell(r))}</code></td>
|
||||
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
|
||||
<td>${esc(name(r))}</td>
|
||||
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
|
||||
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>
|
||||
<td><span class="${lifecycleClass(r.lifecycle_state)}">${esc(lifecycleLabel(r.lifecycle_state))}</span></td>
|
||||
<td class="admin-rules-col-modified">${esc(fmtDateTime(r.updated_at))}</td>
|
||||
@@ -248,7 +268,7 @@ function renderRulesTable() {
|
||||
if (target && (target.closest("a") || target.closest("button"))) return;
|
||||
const id = row.dataset.rowId;
|
||||
if (!id) return;
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(id)}/edit`;
|
||||
window.location.href = `/admin/procedural-events/${encodeURIComponent(id)}/edit`;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -392,7 +412,7 @@ async function submitReasonModal(ev: Event) {
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
const resp = await fetch("/admin/api/rules", {
|
||||
const resp = await fetch("/admin/api/procedural-events", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -416,7 +436,7 @@ async function submitReasonModal(ev: Event) {
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
|
||||
window.location.href = `/admin/procedural-events/${encodeURIComponent(created.id)}/edit`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
429
frontend/src/client/admin-submission-building-blocks.ts
Normal file
429
frontend/src/client/admin-submission-building-blocks.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { initI18n, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
function isEN(): boolean { return getLang() === "en"; }
|
||||
|
||||
// /admin/submission-building-blocks — Composer building-blocks admin
|
||||
// editor (t-paliad-315 Slice C). Three-pane layout: list → editor →
|
||||
// version log. CRUD via /api/admin/submission-building-blocks/*.
|
||||
//
|
||||
// Per Q2 ratification (m, 2026-05-26): building blocks are plain text
|
||||
// paste sources. The editor here is curator-only — no per-section
|
||||
// lineage to surface, no "where is this block used" view.
|
||||
|
||||
interface BuildingBlockJSON {
|
||||
id: string;
|
||||
slug: string;
|
||||
firm?: string | null;
|
||||
section_key: string;
|
||||
proceeding_family?: string | null;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
description_de?: string | null;
|
||||
description_en?: string | null;
|
||||
content_md_de: string;
|
||||
content_md_en: string;
|
||||
author_id?: string | null;
|
||||
visibility: string;
|
||||
is_published: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface VersionJSON {
|
||||
id: string;
|
||||
building_block_id: string;
|
||||
content_md_de: string;
|
||||
content_md_en: string;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
edited_by?: string | null;
|
||||
note?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const VISIBILITIES = ["private", "team", "firm", "global"];
|
||||
|
||||
// Section keys must match what the Composer base spec declares for
|
||||
// each section (see internal/db/migrations/146_submission_bases.up.sql).
|
||||
const SECTION_KEYS = [
|
||||
"letterhead", "caption", "introduction", "requests",
|
||||
"facts", "legal_argument", "evidence", "exhibits",
|
||||
"closing", "signature",
|
||||
];
|
||||
|
||||
const state = {
|
||||
blocks: [] as BuildingBlockJSON[],
|
||||
selectedID: null as string | null,
|
||||
versions: [] as VersionJSON[],
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
await loadList();
|
||||
document.getElementById("admin-bb-new-btn")?.addEventListener("click", onNew);
|
||||
}
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/api/admin/submission-building-blocks", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
feedback(`HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { blocks?: BuildingBlockJSON[] };
|
||||
state.blocks = body.blocks ?? [];
|
||||
paintList();
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
function paintList(): void {
|
||||
const host = document.getElementById("admin-bb-list");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
if (state.blocks.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "admin-bb-empty";
|
||||
empty.textContent = isEN() ? "No blocks yet." : "Noch keine Bausteine.";
|
||||
host.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const b of state.blocks) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "admin-bb-list-row";
|
||||
if (b.id === state.selectedID) row.classList.add("admin-bb-list-row--active");
|
||||
const title = isEN() ? b.title_en : b.title_de;
|
||||
row.innerHTML = `
|
||||
<span class="admin-bb-list-title">${escapeHTML(title || b.slug)}</span>
|
||||
<span class="admin-bb-list-meta">
|
||||
<span class="admin-bb-list-section">${escapeHTML(b.section_key)}</span>
|
||||
<span class="admin-bb-list-vis admin-bb-list-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||||
${b.is_published ? "" : `<span class="admin-bb-list-draft">${isEN() ? "draft" : "Entwurf"}</span>`}
|
||||
</span>`;
|
||||
row.addEventListener("click", () => onSelect(b.id));
|
||||
host.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelect(id: string): Promise<void> {
|
||||
state.selectedID = id;
|
||||
state.dirty = false;
|
||||
paintList();
|
||||
const b = state.blocks.find(x => x.id === id);
|
||||
if (!b) return;
|
||||
paintEditor(b);
|
||||
await loadVersions(id);
|
||||
}
|
||||
|
||||
function onNew(): void {
|
||||
state.selectedID = null;
|
||||
state.versions = [];
|
||||
state.dirty = false;
|
||||
paintList();
|
||||
paintEditor(null);
|
||||
paintVersions();
|
||||
}
|
||||
|
||||
function paintEditor(b: BuildingBlockJSON | null): void {
|
||||
const host = document.getElementById("admin-bb-editor");
|
||||
if (!host) return;
|
||||
const isNew = b === null;
|
||||
const data = b ?? {
|
||||
id: "",
|
||||
slug: "",
|
||||
firm: "",
|
||||
section_key: "requests",
|
||||
proceeding_family: "",
|
||||
title_de: "",
|
||||
title_en: "",
|
||||
description_de: "",
|
||||
description_en: "",
|
||||
content_md_de: "",
|
||||
content_md_en: "",
|
||||
visibility: "firm",
|
||||
is_published: false,
|
||||
} as Partial<BuildingBlockJSON>;
|
||||
|
||||
host.innerHTML = "";
|
||||
const form = document.createElement("form");
|
||||
form.className = "admin-bb-form";
|
||||
form.addEventListener("submit", (e) => { e.preventDefault(); onSave(isNew); });
|
||||
|
||||
form.appendChild(textField("slug", isEN() ? "Slug" : "Slug", data.slug ?? "", true));
|
||||
form.appendChild(textField("firm", "Firm", data.firm ?? "", false, isEN() ? "leer = firmenagnostisch" : "leer = firmenagnostisch"));
|
||||
form.appendChild(selectField("section_key", isEN() ? "Section key" : "Abschnitts-Slug", data.section_key ?? "requests", SECTION_KEYS, false));
|
||||
form.appendChild(textField("proceeding_family", isEN() ? "Proceeding family" : "Verfahrensfamilie", data.proceeding_family ?? "", false, "z. B. de.inf.lg"));
|
||||
form.appendChild(textField("title_de", "Titel (DE)", data.title_de ?? "", true));
|
||||
form.appendChild(textField("title_en", "Title (EN)", data.title_en ?? "", true));
|
||||
form.appendChild(textareaField("description_de", "Beschreibung (DE)", data.description_de ?? "", 2));
|
||||
form.appendChild(textareaField("description_en", "Description (EN)", data.description_en ?? "", 2));
|
||||
form.appendChild(textareaField("content_md_de", isEN() ? "Content (DE Markdown)" : "Inhalt (DE Markdown)", data.content_md_de ?? "", 10));
|
||||
form.appendChild(textareaField("content_md_en", isEN() ? "Content (EN Markdown)" : "Inhalt (EN Markdown)", data.content_md_en ?? "", 10));
|
||||
form.appendChild(selectField("visibility", isEN() ? "Visibility" : "Sichtbarkeit", data.visibility ?? "firm", VISIBILITIES, false));
|
||||
form.appendChild(checkboxField("is_published", isEN() ? "Published" : "Veröffentlicht", Boolean(data.is_published)));
|
||||
|
||||
if (!isNew) {
|
||||
form.appendChild(textField("note", isEN() ? "Save note (optional)" : "Speicher-Notiz (optional)", "", false));
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "admin-bb-form-actions";
|
||||
|
||||
const save = document.createElement("button");
|
||||
save.type = "submit";
|
||||
save.className = "btn-primary btn-cta-lime";
|
||||
save.textContent = isEN() ? "Save" : "Speichern";
|
||||
actions.appendChild(save);
|
||||
|
||||
if (!isNew) {
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "btn-link-danger";
|
||||
del.textContent = isEN() ? "Delete" : "Löschen";
|
||||
del.addEventListener("click", () => onDelete());
|
||||
actions.appendChild(del);
|
||||
}
|
||||
form.appendChild(actions);
|
||||
host.appendChild(form);
|
||||
}
|
||||
|
||||
function textField(name: string, label: string, value: string, required: boolean, hint?: string): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row";
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label + (required ? " *" : "");
|
||||
wrap.appendChild(lab);
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = name;
|
||||
input.className = "entity-form-input";
|
||||
input.value = value;
|
||||
if (required) input.required = true;
|
||||
wrap.appendChild(input);
|
||||
if (hint) {
|
||||
const h = document.createElement("small");
|
||||
h.className = "admin-bb-form-hint";
|
||||
h.textContent = hint;
|
||||
wrap.appendChild(h);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function textareaField(name: string, label: string, value: string, rows: number): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row";
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label;
|
||||
wrap.appendChild(lab);
|
||||
const ta = document.createElement("textarea");
|
||||
ta.name = name;
|
||||
ta.className = "entity-form-input";
|
||||
ta.rows = rows;
|
||||
ta.value = value;
|
||||
wrap.appendChild(ta);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function selectField(name: string, label: string, value: string, options: string[], required: boolean): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row";
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label + (required ? " *" : "");
|
||||
wrap.appendChild(lab);
|
||||
const sel = document.createElement("select");
|
||||
sel.name = name;
|
||||
sel.className = "entity-form-input";
|
||||
for (const opt of options) {
|
||||
const o = document.createElement("option");
|
||||
o.value = opt;
|
||||
o.textContent = opt;
|
||||
if (opt === value) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
wrap.appendChild(sel);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function checkboxField(name: string, label: string, value: boolean): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row admin-bb-form-row--checkbox";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.name = name;
|
||||
input.checked = value;
|
||||
wrap.appendChild(input);
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label;
|
||||
wrap.appendChild(lab);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function onSave(isNew: boolean): Promise<void> {
|
||||
const form = document.querySelector(".admin-bb-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
const data = new FormData(form);
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const key of ["slug", "section_key", "title_de", "title_en", "content_md_de", "content_md_en", "visibility"]) {
|
||||
const v = data.get(key);
|
||||
if (v !== null) payload[key] = String(v);
|
||||
}
|
||||
for (const key of ["firm", "proceeding_family", "description_de", "description_en"]) {
|
||||
const v = data.get(key);
|
||||
if (v !== null) {
|
||||
const s = String(v).trim();
|
||||
payload[key] = s === "" ? null : s;
|
||||
}
|
||||
}
|
||||
payload.is_published = (data.get("is_published") === "on");
|
||||
if (!isNew) {
|
||||
const note = data.get("note");
|
||||
if (note) payload.note = String(note);
|
||||
}
|
||||
try {
|
||||
const url = isNew
|
||||
? "/api/admin/submission-building-blocks"
|
||||
: `/api/admin/submission-building-blocks/${state.selectedID}`;
|
||||
const method = isNew ? "POST" : "PATCH";
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({} as { error?: string }));
|
||||
feedback(body.error ?? `HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
const saved = await res.json() as BuildingBlockJSON;
|
||||
feedback(isEN() ? "Saved." : "Gespeichert.", false);
|
||||
await loadList();
|
||||
state.selectedID = saved.id;
|
||||
paintList();
|
||||
paintEditor(saved);
|
||||
await loadVersions(saved.id);
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!state.selectedID) return;
|
||||
const sure = confirm(isEN() ? "Delete this block?" : "Diesen Baustein löschen?");
|
||||
if (!sure) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/submission-building-blocks/${state.selectedID}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
feedback(`HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
feedback(isEN() ? "Deleted." : "Gelöscht.", false);
|
||||
state.selectedID = null;
|
||||
await loadList();
|
||||
paintEditor(null);
|
||||
state.versions = [];
|
||||
paintVersions();
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions(blockID: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/submission-building-blocks/${blockID}/versions`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
state.versions = [];
|
||||
paintVersions();
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { versions?: VersionJSON[] };
|
||||
state.versions = body.versions ?? [];
|
||||
paintVersions();
|
||||
} catch {
|
||||
state.versions = [];
|
||||
paintVersions();
|
||||
}
|
||||
}
|
||||
|
||||
function paintVersions(): void {
|
||||
const host = document.getElementById("admin-bb-versions");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
if (state.versions.length === 0) return;
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = isEN() ? "History" : "Verlauf";
|
||||
host.appendChild(h);
|
||||
for (const v of state.versions) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-bb-version-row";
|
||||
const date = new Date(v.created_at).toLocaleString();
|
||||
row.innerHTML = `
|
||||
<div class="admin-bb-version-meta">${escapeHTML(date)} — ${escapeHTML(v.note ?? "")}</div>`;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn-small btn-secondary";
|
||||
btn.textContent = isEN() ? "Restore" : "Wiederherstellen";
|
||||
btn.addEventListener("click", () => onRestore(v.id));
|
||||
row.appendChild(btn);
|
||||
host.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRestore(versionID: string): Promise<void> {
|
||||
if (!state.selectedID) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/submission-building-blocks/${state.selectedID}/restore/${versionID}`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
feedback(`HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
const restored = await res.json() as BuildingBlockJSON;
|
||||
feedback(isEN() ? "Restored." : "Wiederhergestellt.", false);
|
||||
paintEditor(restored);
|
||||
await loadVersions(restored.id);
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
function feedback(msg: string, isError: boolean): void {
|
||||
const host = document.getElementById("admin-bb-feedback");
|
||||
if (!host) return;
|
||||
host.style.display = "";
|
||||
host.className = "form-msg " + (isError ? "form-msg--error" : "form-msg--ok");
|
||||
host.textContent = msg;
|
||||
if (!isError) {
|
||||
setTimeout(() => { host.style.display = "none"; }, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Silence unused-import warning when t() isn't called directly — i18n
|
||||
// is initialised so data-i18n attrs render on first paint.
|
||||
void t;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
@@ -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}`,
|
||||
@@ -237,6 +237,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.disc.cfi": "Bucheinsicht",
|
||||
"deadlines.upc.apl.cost": "Berufung Kosten",
|
||||
"deadlines.upc.apl.order": "Berufung Anordnungen",
|
||||
"deadlines.upc.apl.unified": "Berufung",
|
||||
"deadlines.appeal_target.label": "Worauf richtet sich die Berufung?",
|
||||
"deadlines.appeal_target.endentscheidung": "Endentscheidung",
|
||||
"deadlines.appeal_target.kostenentscheidung": "Kostenentscheidung",
|
||||
"deadlines.appeal_target.anordnung": "Anordnung",
|
||||
"deadlines.appeal_target.schadensbemessung": "Schadensbemessung",
|
||||
"deadlines.appeal_target.bucheinsicht": "Bucheinsicht",
|
||||
"deadlines.de.group.inf": "Verletzungsverfahren",
|
||||
"deadlines.de.group.null": "Nichtigkeitsverfahren",
|
||||
"deadlines.de.inf.lg": "LG (1. Instanz)",
|
||||
@@ -305,10 +312,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
"deadlines.view.columns": "Spalten",
|
||||
"deadlines.notes.show": "Hinweise anzeigen",
|
||||
"deadlines.durations.show": "Dauern anzeigen",
|
||||
"deadlines.col.ours": "Unsere Seite",
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
"deadlines.col.both": "Beide Parteien",
|
||||
"deadlines.col.proactive": "Proaktiv",
|
||||
"deadlines.col.reactive": "Reaktiv",
|
||||
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
|
||||
"choices.caret.title": "Optionen für dieses Ereignis",
|
||||
"choices.appellant.title": "Berufung durch …",
|
||||
@@ -453,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",
|
||||
@@ -1514,6 +1520,18 @@ 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": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Bausteine — Paliad",
|
||||
"admin.building_blocks.heading": "Bausteine",
|
||||
"admin.building_blocks.subtitle": "Wiederverwendbare Textbausteine für Composer-Abschnitte.",
|
||||
"admin.building_blocks.loading": "Lädt…",
|
||||
"admin.building_blocks.action.new": "+ Neuer Baustein",
|
||||
"admin.building_blocks.editor.empty": "Wählen Sie einen Baustein aus der Liste — oder erstellen Sie einen neuen.",
|
||||
// t-paliad-240 — global Schriftsätze drafts index page.
|
||||
"submissions.index.title": "Schriftsätze — Paliad",
|
||||
"submissions.index.heading": "Schriftsätze",
|
||||
@@ -2887,12 +2905,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
|
||||
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
||||
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
|
||||
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
|
||||
// (URL change is Slice B.6); the visible labels rename. Canonical
|
||||
// `admin.procedural_events.*` aliases live after the EN block — they
|
||||
// pin the contract for when .tsx files rebind in Slice B (B.5).
|
||||
// t-paliad-305 Slice B.6 (2026-05-26) — canonical URL moved to
|
||||
// `/admin/procedural-events` (301 redirects from /admin/rules*).
|
||||
// The i18n keys `admin.rules.*` are kept as the corpus until a
|
||||
// follow-up slice migrates each reference; canonical
|
||||
// `admin.procedural_events.*` aliases live after the EN block.
|
||||
"nav.admin.rules": "Verfahrensschritte verwalten",
|
||||
"nav.admin.rules_export": "Verfahrensschritt-Migrations",
|
||||
"admin.card.rules.title": "Verfahrensschritte verwalten",
|
||||
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
|
||||
|
||||
@@ -2900,7 +2918,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.list.heading": "Verfahrensschritte verwalten",
|
||||
"admin.rules.list.subtitle": "Verfahrensschritte (Schriftsätze, Anhörungen, Entscheidungen, …) anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ Neuer Verfahrensschritt",
|
||||
"admin.rules.list.export": "Migrations exportieren",
|
||||
"admin.rules.tab.rules": "Regeln",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Lade…",
|
||||
@@ -3062,23 +3079,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.modal.restore.title": "Wiederherstellen",
|
||||
"admin.rules.edit.modal.restore.body": "Regel wird wiederhergestellt (archived → published).",
|
||||
|
||||
"admin.rules.export.title": "Regel-Migrations exportieren — Paliad",
|
||||
"admin.rules.export.heading": "Regel-Migrations exportieren",
|
||||
"admin.rules.export.subtitle": "Generiert ein *.up.sql-Blob mit allen unsynchronisierten Audit-Veränderungen. Manuell in internal/db/migrations/ einchecken.",
|
||||
"admin.rules.export.breadcrumb": "← Regeln verwalten",
|
||||
"admin.rules.export.field.since": "Startend ab Audit-ID (optional)",
|
||||
"admin.rules.export.run": "Export generieren",
|
||||
"admin.rules.export.running": "Lade…",
|
||||
"admin.rules.export.download": "Als Datei herunterladen",
|
||||
"admin.rules.export.copy": "In Zwischenablage kopieren",
|
||||
"admin.rules.export.copied": "In Zwischenablage kopiert.",
|
||||
"admin.rules.export.copy_failed": "Kopieren fehlgeschlagen.",
|
||||
"admin.rules.export.count": "Audit-Zeilen: {n}",
|
||||
"admin.rules.export.latest": "Letzte Audit-ID: {id}",
|
||||
"admin.rules.export.ok": "{n} Audit-Zeilen exportiert.",
|
||||
"admin.rules.export.error": "Export fehlgeschlagen.",
|
||||
"admin.rules.export.no_pending": "Keine offenen Audit-Zeilen zum Export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). Symmetric past/future chip fan
|
||||
// around an ALLES centre. Used by the filter-bar 'time' axis from
|
||||
// Slice A onwards; future slices will migrate /agenda and
|
||||
@@ -3120,6 +3120,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.procedural_events.list.heading": "Verfahrensschritte verwalten",
|
||||
"admin.procedural_events.list.new": "+ Neuer Verfahrensschritt",
|
||||
"admin.procedural_events.col.code": "Code (Verfahrensschritt)",
|
||||
// t-paliad-321: 3-segment proceeding-type code column (joined
|
||||
// server-side); disambiguates same-named rules across proceedings.
|
||||
"admin.procedural_events.col.proceeding": "Verfahren",
|
||||
"admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad",
|
||||
"admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten",
|
||||
"admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)",
|
||||
@@ -3187,7 +3190,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}`,
|
||||
@@ -3344,6 +3347,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.dmgs.cfi": "Damages Determination",
|
||||
"deadlines.upc.disc.cfi": "Lay-open Books",
|
||||
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
|
||||
"deadlines.upc.apl.unified": "Appeal",
|
||||
"deadlines.appeal_target.label": "Appeal against:",
|
||||
"deadlines.appeal_target.endentscheidung": "Final Decision",
|
||||
"deadlines.appeal_target.kostenentscheidung": "Cost Decision",
|
||||
"deadlines.appeal_target.anordnung": "Order",
|
||||
"deadlines.appeal_target.schadensbemessung": "Damages Determination",
|
||||
"deadlines.appeal_target.bucheinsicht": "Lay-open Books",
|
||||
"deadlines.upc.apl.order": "Order Appeal (15-day)",
|
||||
"deadlines.de.group.inf": "Infringement proceedings",
|
||||
"deadlines.de.group.null": "Nullity proceedings",
|
||||
@@ -3413,10 +3423,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
"deadlines.view.columns": "Columns",
|
||||
"deadlines.notes.show": "Show details",
|
||||
"deadlines.durations.show": "Show durations",
|
||||
"deadlines.col.ours": "Client Side",
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
"deadlines.col.both": "Both parties",
|
||||
"deadlines.col.proactive": "Proactive",
|
||||
"deadlines.col.reactive": "Reactive",
|
||||
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
|
||||
"choices.caret.title": "Options for this event",
|
||||
"choices.appellant.title": "Appeal by …",
|
||||
@@ -3568,10 +3581,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",
|
||||
@@ -4603,6 +4612,18 @@ 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": "Edit per section — autosaves after 500ms. Final layout in Word.",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Building blocks — Paliad",
|
||||
"admin.building_blocks.heading": "Building blocks",
|
||||
"admin.building_blocks.subtitle": "Reusable text snippets for Composer sections.",
|
||||
"admin.building_blocks.loading": "Loading…",
|
||||
"admin.building_blocks.action.new": "+ New block",
|
||||
"admin.building_blocks.editor.empty": "Pick a block from the list — or create a new one.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
@@ -5966,7 +5987,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
||||
// t-paliad-262 Slice A — "Rule" relabelled as "Procedural event".
|
||||
"nav.admin.rules": "Manage procedural events",
|
||||
"nav.admin.rules_export": "Procedural-event migrations",
|
||||
"admin.card.rules.title": "Manage procedural events",
|
||||
"admin.card.rules.desc": "Author, edit and publish procedural-event templates. Audit log, preview, migration export.",
|
||||
|
||||
@@ -5974,7 +5994,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.list.heading": "Manage procedural events",
|
||||
"admin.rules.list.subtitle": "Author, edit and publish procedural events (filings, hearings, decisions, …). Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ New procedural event",
|
||||
"admin.rules.list.export": "Export migrations",
|
||||
"admin.rules.tab.rules": "Rules",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Loading…",
|
||||
@@ -6136,23 +6155,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.modal.restore.title": "Restore",
|
||||
"admin.rules.edit.modal.restore.body": "Rule will be restored (archived → published).",
|
||||
|
||||
"admin.rules.export.title": "Export rule migrations — Paliad",
|
||||
"admin.rules.export.heading": "Export rule migrations",
|
||||
"admin.rules.export.subtitle": "Generates a *.up.sql blob with every un-exported audit change. Commit manually into internal/db/migrations/.",
|
||||
"admin.rules.export.breadcrumb": "← Manage Rules",
|
||||
"admin.rules.export.field.since": "Starting from audit id (optional)",
|
||||
"admin.rules.export.run": "Generate export",
|
||||
"admin.rules.export.running": "Loading…",
|
||||
"admin.rules.export.download": "Download as file",
|
||||
"admin.rules.export.copy": "Copy to clipboard",
|
||||
"admin.rules.export.copied": "Copied to clipboard.",
|
||||
"admin.rules.export.copy_failed": "Copy failed.",
|
||||
"admin.rules.export.count": "Audit rows: {n}",
|
||||
"admin.rules.export.latest": "Latest audit id: {id}",
|
||||
"admin.rules.export.ok": "{n} audit rows exported.",
|
||||
"admin.rules.export.error": "Export failed.",
|
||||
"admin.rules.export.no_pending": "No pending audit rows to export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). See DE block above for details.
|
||||
"date_range.button.label": "Time range",
|
||||
"date_range.button.label.custom_range": "From {from} to {to}",
|
||||
@@ -6189,6 +6191,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.procedural_events.list.heading": "Manage procedural events",
|
||||
"admin.procedural_events.list.new": "+ New procedural event",
|
||||
"admin.procedural_events.col.code": "Code (procedural event)",
|
||||
// t-paliad-321: 3-segment proceeding-type code column.
|
||||
"admin.procedural_events.col.proceeding": "Proceeding",
|
||||
"admin.procedural_events.edit.title": "Edit procedural event — Paliad",
|
||||
"admin.procedural_events.edit.breadcrumb":"← Manage procedural events",
|
||||
"admin.procedural_events.edit.field.code": "Code (procedural-event identifier)",
|
||||
|
||||
@@ -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,869 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// sectionAutosaveTimers — one debounce timer per section id so two
|
||||
// sections autosaving simultaneously don't trample each other. Reset
|
||||
// on each keystroke; 500ms after the last keystroke the patch fires.
|
||||
const sectionAutosaveTimers: Record<string, number> = {};
|
||||
const SECTION_AUTOSAVE_MS = 500;
|
||||
|
||||
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 = "";
|
||||
|
||||
// Don't blow away the editor if a section is currently focused —
|
||||
// would steal cursor + selection mid-type. The patch round-trip
|
||||
// returns the updated row, but paintSectionList only re-renders
|
||||
// when the focused section isn't being edited (or the new render
|
||||
// is being driven by something other than the active editor itself).
|
||||
const activeID = activeSectionEditorID();
|
||||
|
||||
list.innerHTML = "";
|
||||
const lang = state.view.draft.language || state.view.lang || "de";
|
||||
for (const sec of sections) {
|
||||
list.appendChild(renderSectionRow(sec, lang, activeID === sec.id));
|
||||
}
|
||||
|
||||
// t-paliad-318 Slice F — "+ Abschnitt hinzufügen" trailing
|
||||
// affordance + "Reihenfolge speichern" affordance (only visible
|
||||
// after a manual reorder; surfaced by paintReorderControls when
|
||||
// pendingReorder is set).
|
||||
let trailer = document.getElementById("submission-draft-sections-trailer");
|
||||
if (!trailer) {
|
||||
trailer = document.createElement("div");
|
||||
trailer.id = "submission-draft-sections-trailer";
|
||||
trailer.className = "submission-draft-sections-trailer";
|
||||
wrap.appendChild(trailer);
|
||||
}
|
||||
trailer.innerHTML = "";
|
||||
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "btn-small btn-secondary";
|
||||
addBtn.textContent = isEN() ? "+ Add section" : "+ Abschnitt hinzufügen";
|
||||
addBtn.addEventListener("click", () => openAddSectionForm(trailer!));
|
||||
trailer.appendChild(addBtn);
|
||||
}
|
||||
|
||||
function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "submission-draft-section";
|
||||
li.dataset.sectionId = sec.id;
|
||||
if (!sec.included) li.classList.add("submission-draft-section--excluded");
|
||||
|
||||
// t-paliad-318 Slice F — drag-and-drop reorder. Native HTML5 DnD,
|
||||
// no external library. The drag handle is the only draggable
|
||||
// affordance so clicks inside the editor area don't accidentally
|
||||
// trigger a drag.
|
||||
li.draggable = false; // overridden via the handle below
|
||||
li.addEventListener("dragover", (ev) => onSectionDragOver(ev, li));
|
||||
li.addEventListener("drop", (ev) => onSectionDrop(ev, li));
|
||||
li.addEventListener("dragleave", () => li.classList.remove("submission-draft-section--drop-target"));
|
||||
|
||||
const head = document.createElement("header");
|
||||
head.className = "submission-draft-section-head";
|
||||
|
||||
// Drag handle — making just this element draggable scoped the
|
||||
// gesture so contentEditable selections still work.
|
||||
const handle = document.createElement("span");
|
||||
handle.className = "submission-draft-section-handle";
|
||||
handle.draggable = true;
|
||||
handle.title = isEN() ? "Drag to reorder" : "Zum Sortieren ziehen";
|
||||
handle.textContent = "⋮⋮";
|
||||
handle.addEventListener("dragstart", (ev) => onSectionDragStart(ev, sec.id));
|
||||
handle.addEventListener("dragend", () => onSectionDragEnd(li));
|
||||
head.appendChild(handle);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Per-section "Aufnehmen" / "Ausblenden" toggle in the head — flips
|
||||
// `included` via PATCH and re-paints.
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "btn-small btn-secondary submission-draft-section-toggle";
|
||||
toggle.textContent = sec.included
|
||||
? (isEN() ? "Hide" : "Ausblenden")
|
||||
: (isEN() ? "Include" : "Aufnehmen");
|
||||
toggle.addEventListener("click", () => onSectionToggleIncluded(sec));
|
||||
head.appendChild(toggle);
|
||||
|
||||
// t-paliad-318 Slice F — per-section delete. Removes the row.
|
||||
// Confirmation guard prevents accidental loss of typed prose.
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "btn-small btn-link-danger submission-draft-section-delete";
|
||||
del.textContent = isEN() ? "Delete" : "Entfernen";
|
||||
del.title = isEN() ? "Remove this section from the draft" : "Abschnitt aus dem Entwurf entfernen";
|
||||
del.addEventListener("click", () => onSectionDelete(sec));
|
||||
head.appendChild(del);
|
||||
|
||||
li.appendChild(head);
|
||||
|
||||
// Toolbar — Slice D rich-prose affordances: B/I + H1/H2/H3 +
|
||||
// bullet/numbered list + blockquote + hyperlink. Plus the Slice C
|
||||
// building-block button. execCommand drives bold/italic/headings/
|
||||
// lists/blockquote; hyperlink uses createLink with a prompt.
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "submission-draft-section-toolbar";
|
||||
toolbar.appendChild(makeToolbarButton("B", isEN() ? "Bold" : "Fett", "bold"));
|
||||
toolbar.appendChild(makeToolbarButton("I", isEN() ? "Italic" : "Kursiv", "italic"));
|
||||
toolbar.appendChild(makeHeadingButton("H1", isEN() ? "Heading 1" : "Überschrift 1", 1));
|
||||
toolbar.appendChild(makeHeadingButton("H2", isEN() ? "Heading 2" : "Überschrift 2", 2));
|
||||
toolbar.appendChild(makeHeadingButton("H3", isEN() ? "Heading 3" : "Überschrift 3", 3));
|
||||
toolbar.appendChild(makeToolbarButton("•", isEN() ? "Bullet list" : "Aufzählung", "insertUnorderedList"));
|
||||
toolbar.appendChild(makeToolbarButton("1.", isEN() ? "Numbered list" : "Nummerierte Liste", "insertOrderedList"));
|
||||
toolbar.appendChild(makeToolbarButton("”", isEN() ? "Blockquote" : "Zitat", "formatBlock", "blockquote"));
|
||||
toolbar.appendChild(makeLinkButton());
|
||||
// t-paliad-315 Slice C — building-block insert button. Opens a
|
||||
// picker modal filtered to this section's section_key. Paste is
|
||||
// plain-text per Q2 (no lineage stamped).
|
||||
const bbBtn = document.createElement("button");
|
||||
bbBtn.type = "button";
|
||||
bbBtn.className = "btn-small btn-secondary submission-draft-section-bb-btn";
|
||||
bbBtn.textContent = isEN() ? "+ Block" : "+ Baustein";
|
||||
bbBtn.title = isEN() ? "Insert a saved building block" : "Baustein einfügen";
|
||||
bbBtn.addEventListener("click", () => openBlockPicker(sec));
|
||||
toolbar.appendChild(bbBtn);
|
||||
li.appendChild(toolbar);
|
||||
|
||||
const md = (lang === "en" ? sec.content_md_en : sec.content_md_de) || "";
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "submission-draft-section-editor";
|
||||
editor.contentEditable = "true";
|
||||
editor.spellcheck = true;
|
||||
editor.dataset.sectionId = sec.id;
|
||||
editor.dataset.lang = lang;
|
||||
editor.dataset.placeholder = isEN()
|
||||
? "Write section content…"
|
||||
: "Abschnittstext eingeben…";
|
||||
// Paint the Markdown as plain text on first render — the editor's
|
||||
// source of truth is Markdown, the DOM is the view. Lawyer types,
|
||||
// we serialise back to MD on autosave.
|
||||
editor.textContent = md;
|
||||
|
||||
editor.addEventListener("input", () => onSectionInput(editor));
|
||||
editor.addEventListener("focus", () => {
|
||||
li.classList.add("submission-draft-section--editing");
|
||||
});
|
||||
editor.addEventListener("blur", () => {
|
||||
li.classList.remove("submission-draft-section--editing");
|
||||
// Force-flush any pending autosave so we don't leave unsynced
|
||||
// edits hanging when the lawyer tabs out.
|
||||
flushSectionAutosave(sec.id);
|
||||
});
|
||||
|
||||
li.appendChild(editor);
|
||||
|
||||
if (isActive) {
|
||||
// The repaint happened while this section was focused — restore
|
||||
// focus to it. Cursor placement at the end is a fair default
|
||||
// (typing mid-content during a repaint is rare; the autosave path
|
||||
// typically doesn't repaint at all).
|
||||
queueMicrotask(() => {
|
||||
const fresh = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sec.id)}"]`) as HTMLDivElement | null;
|
||||
if (fresh) {
|
||||
fresh.focus();
|
||||
placeCaretAtEnd(fresh);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function makeToolbarButton(label: string, title: string, format: string, value?: string): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "submission-draft-section-toolbar-btn";
|
||||
btn.textContent = label;
|
||||
btn.title = title;
|
||||
// Mousedown rather than click so the editor doesn't lose focus
|
||||
// mid-command — execCommand requires the editor to be the active
|
||||
// selection target.
|
||||
btn.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
document.execCommand(format, false, value);
|
||||
// Trigger the input handler so autosave fires.
|
||||
const editor = document.activeElement as HTMLElement | null;
|
||||
if (editor && editor.classList.contains("submission-draft-section-editor")) {
|
||||
onSectionInput(editor as HTMLDivElement);
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
// makeHeadingButton emits an `<h1|h2|h3>` wrapping for the active
|
||||
// block via execCommand("formatBlock", "h1") etc. Toggling the same
|
||||
// heading back to a paragraph is handled by clicking the same button
|
||||
// again (the browser's execCommand semantics).
|
||||
function makeHeadingButton(label: string, title: string, level: 1 | 2 | 3): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "submission-draft-section-toolbar-btn";
|
||||
btn.textContent = label;
|
||||
btn.title = title;
|
||||
btn.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
document.execCommand("formatBlock", false, "h" + level);
|
||||
const editor = document.activeElement as HTMLElement | null;
|
||||
if (editor && editor.classList.contains("submission-draft-section-editor")) {
|
||||
onSectionInput(editor as HTMLDivElement);
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
// makeLinkButton prompts for a URL and wraps the current selection
|
||||
// (or inserts a label-as-URL if nothing selected). The browser's
|
||||
// createLink built-in wires the <a href="…"> tag into the DOM;
|
||||
// domToMarkdown reads it back as `[label](url)`.
|
||||
function makeLinkButton(): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "submission-draft-section-toolbar-btn";
|
||||
btn.textContent = "🔗";
|
||||
btn.title = isEN() ? "Insert link" : "Link einfügen";
|
||||
btn.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
const url = prompt(isEN() ? "URL:" : "URL:");
|
||||
if (!url) return;
|
||||
document.execCommand("createLink", false, url);
|
||||
const editor = document.activeElement as HTMLElement | null;
|
||||
if (editor && editor.classList.contains("submission-draft-section-editor")) {
|
||||
onSectionInput(editor as HTMLDivElement);
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
function activeSectionEditorID(): string | null {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active || !active.classList.contains("submission-draft-section-editor")) return null;
|
||||
return active.dataset.sectionId ?? null;
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(el: HTMLElement): void {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
function onSectionInput(editor: HTMLDivElement): void {
|
||||
const id = editor.dataset.sectionId;
|
||||
if (!id) return;
|
||||
if (sectionAutosaveTimers[id]) clearTimeout(sectionAutosaveTimers[id]);
|
||||
sectionAutosaveTimers[id] = window.setTimeout(() => {
|
||||
sectionAutosaveTimers[id] = 0;
|
||||
flushSectionAutosave(id);
|
||||
}, SECTION_AUTOSAVE_MS);
|
||||
}
|
||||
|
||||
function flushSectionAutosave(sectionID: string): void {
|
||||
if (sectionAutosaveTimers[sectionID]) {
|
||||
clearTimeout(sectionAutosaveTimers[sectionID]);
|
||||
sectionAutosaveTimers[sectionID] = 0;
|
||||
}
|
||||
const editor = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sectionID)}"]`) as HTMLDivElement | null;
|
||||
if (!editor || !state.view) return;
|
||||
const lang = editor.dataset.lang || state.view.draft.language || "de";
|
||||
const md = domToMarkdown(editor);
|
||||
void patchSection(sectionID, lang === "en" ? { content_md_en: md } : { content_md_de: md });
|
||||
}
|
||||
|
||||
// domToMarkdown serialises a contentEditable's DOM tree back to
|
||||
// Markdown. Walks the tree: <b>/<strong> emit `**…**`, <i>/<em> emit
|
||||
// `*…*`, <br> emits a newline, block-level elements emit a blank line
|
||||
// between siblings. Slice B handles only B/I + paragraphs/line breaks
|
||||
// — Slice D's rich toolbar extends this to headings + lists + quote.
|
||||
function domToMarkdown(root: HTMLElement): string {
|
||||
return serializeNode(root).trim();
|
||||
}
|
||||
|
||||
function serializeNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent ?? "";
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
// Lists: handle the wrapper before recursing into items so we can
|
||||
// emit the right per-item Markdown prefix.
|
||||
if (tag === "ul" || tag === "ol") {
|
||||
const items: string[] = [];
|
||||
let counter = 1;
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === "li") {
|
||||
const liInner = serializeNode(child).replace(/\n+$/g, "");
|
||||
const prefix = tag === "ol" ? `${counter}. ` : "- ";
|
||||
items.push(prefix + liInner);
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
return items.join("\n") + "\n\n";
|
||||
}
|
||||
|
||||
let inner = "";
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
inner += serializeNode(child);
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case "b":
|
||||
case "strong":
|
||||
return inner ? `**${inner}**` : "";
|
||||
case "i":
|
||||
case "em":
|
||||
return inner ? `*${inner}*` : "";
|
||||
case "br":
|
||||
return "\n";
|
||||
case "div":
|
||||
case "p":
|
||||
// execCommand and contentEditable insert <div> on Enter in some
|
||||
// browsers, <p> in others. Both are paragraph boundaries.
|
||||
return inner + "\n\n";
|
||||
case "h1":
|
||||
return "# " + inner.replace(/\n+$/g, "") + "\n\n";
|
||||
case "h2":
|
||||
return "## " + inner.replace(/\n+$/g, "") + "\n\n";
|
||||
case "h3":
|
||||
return "### " + inner.replace(/\n+$/g, "") + "\n\n";
|
||||
case "blockquote":
|
||||
// Each line inside the blockquote gets its own "> " prefix per
|
||||
// Markdown convention.
|
||||
return inner.split("\n").map(line => line === "" ? "" : "> " + line).join("\n").replace(/\n+$/g, "") + "\n\n";
|
||||
case "li":
|
||||
// <li> rendered standalone (no <ul>/<ol> ancestor) — emit
|
||||
// bullet by default. The ul/ol branch above handles the
|
||||
// ordered/unordered choice when present.
|
||||
return "- " + inner.replace(/\n+$/g, "") + "\n";
|
||||
case "a": {
|
||||
const href = el.getAttribute("href") ?? "";
|
||||
if (!href || !inner) return inner;
|
||||
return `[${inner}](${href})`;
|
||||
}
|
||||
default:
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSectionToggleIncluded(sec: SubmissionSectionJSON): Promise<void> {
|
||||
await patchSection(sec.id, { included: !sec.included });
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// t-paliad-318 Slice F — reorder / delete / add
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let dragSourceID: string | null = null;
|
||||
|
||||
function onSectionDragStart(ev: DragEvent, sectionID: string): void {
|
||||
if (!ev.dataTransfer) return;
|
||||
dragSourceID = sectionID;
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
ev.dataTransfer.setData("text/plain", sectionID);
|
||||
const parentLi = (ev.target as HTMLElement).closest("li");
|
||||
if (parentLi) parentLi.classList.add("submission-draft-section--dragging");
|
||||
}
|
||||
|
||||
function onSectionDragOver(ev: DragEvent, li: HTMLLIElement): void {
|
||||
ev.preventDefault();
|
||||
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
|
||||
if (dragSourceID && dragSourceID !== li.dataset.sectionId) {
|
||||
li.classList.add("submission-draft-section--drop-target");
|
||||
}
|
||||
}
|
||||
|
||||
function onSectionDragEnd(li: HTMLLIElement): void {
|
||||
li.classList.remove("submission-draft-section--dragging");
|
||||
document.querySelectorAll(".submission-draft-section--drop-target").forEach((el) => {
|
||||
el.classList.remove("submission-draft-section--drop-target");
|
||||
});
|
||||
dragSourceID = null;
|
||||
}
|
||||
|
||||
async function onSectionDrop(ev: DragEvent, targetLi: HTMLLIElement): Promise<void> {
|
||||
ev.preventDefault();
|
||||
targetLi.classList.remove("submission-draft-section--drop-target");
|
||||
const sourceID = dragSourceID;
|
||||
dragSourceID = null;
|
||||
document.querySelectorAll(".submission-draft-section--dragging").forEach((el) => {
|
||||
el.classList.remove("submission-draft-section--dragging");
|
||||
});
|
||||
if (!sourceID || !state.view?.sections) return;
|
||||
const targetID = targetLi.dataset.sectionId;
|
||||
if (!targetID || sourceID === targetID) return;
|
||||
|
||||
const ids = state.view.sections.map(s => s.id);
|
||||
const fromIdx = ids.indexOf(sourceID);
|
||||
const toIdx = ids.indexOf(targetID);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
|
||||
// Splice source out, insert at target position. "Drop on row X"
|
||||
// semantics: source lands JUST BEFORE the target row.
|
||||
ids.splice(fromIdx, 1);
|
||||
const insertAt = ids.indexOf(targetID);
|
||||
ids.splice(insertAt, 0, sourceID);
|
||||
|
||||
await reorderSections(ids);
|
||||
}
|
||||
|
||||
async function reorderSections(ids: string[]): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const draftID = state.view.draft.id;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${draftID}/sections/reorder`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ section_order: ids }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("reorder failed", res.status);
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { sections?: SubmissionSectionJSON[] };
|
||||
if (state.view && body.sections) state.view.sections = body.sections;
|
||||
paintSectionList();
|
||||
} catch (err) {
|
||||
console.warn("reorder error", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSectionDelete(sec: SubmissionSectionJSON): Promise<void> {
|
||||
const label = isEN() ? sec.label_en : sec.label_de;
|
||||
const confirmMsg = isEN()
|
||||
? `Delete section "${label}"? This cannot be undone.`
|
||||
: `Abschnitt "${label}" entfernen? Diese Aktion kann nicht rückgängig gemacht werden.`;
|
||||
if (!confirm(confirmMsg)) return;
|
||||
if (!state.view) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${state.view.draft.id}/sections/${sec.id}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
);
|
||||
if (!res.ok && res.status !== 204) {
|
||||
console.warn("delete section failed", res.status);
|
||||
return;
|
||||
}
|
||||
if (state.view.sections) {
|
||||
state.view.sections = state.view.sections.filter(s => s.id !== sec.id);
|
||||
}
|
||||
paintSectionList();
|
||||
} catch (err) {
|
||||
console.warn("delete section error", err);
|
||||
}
|
||||
}
|
||||
|
||||
function openAddSectionForm(host: HTMLElement): void {
|
||||
// If already open, close (toggle).
|
||||
const existing = host.querySelector(".submission-draft-add-section");
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
return;
|
||||
}
|
||||
const form = document.createElement("form");
|
||||
form.className = "submission-draft-add-section";
|
||||
form.addEventListener("submit", (ev) => { ev.preventDefault(); void submitAddSection(form); });
|
||||
|
||||
const fields = [
|
||||
{ name: "section_key", label: isEN() ? "Slug" : "Slug", required: true, placeholder: "berufungsantraege" },
|
||||
{ name: "label_de", label: "Label (DE)", required: true, placeholder: "Berufungsanträge" },
|
||||
{ name: "label_en", label: "Label (EN)", required: true, placeholder: "Appeal requests" },
|
||||
];
|
||||
for (const f of fields) {
|
||||
const row = document.createElement("label");
|
||||
row.className = "submission-draft-add-section-row";
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = f.label + (f.required ? " *" : "");
|
||||
row.appendChild(lab);
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "text";
|
||||
inp.name = f.name;
|
||||
inp.className = "entity-form-input";
|
||||
inp.required = f.required;
|
||||
inp.placeholder = f.placeholder;
|
||||
row.appendChild(inp);
|
||||
form.appendChild(row);
|
||||
}
|
||||
|
||||
const kindRow = document.createElement("label");
|
||||
kindRow.className = "submission-draft-add-section-row";
|
||||
const kindLab = document.createElement("span");
|
||||
kindLab.textContent = isEN() ? "Kind" : "Typ";
|
||||
kindRow.appendChild(kindLab);
|
||||
const kindSel = document.createElement("select");
|
||||
kindSel.name = "kind";
|
||||
kindSel.className = "entity-form-input";
|
||||
for (const opt of ["prose", "requests", "evidence"]) {
|
||||
const o = document.createElement("option");
|
||||
o.value = opt;
|
||||
o.textContent = opt;
|
||||
kindSel.appendChild(o);
|
||||
}
|
||||
kindRow.appendChild(kindSel);
|
||||
form.appendChild(kindRow);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "submission-draft-add-section-actions";
|
||||
const ok = document.createElement("button");
|
||||
ok.type = "submit";
|
||||
ok.className = "btn-small btn-primary btn-cta-lime";
|
||||
ok.textContent = isEN() ? "Add" : "Hinzufügen";
|
||||
actions.appendChild(ok);
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "btn-small btn-secondary";
|
||||
cancel.textContent = isEN() ? "Cancel" : "Abbrechen";
|
||||
cancel.addEventListener("click", () => form.remove());
|
||||
actions.appendChild(cancel);
|
||||
form.appendChild(actions);
|
||||
|
||||
host.appendChild(form);
|
||||
setTimeout(() => (form.querySelector('input[name="section_key"]') as HTMLInputElement | null)?.focus(), 0);
|
||||
}
|
||||
|
||||
async function submitAddSection(form: HTMLFormElement): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const data = new FormData(form);
|
||||
const payload = {
|
||||
section_key: String(data.get("section_key") ?? "").trim(),
|
||||
kind: String(data.get("kind") ?? "prose"),
|
||||
label_de: String(data.get("label_de") ?? "").trim(),
|
||||
label_en: String(data.get("label_en") ?? "").trim(),
|
||||
};
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${state.view.draft.id}/sections`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({} as { error?: string }));
|
||||
alert(body.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const created = await res.json() as SubmissionSectionJSON;
|
||||
if (state.view.sections) state.view.sections.push(created);
|
||||
form.remove();
|
||||
paintSectionList();
|
||||
} catch (err) {
|
||||
alert(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// t-paliad-315 Slice C — building-block picker modal
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BuildingBlockPickJSON {
|
||||
id: string;
|
||||
slug: string;
|
||||
section_key: string;
|
||||
proceeding_family?: string | null;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
description_de?: string | null;
|
||||
description_en?: string | null;
|
||||
content_md_de: string;
|
||||
content_md_en: string;
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
let blockPickerSearchTimer: number | null = null;
|
||||
|
||||
function openBlockPicker(sec: SubmissionSectionJSON): void {
|
||||
// Remove any prior picker.
|
||||
document.getElementById("submission-bb-picker")?.remove();
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "submission-bb-picker";
|
||||
overlay.className = "submission-bb-picker-overlay";
|
||||
overlay.addEventListener("click", (ev) => {
|
||||
if (ev.target === overlay) overlay.remove();
|
||||
});
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "submission-bb-picker";
|
||||
|
||||
const head = document.createElement("header");
|
||||
head.className = "submission-bb-picker-head";
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = isEN() ? "Insert building block" : "Baustein einfügen";
|
||||
head.appendChild(title);
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "btn-small btn-secondary";
|
||||
close.textContent = isEN() ? "Close" : "Schließen";
|
||||
close.addEventListener("click", () => overlay.remove());
|
||||
head.appendChild(close);
|
||||
modal.appendChild(head);
|
||||
|
||||
const search = document.createElement("input");
|
||||
search.type = "search";
|
||||
search.placeholder = isEN() ? "Search blocks…" : "Bausteine suchen…";
|
||||
search.className = "entity-form-input submission-bb-picker-search";
|
||||
modal.appendChild(search);
|
||||
|
||||
const sectionInfo = document.createElement("p");
|
||||
sectionInfo.className = "submission-bb-picker-sectioninfo";
|
||||
sectionInfo.textContent = (isEN() ? "Section: " : "Abschnitt: ") + sec.section_key;
|
||||
modal.appendChild(sectionInfo);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "submission-bb-picker-list";
|
||||
list.textContent = isEN() ? "Loading…" : "Lädt…";
|
||||
modal.appendChild(list);
|
||||
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const fetchBlocks = async (q: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("section_key", sec.section_key);
|
||||
if (q) params.set("q", q);
|
||||
try {
|
||||
const res = await fetch(`/api/submission-building-blocks?${params.toString()}`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
list.textContent = `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { blocks?: BuildingBlockPickJSON[] };
|
||||
paintPickerList(list, body.blocks ?? [], sec, overlay);
|
||||
} catch (err) {
|
||||
list.textContent = String(err);
|
||||
}
|
||||
};
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
if (blockPickerSearchTimer) clearTimeout(blockPickerSearchTimer);
|
||||
blockPickerSearchTimer = window.setTimeout(() => {
|
||||
void fetchBlocks(search.value.trim());
|
||||
}, 200);
|
||||
});
|
||||
|
||||
void fetchBlocks("");
|
||||
setTimeout(() => search.focus(), 0);
|
||||
}
|
||||
|
||||
function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec: SubmissionSectionJSON, overlay: HTMLElement): void {
|
||||
host.innerHTML = "";
|
||||
if (blocks.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "submission-bb-picker-empty";
|
||||
empty.textContent = isEN() ? "No blocks match." : "Keine passenden Bausteine.";
|
||||
host.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const lang = state.view?.draft.language || "de";
|
||||
for (const b of blocks) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "submission-bb-picker-row";
|
||||
const title = (lang === "en" ? b.title_en : b.title_de) || b.slug;
|
||||
const desc = (lang === "en" ? b.description_en : b.description_de) || "";
|
||||
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
|
||||
row.innerHTML = `
|
||||
<div class="submission-bb-picker-row-head">
|
||||
<strong>${escapeHTML(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||||
</div>
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
row.addEventListener("click", () => {
|
||||
void insertBlockIntoSection(b.id, sec.id, overlay);
|
||||
});
|
||||
host.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function insertBlockIntoSection(blockID: string, sectionID: string, overlay: HTMLElement): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-building-blocks/${blockID}/insert-into/${sectionID}`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("insert-into PATCH failed", res.status);
|
||||
return;
|
||||
}
|
||||
const updated = await res.json() as SubmissionSectionJSON;
|
||||
if (state.view && state.view.sections) {
|
||||
const idx = state.view.sections.findIndex(s => s.id === sectionID);
|
||||
if (idx >= 0) state.view.sections[idx] = updated;
|
||||
}
|
||||
paintSectionList();
|
||||
overlay.remove();
|
||||
} catch (err) {
|
||||
console.warn("insert block error", err);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
const draftID = state.view?.draft.id;
|
||||
if (!draftID) return;
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${draftID}/sections/${sectionID}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("section PATCH failed", res.status, sectionID);
|
||||
return;
|
||||
}
|
||||
const updated = await res.json() as SubmissionSectionJSON;
|
||||
// Splice the updated row into state.view.sections. Don't re-paint
|
||||
// unless we need to (avoid focus stealing during active typing).
|
||||
if (state.view && state.view.sections) {
|
||||
const idx = state.view.sections.findIndex(s => s.id === sectionID);
|
||||
if (idx >= 0) state.view.sections[idx] = updated;
|
||||
}
|
||||
// Only repaint when the change has visible UI knock-on (toggle,
|
||||
// label, order). content_md_* changes don't need a repaint —
|
||||
// the editor already shows the lawyer's keystrokes.
|
||||
if ("included" in payload || "label_de" in payload || "label_en" in payload || "order_index" in payload) {
|
||||
paintSectionList();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("section PATCH error", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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,21 +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.merits",
|
||||
"upc.apl.cost",
|
||||
"upc.apl.order",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
@@ -75,34 +93,127 @@ 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",
|
||||
]);
|
||||
|
||||
function hasAppealTarget(proceedingType: string): boolean {
|
||||
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
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 makeScenarioStorage(): StorageLike {
|
||||
try {
|
||||
const probe = "__paliad_va_probe__";
|
||||
window.localStorage.setItem(probe, "1");
|
||||
window.localStorage.removeItem(probe);
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return makeMemoryStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function readAppellantFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("appellant");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
// 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 (s === null) url.searchParams.delete("side");
|
||||
else url.searchParams.set("side", s);
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
// 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
|
||||
// is the most-common appeal target; the chip group also defaults
|
||||
// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in
|
||||
// sync so the URL-less default render hits the same code path.
|
||||
let currentAppealTarget: AppealTarget = "";
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
@@ -113,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";
|
||||
@@ -178,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 /
|
||||
@@ -268,6 +358,13 @@ async function doCalc() {
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1): for the unified upc.apl Berufung,
|
||||
// default to "endentscheidung" when no chip pick is stored in URL.
|
||||
// For non-appeal proceedings the engine ignores opts.AppealTarget.
|
||||
const appealTarget = hasAppealTarget(selectedType)
|
||||
? (currentAppealTarget || "endentscheidung")
|
||||
: "";
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
@@ -276,6 +373,7 @@ async function doCalc() {
|
||||
courtId,
|
||||
perCardChoices,
|
||||
includeHidden: showHidden,
|
||||
appealTarget,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
@@ -376,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";
|
||||
@@ -429,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 || "";
|
||||
@@ -439,35 +549,92 @@ 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");
|
||||
// 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.
|
||||
// syncAppealTargetRowVisibility shows the appeal-target chip group
|
||||
// when the unified upc.apl Berufung tile is selected, hides it
|
||||
// otherwise. Mirrors syncAppellantRowVisibility's pattern: clears
|
||||
// state + URL when hiding so a stale ?target= can't leak.
|
||||
function syncAppealTargetRowVisibility() {
|
||||
const row = document.getElementById("appeal-target-row");
|
||||
if (!row) return;
|
||||
const visible = hasAppellantAxis(selectedType);
|
||||
const visible = hasAppealTarget(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppellant !== null) {
|
||||
currentAppellant = null;
|
||||
writeAppellantToURL(null);
|
||||
syncRadioGroup("appellant", "");
|
||||
if (!visible && currentAppealTarget !== "") {
|
||||
currentAppealTarget = "";
|
||||
applyURLFilters({ target: "" });
|
||||
syncRadioGroup("appeal-target", "endentscheidung");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,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);
|
||||
@@ -653,10 +820,10 @@ 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();
|
||||
currentSide = parseSideFromSearch(window.location.search);
|
||||
currentAppealTarget = parseAppealTargetFromSearch(window.location.search);
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
|
||||
syncSideHintVisibility();
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
@@ -664,19 +831,26 @@ 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) => {
|
||||
// Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler.
|
||||
// Each chip change re-fetches with the new target slug so the
|
||||
// timeline re-renders against the matching rule subset.
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appeal-target]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeAppellantToURL(currentAppellant);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(v)) {
|
||||
currentAppealTarget = v as AppealTarget;
|
||||
} else {
|
||||
currentAppealTarget = "";
|
||||
}
|
||||
applyURLFilters({ target: currentAppealTarget });
|
||||
scheduleCalc(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -697,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());
|
||||
|
||||
@@ -749,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);
|
||||
});
|
||||
}
|
||||
@@ -766,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({
|
||||
@@ -781,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);
|
||||
},
|
||||
});
|
||||
@@ -824,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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
formatDurationLabel,
|
||||
renderColumnsBody,
|
||||
stripLeadingDurationFromNotes,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
@@ -325,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([
|
||||
@@ -392,4 +419,357 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
["Decision"],
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// m's correction in m/paliad#127 (t-paliad-295) reverted half of #88's
|
||||
// header refresh: the user-perspective labels "Unsere Seite"/"Gegnerseite"
|
||||
// only make sense once the user has picked a side. While the side is
|
||||
// still "Nicht festgelegt" (side === null — the default after #120) the
|
||||
// header falls back to the semantic-neutral "Proaktiv"/"Reaktiv" labels.
|
||||
// Picking a side re-enables the #88 labels. The bucketing primitive
|
||||
// itself is unchanged — only the column-header text differs.
|
||||
describe("renderColumnsBody — side-aware column header labels (m/paliad#127)", () => {
|
||||
const dlFix = (party: string, name: string, due: string): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party,
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
});
|
||||
const data: DeadlineResponse = {
|
||||
proceedingType: "upc.inf.cfi",
|
||||
proceedingName: "UPC Verletzungsverfahren",
|
||||
triggerDate: "2026-01-01",
|
||||
deadlines: [
|
||||
dlFix("claimant", "Klageschrift", "2026-01-01"),
|
||||
dlFix("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
],
|
||||
};
|
||||
|
||||
test("side=null renders Proaktiv/Gericht/Reaktiv headers", () => {
|
||||
const html = renderColumnsBody(data, { side: null });
|
||||
expect(html).toContain(">Proaktiv<");
|
||||
expect(html).toContain(">Gericht<");
|
||||
expect(html).toContain(">Reaktiv<");
|
||||
expect(html).not.toContain(">Unsere Seite<");
|
||||
expect(html).not.toContain(">Gegnerseite<");
|
||||
});
|
||||
|
||||
test("side=null when opts omitted (default) still renders Proaktiv/Reaktiv", () => {
|
||||
const html = renderColumnsBody(data);
|
||||
expect(html).toContain(">Proaktiv<");
|
||||
expect(html).toContain(">Reaktiv<");
|
||||
});
|
||||
|
||||
test("side=claimant renders Unsere Seite/Gericht/Gegnerseite headers", () => {
|
||||
const html = renderColumnsBody(data, { side: "claimant" });
|
||||
expect(html).toContain(">Unsere Seite<");
|
||||
expect(html).toContain(">Gericht<");
|
||||
expect(html).toContain(">Gegnerseite<");
|
||||
expect(html).not.toContain(">Proaktiv<");
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
|
||||
test("side=defendant renders Unsere Seite/Gegnerseite headers (column swap is bucketing, not labels)", () => {
|
||||
// The user-perspective labels are picked once a side is set; the
|
||||
// bucketer still routes defendant filings into the `ours` column when
|
||||
// side=defendant, so the left column's header truthfully reads
|
||||
// "Unsere Seite" regardless of which underlying party occupies it.
|
||||
const html = renderColumnsBody(data, { side: "defendant" });
|
||||
expect(html).toContain(">Unsere Seite<");
|
||||
expect(html).toContain(">Gegnerseite<");
|
||||
expect(html).not.toContain(">Proaktiv<");
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -195,6 +300,12 @@ export interface CalcParams {
|
||||
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
|
||||
// ON.
|
||||
includeHidden?: boolean;
|
||||
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung
|
||||
// (upc.apl) timeline to the rule subset whose applies_to_target
|
||||
// contains the requested slug. Empty = no filter. Valid values:
|
||||
// endentscheidung | kostenentscheidung | anordnung |
|
||||
// schadensbemessung | bucheinsicht.
|
||||
appealTarget?: string;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -315,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
|
||||
@@ -419,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>`
|
||||
@@ -428,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>`
|
||||
@@ -539,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 = [
|
||||
@@ -561,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>
|
||||
`;
|
||||
@@ -608,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).
|
||||
@@ -617,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
|
||||
@@ -632,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
|
||||
@@ -666,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);
|
||||
@@ -688,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);
|
||||
}
|
||||
@@ -715,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) {
|
||||
@@ -756,14 +1019,29 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
// Column-header labels have two modes (m/paliad#127):
|
||||
// - side picked → "Unsere Seite" / "Gegnerseite" (the columns
|
||||
// truthfully describe whose filings sit there,
|
||||
// because the bucketer routed the user's side into
|
||||
// `ours`).
|
||||
// - side === null → "Proaktiv" / "Reaktiv" (semantic-neutral). The
|
||||
// user-perspective labels would lie here: we don't
|
||||
// know yet which party is "us", so calling the left
|
||||
// column "Unsere Seite" presumes a pick the user
|
||||
// hasn't made. The neutral Proaktiv/Reaktiv pair
|
||||
// keeps the spatial axis ("who initiates vs who
|
||||
// responds") legible while the hint chip on the
|
||||
// page nudges the user to pick a side.
|
||||
//
|
||||
// Note: the COLUMN PROJECTION does not change — the bucketing primitive
|
||||
// still routes claimant→left, defendant→right when side=null (legacy
|
||||
// claimant-on-the-left fallback). Only the HEADER label changes.
|
||||
const leftLabel = userSide === null ? t("deadlines.col.proactive") : t("deadlines.col.ours");
|
||||
const rightLabel = userSide === null ? t("deadlines.col.reactive") : t("deadlines.col.opponent");
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(leftLabel, "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
html += headerCell(rightLabel, "fr-col-opponent");
|
||||
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.ours);
|
||||
@@ -796,6 +1074,7 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
? params.perCardChoices
|
||||
: undefined,
|
||||
includeHidden: params.includeHidden ? true : undefined,
|
||||
appealTarget: params.appealTarget || undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
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>
|
||||
|
||||
@@ -204,8 +204,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||
{navItem("/admin/procedural-events", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
|
||||
@@ -125,6 +125,12 @@ export type I18nKey =
|
||||
| "admin.broadcasts.loading"
|
||||
| "admin.broadcasts.subtitle"
|
||||
| "admin.broadcasts.title"
|
||||
| "admin.building_blocks.action.new"
|
||||
| "admin.building_blocks.editor.empty"
|
||||
| "admin.building_blocks.heading"
|
||||
| "admin.building_blocks.loading"
|
||||
| "admin.building_blocks.subtitle"
|
||||
| "admin.building_blocks.title"
|
||||
| "admin.card.approval_policies.desc"
|
||||
| "admin.card.approval_policies.title"
|
||||
| "admin.card.audit.desc"
|
||||
@@ -291,6 +297,7 @@ export type I18nKey =
|
||||
| "admin.partner_units.subtitle"
|
||||
| "admin.partner_units.title"
|
||||
| "admin.procedural_events.col.code"
|
||||
| "admin.procedural_events.col.proceeding"
|
||||
| "admin.procedural_events.edit.breadcrumb"
|
||||
| "admin.procedural_events.edit.field.code"
|
||||
| "admin.procedural_events.edit.field.event_kind"
|
||||
@@ -401,22 +408,6 @@ export type I18nKey =
|
||||
| "admin.rules.edit.title"
|
||||
| "admin.rules.empty"
|
||||
| "admin.rules.error.load"
|
||||
| "admin.rules.export.breadcrumb"
|
||||
| "admin.rules.export.copied"
|
||||
| "admin.rules.export.copy"
|
||||
| "admin.rules.export.copy_failed"
|
||||
| "admin.rules.export.count"
|
||||
| "admin.rules.export.download"
|
||||
| "admin.rules.export.error"
|
||||
| "admin.rules.export.field.since"
|
||||
| "admin.rules.export.heading"
|
||||
| "admin.rules.export.latest"
|
||||
| "admin.rules.export.no_pending"
|
||||
| "admin.rules.export.ok"
|
||||
| "admin.rules.export.run"
|
||||
| "admin.rules.export.running"
|
||||
| "admin.rules.export.subtitle"
|
||||
| "admin.rules.export.title"
|
||||
| "admin.rules.filter.lifecycle"
|
||||
| "admin.rules.filter.lifecycle.any"
|
||||
| "admin.rules.filter.proceeding"
|
||||
@@ -428,7 +419,6 @@ export type I18nKey =
|
||||
| "admin.rules.lifecycle.archived"
|
||||
| "admin.rules.lifecycle.draft"
|
||||
| "admin.rules.lifecycle.published"
|
||||
| "admin.rules.list.export"
|
||||
| "admin.rules.list.heading"
|
||||
| "admin.rules.list.new"
|
||||
| "admin.rules.list.subtitle"
|
||||
@@ -1201,10 +1191,12 @@ export type I18nKey =
|
||||
| "deadlines.adjusted.weekend"
|
||||
| "deadlines.adjusted.weekend.saturday"
|
||||
| "deadlines.adjusted.weekend.sunday"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
| "deadlines.appellant.none"
|
||||
| "deadlines.appeal_target.anordnung"
|
||||
| "deadlines.appeal_target.bucheinsicht"
|
||||
| "deadlines.appeal_target.endentscheidung"
|
||||
| "deadlines.appeal_target.kostenentscheidung"
|
||||
| "deadlines.appeal_target.label"
|
||||
| "deadlines.appeal_target.schadensbemessung"
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.card.calc.add_to_project"
|
||||
| "deadlines.card.calc.add_to_project.disabled"
|
||||
@@ -1233,6 +1225,8 @@ export type I18nKey =
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
@@ -1277,6 +1271,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"
|
||||
@@ -1529,6 +1524,7 @@ export type I18nKey =
|
||||
| "deadlines.upc.apl.cost"
|
||||
| "deadlines.upc.apl.merits"
|
||||
| "deadlines.upc.apl.order"
|
||||
| "deadlines.upc.apl.unified"
|
||||
| "deadlines.upc.ccr.cfi"
|
||||
| "deadlines.upc.disc.cfi"
|
||||
| "deadlines.upc.dmgs.cfi"
|
||||
@@ -1992,7 +1988,6 @@ export type I18nKey =
|
||||
| "nav.admin.paliadin"
|
||||
| "nav.admin.partner_units"
|
||||
| "nav.admin.rules"
|
||||
| "nav.admin.rules_export"
|
||||
| "nav.admin.team"
|
||||
| "nav.agenda"
|
||||
| "nav.akten"
|
||||
@@ -2627,6 +2622,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"
|
||||
@@ -2639,6 +2636,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,479 @@ 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);
|
||||
}
|
||||
|
||||
/* t-paliad-313 Slice B — inline editor per section. */
|
||||
.submission-draft-section-toggle {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.submission-draft-section-toolbar {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin: 0.4rem 0 0.3rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-section-toolbar-btn {
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-elev-1);
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.submission-draft-section-toolbar-btn:hover {
|
||||
background: var(--color-bg-subtle, var(--color-bg-elev-2));
|
||||
}
|
||||
|
||||
.submission-draft-section-editor {
|
||||
min-height: 3rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-elev-1);
|
||||
font-family: inherit;
|
||||
font-size: 0.92em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.submission-draft-section-editor:focus {
|
||||
border-color: var(--color-accent-fg, var(--color-text));
|
||||
box-shadow: 0 0 0 2px var(--color-bg-lime-tint, transparent);
|
||||
}
|
||||
|
||||
.submission-draft-section-editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.submission-draft-section--editing {
|
||||
background: var(--color-bg-elev-2, var(--color-bg-elev-1));
|
||||
}
|
||||
|
||||
/* t-paliad-318 Slice F — drag-and-drop reorder + add / delete affordances. */
|
||||
.submission-draft-section-handle {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
padding: 0 0.35rem;
|
||||
margin-right: 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.submission-draft-section-handle:hover {
|
||||
background: var(--color-bg-subtle, var(--color-bg-elev-2));
|
||||
}
|
||||
|
||||
.submission-draft-section-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.submission-draft-section--dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.submission-draft-section--drop-target {
|
||||
border-top: 2px solid var(--color-accent-fg, var(--color-text));
|
||||
}
|
||||
|
||||
.submission-draft-section-delete {
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.submission-draft-sections-trailer {
|
||||
margin-top: 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.submission-draft-add-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
}
|
||||
|
||||
.submission-draft-add-section-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.submission-draft-add-section-row > span {
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-add-section-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* t-paliad-315 Slice C — building-block picker modal */
|
||||
.submission-draft-section-bb-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.submission-bb-picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.submission-bb-picker {
|
||||
background: var(--color-bg, white);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
width: min(720px, 92vw);
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.submission-bb-picker-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.submission-bb-picker-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.submission-bb-picker-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submission-bb-picker-sectioninfo {
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-bb-picker-list {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-bb-picker-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submission-bb-picker-row:hover {
|
||||
background: var(--color-bg-lime-tint, var(--color-bg-elev-2));
|
||||
}
|
||||
|
||||
.submission-bb-picker-row-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-bb-picker-row-desc {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-bb-picker-row-preview {
|
||||
margin: 0.25rem 0 0 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
white-space: pre-wrap;
|
||||
max-height: 4em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.submission-bb-picker-vis {
|
||||
font-size: 0.7em;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.submission-bb-picker-vis--private { background: #fde2e2; color: #8a2a2a; }
|
||||
.submission-bb-picker-vis--team { background: #fff4d6; color: #7a5d12; }
|
||||
.submission-bb-picker-vis--firm { background: #def5e2; color: #266e34; }
|
||||
.submission-bb-picker-vis--global { background: #dce8fb; color: #1f437a; }
|
||||
|
||||
.submission-bb-picker-empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* t-paliad-315 Slice C — /admin/submission-building-blocks editor */
|
||||
.admin-bb-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) 1fr minmax(180px, 240px);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-bb-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-bb-list-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
text-align: left;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-bb-list-row--active {
|
||||
background: var(--color-bg-lime-tint, var(--color-bg-elev-2));
|
||||
border-color: var(--color-accent-fg, var(--color-text));
|
||||
}
|
||||
|
||||
.admin-bb-list-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.admin-bb-list-meta {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.7em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-bb-list-section {
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.admin-bb-list-vis {
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.admin-bb-list-vis--private { background: #fde2e2; color: #8a2a2a; }
|
||||
.admin-bb-list-vis--team { background: #fff4d6; color: #7a5d12; }
|
||||
.admin-bb-list-vis--firm { background: #def5e2; color: #266e34; }
|
||||
.admin-bb-list-vis--global { background: #dce8fb; color: #1f437a; }
|
||||
|
||||
.admin-bb-list-draft {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-bb-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.admin-bb-form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.admin-bb-form-row--checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.admin-bb-form-row > span {
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-bb-form-hint {
|
||||
font-size: 0.75em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-bb-form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-bb-versions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-bb-version-row {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.admin-bb-version-meta {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-bb-empty {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-language-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6220,7 +6703,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 +6861,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 +6899,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 +6914,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 +6937,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 +7065,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 +7188,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 +8395,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 +17055,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 +17109,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 +17131,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 +17198,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 {
|
||||
@@ -18185,42 +18668,6 @@ dialog.quick-add-sheet::backdrop {
|
||||
border-top: 1px solid var(--color-border, #d4d4d8);
|
||||
}
|
||||
|
||||
/* Export page */
|
||||
|
||||
.admin-rules-export-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-rules-export-controls .form-field {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.admin-rules-export-summary {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted, #71717a);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-rules-export-pre {
|
||||
background: var(--color-bg-subtle, #f4f4f5);
|
||||
border: 1px solid var(--color-border, #d4d4d8);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
max-height: 60vh;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8rem;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Date-range picker (t-paliad-248) ------------------------------------
|
||||
Symmetric past/future chip fan around an ALLES centre, in a popover
|
||||
anchored under a closed-state trigger button. Reuses .agenda-chip /
|
||||
|
||||
@@ -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">
|
||||
Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.
|
||||
</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">
|
||||
|
||||
@@ -28,16 +28,20 @@ function proceedingBtn(p: ProceedingDef): string {
|
||||
);
|
||||
}
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
|
||||
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
|
||||
// unified "Berufung" tile (upc.apl). After picking it, the user
|
||||
// selects which decision the appeal is directed AT via the
|
||||
// .appeal-target-row chip group below — the engine then filters
|
||||
// rules whose applies_to_target contains the picked slug.
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
|
||||
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
|
||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||
@@ -216,20 +220,33 @@ export function renderVerfahrensablauf(): string {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
{/* Appeal-target chip row (Slice B1 / m/paliad#124 §18.1).
|
||||
Shown only when the unified upc.apl Berufung tile is
|
||||
selected; lets the user narrow the timeline to the
|
||||
rules whose applies_to_target contains the picked
|
||||
decision kind. URL state ?target=<slug>. */}
|
||||
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
<input type="radio" name="appeal-target" value="endentscheidung" checked />
|
||||
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
<input type="radio" name="appeal-target" value="kostenentscheidung" />
|
||||
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
<input type="radio" name="appeal-target" value="anordnung" />
|
||||
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="schadensbemessung" />
|
||||
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="bucheinsicht" />
|
||||
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,6 +341,13 @@ export function renderVerfahrensablauf(): string {
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
{/* Durations toggle (m/paliad#133, t-paliad-302).
|
||||
Default off — hover-tooltips on date spans are
|
||||
the always-on path. */}
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="verfahrensablauf-durations-show" />
|
||||
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
|
||||
134
internal/db/migration_136_test.go
Normal file
134
internal/db/migration_136_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Slice B.1 (t-paliad-273) — migration 136 backfill invariants.
|
||||
//
|
||||
// The dry-run gate (migrate_test.go: TestMigrations_DryRun) catches
|
||||
// migrations that crash on apply, but it rolls back inside its own
|
||||
// transaction — the post-state assertions in mig 136's PL/pgSQL block
|
||||
// run, but a future refactor of those assertions might forget a check
|
||||
// or introduce a silent count drift. This test layers a Go-side
|
||||
// invariant check on top so the contract is restated in test code,
|
||||
// outside the PL/pgSQL block, against the resulting tables.
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL, same pattern as
|
||||
// internal/services/submission_codes_shape_test.go.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// TestMigration136_BackfillInvariants applies every embedded migration
|
||||
// (which lands mig 136 along the way) and then asserts the four
|
||||
// invariants the B.1 design + B.0 findings nailed down:
|
||||
//
|
||||
// 1. procedural_events row count = (distinct submission_codes in
|
||||
// deadline_rules) + (deadline_rules with NULL submission_code).
|
||||
// Codes-bearing branch is 1:1 per the B.0 audit (no multi-row
|
||||
// codes since the _archived_litigation.* removal); the NULL
|
||||
// branch gets one synthetic procedural_event per rule.
|
||||
// 2. sequencing_rules row count = deadline_rules row count (1:1).
|
||||
// 3. legal_sources row count = distinct legal_source in
|
||||
// deadline_rules (NULL excluded).
|
||||
// 4. every sequencing_rules row's procedural_event_id resolves to a
|
||||
// procedural_events row (NOT NULL FK already enforces this at the
|
||||
// DB level — this test catches a future relaxation of the FK).
|
||||
// 5. no two synthetic codes collide (covered by the UNIQUE on
|
||||
// procedural_events.code; restated here for documentation).
|
||||
//
|
||||
// The test is robust against corpus size — it derives all expected
|
||||
// counts from the live deadline_rules state, so a scratch DB with 0
|
||||
// rules trivially passes, and a prod-shaped scratch DB exercises the
|
||||
// real invariants.
|
||||
func TestMigration136_BackfillInvariants(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping mig 136 invariant test")
|
||||
}
|
||||
if err := ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
var (
|
||||
drTotal, drCodesDistinct, drCodesNull, drLegalDistinct int
|
||||
peTotal, srTotal, lsTotal int
|
||||
orphanPE, dupSynthetic int
|
||||
)
|
||||
|
||||
mustQ := func(label, q string, dst *int) {
|
||||
t.Helper()
|
||||
if err := conn.QueryRowContext(ctx, q).Scan(dst); err != nil {
|
||||
t.Fatalf("%s: %v", label, err)
|
||||
}
|
||||
}
|
||||
|
||||
mustQ("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &drTotal)
|
||||
mustQ("dr_codes_distinct",
|
||||
`SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL`,
|
||||
&drCodesDistinct)
|
||||
mustQ("dr_codes_null",
|
||||
`SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL`,
|
||||
&drCodesNull)
|
||||
mustQ("dr_legal_distinct",
|
||||
`SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`,
|
||||
&drLegalDistinct)
|
||||
mustQ("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &peTotal)
|
||||
mustQ("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &srTotal)
|
||||
mustQ("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &lsTotal)
|
||||
|
||||
// Invariant 1: procedural_events = distinct_codes + null_codes
|
||||
wantPE := drCodesDistinct + drCodesNull
|
||||
if peTotal != wantPE {
|
||||
t.Errorf("procedural_events count mismatch: got %d, want %d (distinct codes=%d + null-code rules=%d)",
|
||||
peTotal, wantPE, drCodesDistinct, drCodesNull)
|
||||
}
|
||||
|
||||
// Invariant 2: sequencing_rules 1:1 with deadline_rules
|
||||
if srTotal != drTotal {
|
||||
t.Errorf("sequencing_rules count mismatch: got %d, want %d (1:1 with deadline_rules)",
|
||||
srTotal, drTotal)
|
||||
}
|
||||
|
||||
// Invariant 3: legal_sources = distinct legal_source
|
||||
if lsTotal != drLegalDistinct {
|
||||
t.Errorf("legal_sources count mismatch: got %d, want %d (distinct legal_source)",
|
||||
lsTotal, drLegalDistinct)
|
||||
}
|
||||
|
||||
// Invariant 4: every sequencing_rules.procedural_event_id resolves
|
||||
mustQ("orphan_pe", `
|
||||
SELECT COUNT(*)
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.id IS NULL`, &orphanPE)
|
||||
if orphanPE != 0 {
|
||||
t.Errorf("FK integrity violated: %d sequencing_rules row(s) have no resolving procedural_event_id", orphanPE)
|
||||
}
|
||||
|
||||
// Invariant 5: no duplicate synthetic codes
|
||||
mustQ("dup_synthetic", `
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT code FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
GROUP BY code
|
||||
HAVING COUNT(*) > 1
|
||||
) d`, &dupSynthetic)
|
||||
if dupSynthetic != 0 {
|
||||
t.Errorf("synthetic code uniqueness violated: %d duplicate(s) under 'null.%%' prefix", dupSynthetic)
|
||||
}
|
||||
|
||||
t.Logf("mig 136 invariants OK: deadline_rules=%d, procedural_events=%d (=%d+%d), "+
|
||||
"sequencing_rules=%d, legal_sources=%d (distinct legal_source=%d)",
|
||||
drTotal, peTotal, drCodesDistinct, drCodesNull, srTotal, lsTotal, drLegalDistinct)
|
||||
}
|
||||
72
internal/db/migrations/134_berufung_unification.down.sql
Normal file
72
internal/db/migrations/134_berufung_unification.down.sql
Normal file
@@ -0,0 +1,72 @@
|
||||
-- 134_berufung_unification — DOWN
|
||||
--
|
||||
-- Reverses the Berufung unification: un-archives the 3 old appeal
|
||||
-- proceeding_types, points the 16 rules back at their original
|
||||
-- proceeding by their applies_to_target stamp, drops the new
|
||||
-- upc.apl row, drops the two columns + their CHECK constraints.
|
||||
--
|
||||
-- The 3 old proceeding_types are recovered by code (we archived them,
|
||||
-- never deleted them — that's what makes this down-migration safe).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger — step 2 UPDATEs
|
||||
-- paliad.deadline_rules to reverse the reassignment).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 134 DOWN: revert Slice B1 — restore 3 separate UPC appeal proceeding_types, drop applies_to_target column',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Un-archive the 3 old appeal proceeding_types.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = true
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Point rules back at their original proceeding_type by stamp.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.merits'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['endentscheidung']::text[];
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.cost'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['kostenentscheidung']::text[];
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.order'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['anordnung']::text[];
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Drop the unified upc.apl.unified row (now orphaned).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DELETE FROM paliad.proceeding_types WHERE code = 'upc.apl.unified';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Drop the new columns + their CHECK constraints.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_applies_to_target_chk;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS applies_to_target;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP CONSTRAINT IF EXISTS proceeding_types_appeal_target_chk;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS appeal_target;
|
||||
272
internal/db/migrations/134_berufung_unification.up.sql
Normal file
272
internal/db/migrations/134_berufung_unification.up.sql
Normal file
@@ -0,0 +1,272 @@
|
||||
-- 134_berufung_unification — Slice B1, m/paliad#124, t-paliad-298+
|
||||
--
|
||||
-- Collapses the 3 active UPC appeal proceeding_types (upc.apl.merits,
|
||||
-- upc.apl.cost, upc.apl.order — 16 rules across 3 codes) into ONE
|
||||
-- unified upc.apl proceeding type + an `appeal_target` discriminator on
|
||||
-- both proceeding_types (top-level marker) and deadline_rules
|
||||
-- (per-row applies-to set, text[] for multi-target rules).
|
||||
--
|
||||
-- ADDITIVE ONLY. The migration:
|
||||
-- 1. Adds the two columns + check constraints.
|
||||
-- 2. Inserts the new upc.apl proceeding type.
|
||||
-- 3. Audit-first: NOTICES every row about to be touched.
|
||||
-- 4. Reassigns rule rows from the 3 old types to upc.apl, stamping
|
||||
-- applies_to_target by source proceeding code.
|
||||
-- 5. Archives (is_active=false) the 3 old proceeding_types — NEVER
|
||||
-- deletes them, so any historical project_event_choices / FK
|
||||
-- references stay intact.
|
||||
--
|
||||
-- Schadensbemessung + Bucheinsicht get NO rule rows in this migration
|
||||
-- (m's 2026-05-26 decision: distinct rule sets, not shared with
|
||||
-- merits). Their appeal_target enum values are defined and addressable
|
||||
-- by CalcOptions.AppealTarget; the engine returns an empty timeline
|
||||
-- until rules are seeded in a follow-up slice (likely via
|
||||
-- /admin/rules, pairing with t-paliad-193 orphan-concept-seed).
|
||||
--
|
||||
-- See docs/design-litigation-planner-2026-05-26.md §18.1.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
|
||||
-- paliad.deadline_rules — step 4 reassigns 16 rules).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 134: t-paliad-292 Slice B1 — Berufung unification, collapse 3 UPC appeal proceeding_types into upc.apl.unified + appeal_target discriminator',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Schema additions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN appeal_target text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD CONSTRAINT proceeding_types_appeal_target_chk
|
||||
CHECK (appeal_target IS NULL OR appeal_target IN (
|
||||
'endentscheidung',
|
||||
'kostenentscheidung',
|
||||
'anordnung',
|
||||
'schadensbemessung',
|
||||
'bucheinsicht'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.appeal_target IS
|
||||
'Top-level appeal-target marker. NULL on non-appeal proceedings. '
|
||||
'Reserved for future variants — today only the unified upc.apl row '
|
||||
'has this NULL (the actual per-rule target set lives on '
|
||||
'paliad.deadline_rules.applies_to_target).';
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN applies_to_target text[] NULL;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_applies_to_target_chk
|
||||
CHECK (
|
||||
applies_to_target IS NULL
|
||||
OR applies_to_target <@ ARRAY[
|
||||
'endentscheidung',
|
||||
'kostenentscheidung',
|
||||
'anordnung',
|
||||
'schadensbemessung',
|
||||
'bucheinsicht'
|
||||
]::text[]
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.applies_to_target IS
|
||||
'Set of appeal_target slugs this rule applies to. NULL on rules '
|
||||
'that don''t belong to an appeal proceeding. The engine filters '
|
||||
'by CalcOptions.AppealTarget — rules whose applies_to_target '
|
||||
'contains the requested slug are emitted; others are suppressed.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Insert the unified upc.apl row.
|
||||
--
|
||||
-- Inherits default_color from the merits row (the most-used appeal
|
||||
-- track today). sort_order follows the cluster of UPC proceedings;
|
||||
-- placed just before upc.apl.merits's old slot so the chip-grouped
|
||||
-- picker UI lands Berufung in a sensible position. Tweakable later
|
||||
-- without a migration.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.proceeding_types (
|
||||
code, name, name_en, description, jurisdiction, category,
|
||||
default_color, sort_order, is_active, display_order,
|
||||
appeal_target
|
||||
)
|
||||
SELECT
|
||||
'upc.apl.unified',
|
||||
'Berufungsverfahren',
|
||||
'Appeal',
|
||||
'Vereinheitlichtes Berufungsverfahren — wählen Sie anschließend, '
|
||||
'worauf die Berufung sich richtet (Endentscheidung, '
|
||||
'Kostenentscheidung, Anordnung, Schadensbemessung, Bucheinsicht).',
|
||||
'UPC',
|
||||
'fristenrechner',
|
||||
default_color,
|
||||
sort_order,
|
||||
true,
|
||||
display_order,
|
||||
NULL
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.merits';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Audit-first RAISE NOTICE pass.
|
||||
--
|
||||
-- Lists every rule row that will be reassigned + every proceeding_type
|
||||
-- row that will be archived. The migration runs to completion either
|
||||
-- way; the operator reads the notices to confirm scope before the
|
||||
-- next migration in the chain.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
upc_apl_id int;
|
||||
rules_touched int := 0;
|
||||
procs_archived int := 0;
|
||||
BEGIN
|
||||
SELECT id INTO upc_apl_id
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.unified';
|
||||
RAISE NOTICE '[mig 134] new upc.apl.unified proceeding_type_id = %', upc_apl_id;
|
||||
|
||||
RAISE NOTICE '[mig 134] Rules to reassign to upc.apl.unified with applies_to_target:';
|
||||
FOR rec IN
|
||||
SELECT dr.id AS rule_id,
|
||||
pt.code AS old_proceeding,
|
||||
dr.submission_code,
|
||||
dr.name
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
AND dr.is_active = true
|
||||
ORDER BY pt.code, dr.sequence_order
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] % % % (%)',
|
||||
rec.old_proceeding, rec.submission_code, rec.name, rec.rule_id;
|
||||
rules_touched := rules_touched + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 134] Total rules to reassign: %', rules_touched;
|
||||
|
||||
RAISE NOTICE '[mig 134] Proceeding_types to archive (is_active=false):';
|
||||
FOR rec IN
|
||||
SELECT id, code, name
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
ORDER BY sort_order
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] % % (id=%)', rec.code, rec.name, rec.id;
|
||||
procs_archived := procs_archived + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 134] Total proceeding_types to archive: %', procs_archived;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Reassign rule rows.
|
||||
--
|
||||
-- Stamp applies_to_target by source proceeding code, then point all
|
||||
-- 16 rules at the new upc.apl row.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- 4a. upc.apl.merits → applies_to_target = {endentscheidung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['endentscheidung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.merits'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4b. upc.apl.cost → applies_to_target = {kostenentscheidung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['kostenentscheidung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.cost'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4c. upc.apl.order → applies_to_target = {anordnung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['anordnung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.order'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4d. Reassign all 16 rules to the new upc.apl.unified proceeding_type row.
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.unified'
|
||||
)
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. Archive the 3 old proceeding_types.
|
||||
--
|
||||
-- NEVER DELETE — historical project_event_choices and project FKs
|
||||
-- (paliad.projects.proceeding_type_id) may still reference these IDs.
|
||||
-- The is_active=false flag stops them appearing in the picker but
|
||||
-- preserves FK integrity for historical reads.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6. Post-migration sanity check.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
unified_count int;
|
||||
archived_count int;
|
||||
target_distribution record;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO unified_count
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true;
|
||||
RAISE NOTICE '[mig 134] post: rules on unified upc.apl.unified = % (expected 16)', unified_count;
|
||||
IF unified_count <> 16 THEN
|
||||
RAISE EXCEPTION '[mig 134] FAILED — expected 16 rules on upc.apl.unified, got %', unified_count;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO archived_count
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
AND is_active = false;
|
||||
RAISE NOTICE '[mig 134] post: archived old appeal proceeding_types = % (expected 3)', archived_count;
|
||||
IF archived_count <> 3 THEN
|
||||
RAISE EXCEPTION '[mig 134] FAILED — expected 3 archived types, got %', archived_count;
|
||||
END IF;
|
||||
|
||||
FOR target_distribution IN
|
||||
SELECT unnest(applies_to_target) AS target, COUNT(*) AS n
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true
|
||||
GROUP BY unnest(applies_to_target)
|
||||
ORDER BY 1
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] post: applies_to_target=% count=%',
|
||||
target_distribution.target, target_distribution.n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- TODO (follow-up slice, not in 134):
|
||||
--
|
||||
-- Seed rules for Schadensbemessung-as-appeal + Bucheinsicht-as-appeal.
|
||||
-- m's 2026-05-26 decision: distinct rule sets, NOT shared with merits.
|
||||
-- - Schadensbemessung: anchor on R.118.4 decision; conjecture 2/4-month
|
||||
-- merits-style track but distinct legal basis.
|
||||
-- - Bucheinsicht: anchor on R.142 (Lay-open-books decision); conjecture
|
||||
-- 15-day track per R.220.2 + R.224.2.b.
|
||||
-- Can pair with t-paliad-193 orphan-concept-seed if m wants a combined
|
||||
-- editorial pass via /admin/rules.
|
||||
-- ---------------------------------------------------------------
|
||||
8
internal/db/migrations/135_primary_party_check.down.sql
Normal file
8
internal/db/migrations/135_primary_party_check.down.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- 135_primary_party_check — DOWN
|
||||
--
|
||||
-- Drops the CHECK constraint added in 135.up. No data revert needed
|
||||
-- — the column stays text, the four-value vocab is enforced only by
|
||||
-- application code thereafter.
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_primary_party_chk;
|
||||
92
internal/db/migrations/135_primary_party_check.up.sql
Normal file
92
internal/db/migrations/135_primary_party_check.up.sql
Normal file
@@ -0,0 +1,92 @@
|
||||
-- 135_primary_party_check — Slice B3, m/paliad#124 §18.3
|
||||
--
|
||||
-- Tightens paliad.deadline_rules.primary_party from free-text to a
|
||||
-- CHECK constraint over the canonical four-value vocabulary
|
||||
-- (claimant / defendant / court / both). NULL stays valid for the
|
||||
-- 78 cross-cutting orphan concept seeds (Wiedereinsetzung,
|
||||
-- Versäumnisurteil-Einspruch, Schriftsatznachreichung,
|
||||
-- Weiterbehandlung) — they have no proceeding_type_id binding so
|
||||
-- they're outside the calculator's path; loosening the CHECK to
|
||||
-- "IS NULL OR IN (…)" keeps them valid without backfill gymnastics.
|
||||
--
|
||||
-- Audit-first: the DO block RAISEs NOTICE for every non-conforming
|
||||
-- row before adding the CHECK, and RAISEs EXCEPTION if any dirty
|
||||
-- rows are found so the operator can decide a manual cleanup path.
|
||||
-- Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows
|
||||
-- on the current corpus: 26 claimant + 26 defendant + 38 court +
|
||||
-- 63 both + 78 NULL = 231 total, all in the canonical vocab. The
|
||||
-- audit pass stays in the migration for safety against future drift
|
||||
-- (e.g. a rule editor write that bypassed the application-layer
|
||||
-- validation hook this slice also adds).
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
dirty_count int := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 135] primary_party audit pass — non-conforming rows:';
|
||||
FOR rec IN
|
||||
SELECT dr.id, dr.name, dr.primary_party,
|
||||
pt.code AS proceeding_code
|
||||
FROM paliad.deadline_rules dr
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE dr.is_active = true
|
||||
AND dr.primary_party IS NOT NULL
|
||||
AND dr.primary_party NOT IN ('claimant', 'defendant', 'court', 'both')
|
||||
ORDER BY pt.code NULLS LAST, dr.name
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 135] % % primary_party=% (rule=%)',
|
||||
COALESCE(rec.proceeding_code, '<orphan>'),
|
||||
rec.name,
|
||||
rec.primary_party,
|
||||
rec.id;
|
||||
dirty_count := dirty_count + 1;
|
||||
END LOOP;
|
||||
IF dirty_count > 0 THEN
|
||||
RAISE EXCEPTION '[mig 135] FAILED — % rule(s) carry non-canonical primary_party values. '
|
||||
'Manual cleanup required: update each row to one of '
|
||||
'''claimant'', ''defendant'', ''court'', ''both'', or NULL. '
|
||||
'See the NOTICE lines above for the offending rows.', dirty_count;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 135] audit clean — proceeding with CHECK constraint';
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- Add the CHECK constraint. NULL stays valid; the four canonical
|
||||
-- values are the only allowed non-NULL forms.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_primary_party_chk
|
||||
CHECK (
|
||||
primary_party IS NULL
|
||||
OR primary_party IN ('claimant', 'defendant', 'court', 'both')
|
||||
);
|
||||
|
||||
COMMENT ON CONSTRAINT deadline_rules_primary_party_chk
|
||||
ON paliad.deadline_rules IS
|
||||
'Slice B3 (mig 135, m/paliad#124 §18.3) — canonical four-value '
|
||||
'vocab for primary_party (claimant / defendant / court / both). '
|
||||
'NULL allowed for cross-cutting orphan concept seeds (78 rows in '
|
||||
'live corpus as of mig 135). See pkg/litigationplanner.PrimaryParties '
|
||||
'for the in-code vocabulary.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- Post-migration distribution check — informational NOTICE only.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 135] post: primary_party distribution after constraint add:';
|
||||
FOR rec IN
|
||||
SELECT COALESCE(primary_party, '<NULL>') AS party, COUNT(*) AS n
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
GROUP BY primary_party
|
||||
ORDER BY party
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 135] % count=%', rec.party, rec.n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 136_procedural_events_additive (down) — Slice B.1, t-paliad-273
|
||||
--
|
||||
-- Safe to run at any point in B.1's lifetime. Up does NOT touch
|
||||
-- paliad.deadline_rules, so dropping the new tables + columns loses no
|
||||
-- application data — every source row in deadline_rules is intact and
|
||||
-- authoritative through the dual-write window.
|
||||
--
|
||||
-- Reverse order: drop indexes implicitly via DROP TABLE, drop the two
|
||||
-- deadlines link columns first (their FKs target procedural_events +
|
||||
-- sequencing_rules), then drop the three new tables in FK-safe order
|
||||
-- (sequencing_rules → procedural_events → legal_sources).
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS procedural_event_id,
|
||||
DROP COLUMN IF EXISTS sequencing_rule_id;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules;
|
||||
DROP TABLE IF EXISTS paliad.procedural_events;
|
||||
DROP TABLE IF EXISTS paliad.legal_sources;
|
||||
488
internal/db/migrations/136_procedural_events_additive.up.sql
Normal file
488
internal/db/migrations/136_procedural_events_additive.up.sql
Normal file
@@ -0,0 +1,488 @@
|
||||
-- 136_procedural_events_additive — Slice B.1, t-paliad-273 / m/paliad#93
|
||||
--
|
||||
-- ADDITIVE ONLY. Creates the three new tables that split today's
|
||||
-- paliad.deadline_rules into its three latent concepts (per the
|
||||
-- 2026-05-25 inventor design + 2026-05-26 B.0 re-validation):
|
||||
--
|
||||
-- 1. paliad.legal_sources — the source-of-law citations
|
||||
-- (DE.PatG.102, UPC.RoP.220.1, …)
|
||||
-- 2. paliad.procedural_events — the procedural-event templates
|
||||
-- (Rechtsbeschwerdebegründung, etc.;
|
||||
-- successor of `submission_code`)
|
||||
-- 3. paliad.sequencing_rules — the timing + trigger + condition
|
||||
-- mechanics (today's per-row data)
|
||||
--
|
||||
-- and adds two nullable link columns on paliad.deadlines so B.2's
|
||||
-- dual-write phase has somewhere to point.
|
||||
--
|
||||
-- The migration does NOT touch paliad.deadline_rules. The legacy table
|
||||
-- stays intact and authoritative for reads until B.3 flips the cutover.
|
||||
-- deadlines.rule_id stays in place (read by the calculator + projection
|
||||
-- service). No app code is changed by this migration; B.2 introduces
|
||||
-- the dual-write that wires services to the new tables.
|
||||
--
|
||||
-- Backfill plan (cf. design §5.1 + B.0 findings §7):
|
||||
-- * legal_sources <- DISTINCT legal_source FROM deadline_rules WHERE
|
||||
-- legal_source IS NOT NULL. pretty_de/pretty_en
|
||||
-- LEFT NULL for now (legalSourcePretty() in Go
|
||||
-- continues to materialise them on read; a future
|
||||
-- slice backfills them via a Go shim).
|
||||
-- * procedural_events <-
|
||||
-- (a) DISTINCT ON (submission_code) FROM deadline_rules WHERE
|
||||
-- submission_code IS NOT NULL — picks the lowest-id rule per
|
||||
-- code as the procedural-event identity source.
|
||||
-- (b) one synthetic procedural_event per NULL-submission_code
|
||||
-- rule, code = 'null.' || substring(replace(id::text,'-',''),1,8).
|
||||
-- m's pick (paliadin instruction 2026-05-26): mint synthetic
|
||||
-- codes so every deadline_rules row ends up with a
|
||||
-- procedural_events row, preserving the 1:1 sequencing-rule
|
||||
-- backfill and keeping the NOT NULL FK on
|
||||
-- sequencing_rules.procedural_event_id intact.
|
||||
-- * sequencing_rules <- 1:1 from deadline_rules. The new row inherits
|
||||
-- the source row's id so that any existing
|
||||
-- paliad.deadlines.rule_id FK target stays resolvable through
|
||||
-- the dual-write window (design §5.1 step 4).
|
||||
-- * deadlines.procedural_event_id + sequencing_rule_id <- joined from
|
||||
-- sequencing_rules on the inherited id.
|
||||
--
|
||||
-- Design deviations (intentional, documented):
|
||||
-- - procedural_events.event_kind is NULLABLE (design proposed NOT NULL
|
||||
-- with 'other' fallback). Today 89 deadline_rules rows have NULL
|
||||
-- event_type — these are "structural / parent-only rows in the
|
||||
-- proceeding tree" per B.0 §1. Forcing them to 'other' would lose
|
||||
-- semantics. A later slice can tighten this to NOT NULL after the
|
||||
-- 78+11 NULLs are reclassified.
|
||||
-- - legal_sources.pretty_de / pretty_en are NULLABLE (design proposed
|
||||
-- NOT NULL). Materialising them requires the Go-side
|
||||
-- legalSourcePretty() function — out of scope for a SQL migration.
|
||||
-- The Go read path continues to compute them on the fly from
|
||||
-- legal_source / citation; a future slice (Go shim driven from
|
||||
-- internal/services/submission_vars.go:619) backfills them.
|
||||
-- - submission_drafts is NOT modified. The design proposes adding
|
||||
-- procedural_event_id there too (§4.1 §5.1 step 6) but the B.1
|
||||
-- instruction scope is explicit: tables + deadlines columns only.
|
||||
-- submission_drafts continues to key off submission_code text.
|
||||
--
|
||||
-- Audit pattern follows mig 135 (Slice B3): PRE-pass counts what we
|
||||
-- expect to write, BACKFILL runs the SELECT-INSERTs, POST-pass verifies
|
||||
-- row counts and FK integrity. Any mismatch RAISE EXCEPTIONs and the
|
||||
-- transaction rolls back — operator sees the NOTICE lines and the
|
||||
-- failed assertion message.
|
||||
--
|
||||
-- See: docs/design-procedural-events-model-2026-05-25.md §4 + §5
|
||||
-- docs/design-procedural-events-b0-findings-2026-05-26.md §7
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. PRE pass — snapshot what we're about to backfill
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_rules int;
|
||||
v_codes_nn int;
|
||||
v_codes_distinct int;
|
||||
v_codes_null int;
|
||||
v_legal_distinct int;
|
||||
v_concept_linked int;
|
||||
v_dups int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_rules FROM paliad.deadline_rules;
|
||||
SELECT COUNT(*) INTO v_codes_nn FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(DISTINCT submission_code) INTO v_codes_distinct
|
||||
FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_codes_null FROM paliad.deadline_rules WHERE submission_code IS NULL;
|
||||
SELECT COUNT(DISTINCT legal_source) INTO v_legal_distinct
|
||||
FROM paliad.deadline_rules WHERE legal_source IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_concept_linked FROM paliad.deadline_rules WHERE concept_id IS NOT NULL;
|
||||
|
||||
RAISE NOTICE '[mig 136] PRE: deadline_rules=%, with_submission_code=%, distinct_codes=%, null_codes=%, distinct_legal_sources=%, concept_linked=%',
|
||||
v_rules, v_codes_nn, v_codes_distinct, v_codes_null, v_legal_distinct, v_concept_linked;
|
||||
|
||||
-- Defensive: refuse to run if multi-row submission_codes have crept
|
||||
-- back in. B.0 (2026-05-26) found zero; mig 134 + 135 do not add
|
||||
-- any. If this CHECK ever fires the backfill arithmetic below
|
||||
-- breaks silently (one PE per code becomes ambiguous), so abort.
|
||||
SELECT COUNT(*) INTO v_dups FROM (
|
||||
SELECT submission_code
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
GROUP BY submission_code
|
||||
HAVING COUNT(*) > 1
|
||||
) d;
|
||||
IF v_dups > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED PRE: % submission_code value(s) appear on >1 deadline_rules row. '
|
||||
'The B.0 audit (2026-05-26) found zero. If you are seeing this, a rule was added that '
|
||||
'duplicates an existing submission_code (or the _archived_litigation.* rows returned). '
|
||||
'Decide whether the new schema collapses them (multiple sequencing rules → one '
|
||||
'procedural event) or whether each row gets its own code, then update this migration '
|
||||
'or the offending data before re-running.', v_dups;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. CREATE TABLE paliad.legal_sources
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.legal_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
citation text NOT NULL UNIQUE,
|
||||
jurisdiction text NOT NULL,
|
||||
pretty_de text,
|
||||
pretty_en text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.legal_sources IS
|
||||
'Source-of-law citations (DE.PatG.102, UPC.RoP.220.1, …). One row per '
|
||||
'distinct citation shorthand. pretty_de/pretty_en backfilled by a '
|
||||
'future Go-driven slice; until then NULL and the Go service ('
|
||||
'internal/services/submission_vars.go:619 legalSourcePretty) computes '
|
||||
'the human-readable form on read from the citation. Slice B.1 t-paliad-273.';
|
||||
|
||||
CREATE INDEX legal_sources_jurisdiction_idx ON paliad.legal_sources(jurisdiction);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. CREATE TABLE paliad.procedural_events
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.procedural_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
name_en text NOT NULL DEFAULT '',
|
||||
description text,
|
||||
event_kind text,
|
||||
primary_party_default text,
|
||||
legal_source_id uuid REFERENCES paliad.legal_sources(id),
|
||||
concept_id uuid REFERENCES paliad.deadline_concepts(id),
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.procedural_events(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.procedural_events IS
|
||||
'Procedural-event templates — the "what kind of step is this in the '
|
||||
'proceeding" hat of the legacy paliad.deadline_rules row. One row per '
|
||||
'unique submission_code, plus one synthetic row per NULL-submission_code '
|
||||
'rule (code prefix "null."). Slice B.1 t-paliad-273.';
|
||||
|
||||
COMMENT ON COLUMN paliad.procedural_events.event_kind IS
|
||||
'filing|reply|hearing|decision|order|other. NULLABLE for now — 89 '
|
||||
'rules in the live corpus have NULL event_type (structural / parent-only '
|
||||
'rows in the proceeding tree). A future slice can tighten to NOT NULL '
|
||||
'after these are reclassified.';
|
||||
|
||||
COMMENT ON COLUMN paliad.procedural_events.concept_id IS
|
||||
'Optional reference to a deadline_concepts row. N:1 — one concept may '
|
||||
'be shared by many procedural events (e.g. "Berufungsfrist" attaches to '
|
||||
'all four court-specific Berufung procedural events). Do NOT add UNIQUE.';
|
||||
|
||||
CREATE INDEX procedural_events_concept_id_idx ON paliad.procedural_events(concept_id);
|
||||
CREATE INDEX procedural_events_event_kind_idx ON paliad.procedural_events(event_kind);
|
||||
CREATE INDEX procedural_events_lifecycle_idx ON paliad.procedural_events(lifecycle_state);
|
||||
CREATE INDEX procedural_events_legal_source_idx ON paliad.procedural_events(legal_source_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. CREATE TABLE paliad.sequencing_rules
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
procedural_event_id uuid NOT NULL REFERENCES paliad.procedural_events(id),
|
||||
proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
parent_id uuid REFERENCES paliad.sequencing_rules(id),
|
||||
trigger_event_id bigint REFERENCES paliad.trigger_events(id),
|
||||
duration_value integer NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months',
|
||||
timing text DEFAULT 'after',
|
||||
alt_duration_value integer,
|
||||
alt_duration_unit text,
|
||||
alt_rule_code text,
|
||||
anchor_alt text,
|
||||
combine_op text,
|
||||
condition_expr jsonb,
|
||||
primary_party text,
|
||||
sequence_order integer NOT NULL DEFAULT 0,
|
||||
is_spawn boolean NOT NULL DEFAULT false,
|
||||
spawn_label text,
|
||||
spawn_proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
is_bilateral boolean NOT NULL DEFAULT false,
|
||||
is_court_set boolean NOT NULL DEFAULT false,
|
||||
priority text NOT NULL DEFAULT 'mandatory',
|
||||
rule_code text,
|
||||
rule_codes text[],
|
||||
deadline_notes text,
|
||||
deadline_notes_en text,
|
||||
choices_offered jsonb,
|
||||
applies_to_target text[],
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.sequencing_rules(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules IS
|
||||
'Sequencing-rule mechanics — the "how and when does this fire" hat of '
|
||||
'the legacy paliad.deadline_rules row. 1:1 with deadline_rules during '
|
||||
'the dual-write window; the id is inherited from deadline_rules.id so '
|
||||
'paliad.deadlines.rule_id FKs continue to resolve transitively. '
|
||||
'Slice B.1 t-paliad-273.';
|
||||
|
||||
COMMENT ON COLUMN paliad.sequencing_rules.primary_party IS
|
||||
'Per-rule override of procedural_events.primary_party_default. Same '
|
||||
'four-value vocab as deadline_rules.primary_party (mig 135 CHECK). '
|
||||
'NULL = use procedural-event default. A future slice can add the '
|
||||
'same CHECK here.';
|
||||
|
||||
CREATE INDEX sequencing_rules_pe_proc_lifecycle_idx
|
||||
ON paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state);
|
||||
CREATE INDEX sequencing_rules_parent_id_idx ON paliad.sequencing_rules(parent_id);
|
||||
CREATE INDEX sequencing_rules_trigger_event_idx ON paliad.sequencing_rules(trigger_event_id);
|
||||
CREATE INDEX sequencing_rules_proceeding_type_idx ON paliad.sequencing_rules(proceeding_type_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. ALTER paliad.deadlines — add link columns
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id),
|
||||
ADD COLUMN sequencing_rule_id uuid REFERENCES paliad.sequencing_rules(id);
|
||||
|
||||
COMMENT ON COLUMN paliad.deadlines.procedural_event_id IS
|
||||
'NULLABLE link to the procedural event this deadline instantiates. '
|
||||
'Added Slice B.1 (mig 136). B.2 dual-write populates it on every new '
|
||||
'deadline; B.3 cutover flips reads to use this instead of rule_id. '
|
||||
'rule_id stays in place until B.4 destructive drop.';
|
||||
COMMENT ON COLUMN paliad.deadlines.sequencing_rule_id IS
|
||||
'NULLABLE link to the sequencing rule. Same lifecycle as '
|
||||
'procedural_event_id — added Slice B.1, dual-written B.2, read in B.3, '
|
||||
'rule_id dropped in B.4.';
|
||||
|
||||
CREATE INDEX deadlines_procedural_event_id_idx ON paliad.deadlines(procedural_event_id);
|
||||
CREATE INDEX deadlines_sequencing_rule_id_idx ON paliad.deadlines(sequencing_rule_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. BACKFILL — legal_sources
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
||||
SELECT DISTINCT
|
||||
legal_source AS citation,
|
||||
COALESCE(NULLIF(split_part(legal_source, '.', 1), ''), 'other') AS jurisdiction
|
||||
FROM paliad.deadline_rules
|
||||
WHERE legal_source IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6. BACKFILL — procedural_events
|
||||
-- (a) codes-bearing branch: DISTINCT ON (submission_code) picks the
|
||||
-- lowest-id (tie-break sequence_order) deadline_rules row as the
|
||||
-- identity source per the design's §5.1 step 3.
|
||||
-- (b) NULL-code branch: one synthetic row per rule, code minted from
|
||||
-- the rule id's first 8 hex chars (sans dashes) — m's pick
|
||||
-- 2026-05-26 (paliadin instruction).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- (a) codes-bearing rules → one procedural_events row per distinct code
|
||||
INSERT INTO paliad.procedural_events
|
||||
(code, name, name_en, description, event_kind, primary_party_default,
|
||||
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
|
||||
SELECT
|
||||
src.submission_code,
|
||||
src.name,
|
||||
src.name_en,
|
||||
src.description,
|
||||
src.event_type,
|
||||
src.primary_party,
|
||||
ls.id,
|
||||
src.concept_id,
|
||||
src.lifecycle_state,
|
||||
src.published_at,
|
||||
src.is_active
|
||||
FROM (
|
||||
SELECT DISTINCT ON (submission_code)
|
||||
submission_code, name, name_en, description, event_type,
|
||||
primary_party, concept_id, legal_source, lifecycle_state,
|
||||
published_at, is_active
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
ORDER BY submission_code, id, sequence_order
|
||||
) src
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.citation = src.legal_source;
|
||||
|
||||
-- (b) NULL-code rules → one synthetic procedural_events row each
|
||||
INSERT INTO paliad.procedural_events
|
||||
(code, name, name_en, description, event_kind, primary_party_default,
|
||||
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
|
||||
SELECT
|
||||
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8) AS code,
|
||||
dr.name,
|
||||
dr.name_en,
|
||||
dr.description,
|
||||
dr.event_type,
|
||||
dr.primary_party,
|
||||
ls.id,
|
||||
dr.concept_id,
|
||||
dr.lifecycle_state,
|
||||
dr.published_at,
|
||||
dr.is_active
|
||||
FROM paliad.deadline_rules dr
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.citation = dr.legal_source
|
||||
WHERE dr.submission_code IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 7. BACKFILL — sequencing_rules
|
||||
-- 1:1 with deadline_rules. id inherited so deadlines.rule_id FKs
|
||||
-- continue to resolve through the dual-write window (design §5.1
|
||||
-- step 4). procedural_event_id resolved by JOIN on the (real or
|
||||
-- synthetic) code.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.sequencing_rules
|
||||
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
|
||||
duration_value, duration_unit, timing,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
||||
combine_op, condition_expr, primary_party, sequence_order,
|
||||
is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
is_bilateral, is_court_set, priority,
|
||||
rule_code, rule_codes, deadline_notes, deadline_notes_en,
|
||||
choices_offered, applies_to_target,
|
||||
lifecycle_state, draft_of, published_at, is_active,
|
||||
created_at, updated_at)
|
||||
SELECT
|
||||
dr.id,
|
||||
pe.id,
|
||||
dr.proceeding_type_id,
|
||||
dr.parent_id,
|
||||
dr.trigger_event_id,
|
||||
dr.duration_value, dr.duration_unit, dr.timing,
|
||||
dr.alt_duration_value, dr.alt_duration_unit, dr.alt_rule_code, dr.anchor_alt,
|
||||
dr.combine_op, dr.condition_expr, dr.primary_party, dr.sequence_order,
|
||||
dr.is_spawn, dr.spawn_label, dr.spawn_proceeding_type_id,
|
||||
dr.is_bilateral, dr.is_court_set, dr.priority,
|
||||
dr.rule_code, dr.rule_codes, dr.deadline_notes, dr.deadline_notes_en,
|
||||
dr.choices_offered, dr.applies_to_target,
|
||||
dr.lifecycle_state,
|
||||
-- draft_of is a self-FK on deadline_rules; preserve as a self-FK on
|
||||
-- sequencing_rules since the inherited ids are stable across both.
|
||||
dr.draft_of,
|
||||
dr.published_at, dr.is_active,
|
||||
dr.created_at, dr.updated_at
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.procedural_events pe
|
||||
ON pe.code = COALESCE(
|
||||
dr.submission_code,
|
||||
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 8. BACKFILL — paliad.deadlines link columns
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET procedural_event_id = sr.procedural_event_id,
|
||||
sequencing_rule_id = sr.id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE d.rule_id = sr.id;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 9. POST pass — integrity assertions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_dr_total int;
|
||||
v_dr_codes_distinct int;
|
||||
v_dr_codes_null int;
|
||||
v_dr_legal_distinct int;
|
||||
v_pe_total int;
|
||||
v_sr_total int;
|
||||
v_ls_total int;
|
||||
v_orphan_pe int;
|
||||
v_dup_synthetic int;
|
||||
v_deadlines_linked int;
|
||||
v_deadlines_total int;
|
||||
v_pe_missing_ls int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_dr_total FROM paliad.deadline_rules;
|
||||
SELECT COUNT(DISTINCT submission_code)
|
||||
INTO v_dr_codes_distinct FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_dr_codes_null FROM paliad.deadline_rules WHERE submission_code IS NULL;
|
||||
SELECT COUNT(DISTINCT legal_source)
|
||||
INTO v_dr_legal_distinct FROM paliad.deadline_rules WHERE legal_source IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_pe_total FROM paliad.procedural_events;
|
||||
SELECT COUNT(*) INTO v_sr_total FROM paliad.sequencing_rules;
|
||||
SELECT COUNT(*) INTO v_ls_total FROM paliad.legal_sources;
|
||||
SELECT COUNT(*) INTO v_deadlines_total FROM paliad.deadlines;
|
||||
SELECT COUNT(*) INTO v_deadlines_linked FROM paliad.deadlines WHERE procedural_event_id IS NOT NULL;
|
||||
|
||||
-- a. procedural_events row count = distinct_codes + null_codes
|
||||
IF v_pe_total <> v_dr_codes_distinct + v_dr_codes_null THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: procedural_events count mismatch — got %, expected % (% distinct codes + % null-code rules)',
|
||||
v_pe_total, v_dr_codes_distinct + v_dr_codes_null, v_dr_codes_distinct, v_dr_codes_null;
|
||||
END IF;
|
||||
|
||||
-- b. sequencing_rules row count = deadline_rules row count (1:1)
|
||||
IF v_sr_total <> v_dr_total THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: sequencing_rules count mismatch — got %, expected % (1:1 with deadline_rules)',
|
||||
v_sr_total, v_dr_total;
|
||||
END IF;
|
||||
|
||||
-- c. legal_sources row count = distinct legal_source in deadline_rules
|
||||
IF v_ls_total <> v_dr_legal_distinct THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: legal_sources count mismatch — got %, expected % (distinct legal_source)',
|
||||
v_ls_total, v_dr_legal_distinct;
|
||||
END IF;
|
||||
|
||||
-- d. every sequencing_rules row's procedural_event_id resolves
|
||||
SELECT COUNT(*)
|
||||
INTO v_orphan_pe
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.id IS NULL;
|
||||
IF v_orphan_pe > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % sequencing_rules row(s) have no resolving procedural_event_id', v_orphan_pe;
|
||||
END IF;
|
||||
|
||||
-- e. no two synthetic codes collide (would have crashed the INSERT
|
||||
-- via UNIQUE, but assert again for clarity — collision among 78
|
||||
-- UUIDs at 8 hex chars is ~6e-7 probability)
|
||||
SELECT COUNT(*)
|
||||
INTO v_dup_synthetic
|
||||
FROM (
|
||||
SELECT code, COUNT(*) AS n
|
||||
FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
GROUP BY code
|
||||
HAVING COUNT(*) > 1
|
||||
) d;
|
||||
IF v_dup_synthetic > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % synthetic codes collided. '
|
||||
'Re-run with a longer substring (16 hex chars instead of 8) '
|
||||
'or full uuid in the code-mint expression.', v_dup_synthetic;
|
||||
END IF;
|
||||
|
||||
-- f. every procedural_events.legal_source_id either resolves or is
|
||||
-- NULL (NULL is fine — 119 of 231 rules have NULL legal_source)
|
||||
SELECT COUNT(*)
|
||||
INTO v_pe_missing_ls
|
||||
FROM paliad.procedural_events pe
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
|
||||
WHERE pe.legal_source_id IS NOT NULL
|
||||
AND ls.id IS NULL;
|
||||
IF v_pe_missing_ls > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % procedural_events row(s) reference a missing legal_sources id', v_pe_missing_ls;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 136] POST: legal_sources=%, procedural_events=%, sequencing_rules=%, deadlines=% (% linked)',
|
||||
v_ls_total, v_pe_total, v_sr_total, v_deadlines_total, v_deadlines_linked;
|
||||
RAISE NOTICE '[mig 136] integrity OK — backfill complete. '
|
||||
'deadline_rules untouched (1:1 with sequencing_rules; '
|
||||
'ready for B.2 dual-write).';
|
||||
END $$;
|
||||
18
internal/db/migrations/137_proceeding_role_labels.down.sql
Normal file
18
internal/db/migrations/137_proceeding_role_labels.down.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 137_proceeding_role_labels — DOWN
|
||||
--
|
||||
-- Drops the 4 role-label columns. Backfilled data is lost on
|
||||
-- down-migration; that's acceptable because the frontend renderer
|
||||
-- falls back to the default labels ("Klägerseite" / "Beklagtenseite")
|
||||
-- when the columns are absent.
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_reactive_label_en;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_reactive_label_de;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_proactive_label_en;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_proactive_label_de;
|
||||
137
internal/db/migrations/137_proceeding_role_labels.up.sql
Normal file
137
internal/db/migrations/137_proceeding_role_labels.up.sql
Normal file
@@ -0,0 +1,137 @@
|
||||
-- 137_proceeding_role_labels — t-paliad-301, m/paliad#132
|
||||
--
|
||||
-- Bug A fix: per-proceeding role labels so the Verfahrensablauf side
|
||||
-- selector can render "Berufungskläger / Berufungsbeklagter" for the
|
||||
-- unified UPC Berufung tile instead of the generic "Klägerseite /
|
||||
-- Beklagtenseite".
|
||||
--
|
||||
-- Four new optional columns on paliad.proceeding_types. NULL on a
|
||||
-- column falls back to the language-default ("Klägerseite" / "Claimant
|
||||
-- side" / "Beklagtenseite" / "Defendant side") in the frontend renderer.
|
||||
-- Only the proceedings whose role-naming actually differs get a backfill.
|
||||
--
|
||||
-- Live-DB audit (mcp__supabase__execute_sql) before drafting:
|
||||
-- - paliad.proceeding_types has 14 columns; the 4 target columns do
|
||||
-- NOT exist (zero name collisions).
|
||||
-- - Zero triggers on paliad.proceeding_types. No audit_reason
|
||||
-- setup needed.
|
||||
-- - No updated_at / created_at on the table — DO NOT include
|
||||
-- timestamp UPDATEs (lesson from mig 134 HOTFIX 3).
|
||||
--
|
||||
-- ADDITIVE ONLY. ALTER + UPDATE statements; no CHECK constraints
|
||||
-- (the columns are free-text labels, validated at the application layer).
|
||||
-- Down migration drops the 4 columns.
|
||||
--
|
||||
-- See m/paliad#132 for the full design rationale + the role-label
|
||||
-- matrix per proceeding code.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Schema additions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_proactive_label_de text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_proactive_label_en text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_reactive_label_de text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_reactive_label_en text NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_de IS
|
||||
'DE label for the proactive (claimant-equivalent) side of this '
|
||||
'proceeding. NULL = renderer falls back to "Klägerseite". '
|
||||
't-paliad-301 / m/paliad#132 Bug A.';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_en IS
|
||||
'EN label for the proactive side. NULL = "Claimant side".';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_de IS
|
||||
'DE label for the reactive (defendant-equivalent) side. NULL = '
|
||||
'"Beklagtenseite".';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_en IS
|
||||
'EN label for the reactive side. NULL = "Defendant side".';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Audit-first NOTICE pass.
|
||||
--
|
||||
-- Lists which proceeding_types are about to receive a backfill so
|
||||
-- the operator sees the scope before the UPDATE fires. NULL columns
|
||||
-- on every other row stay NULL (the frontend falls back to defaults).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
backfill_count int := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 137] Proceedings that will receive role-label backfill:';
|
||||
FOR rec IN
|
||||
SELECT code, name
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.unified', 'upc.rev.cfi', 'epa.opp.opd', 'epa.opp.boa')
|
||||
ORDER BY code
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 137] % %', rec.code, rec.name;
|
||||
backfill_count := backfill_count + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 137] Total: % proceedings (others stay NULL → renderer default)', backfill_count;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Backfill.
|
||||
--
|
||||
-- Per the design matrix in m/paliad#132:
|
||||
-- - upc.apl.unified → Berufungskläger / Berufungsbeklagter / Appellant / Appellee
|
||||
-- - upc.rev.cfi → Antragsteller (Nichtigkeit) / Antragsgegner (Nichtigkeit) /
|
||||
-- Revocation claimant / Revocation defendant
|
||||
-- - epa.opp.opd → Einsprechende(r) / Patentinhaber(in) /
|
||||
-- Opponent / Patentee
|
||||
-- - epa.opp.boa → Einsprechende(r) / Patentinhaber(in) /
|
||||
-- Opponent / Patentee
|
||||
-- - (others) → stay NULL → frontend defaults
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Berufungskläger',
|
||||
role_reactive_label_de = 'Berufungsbeklagter',
|
||||
role_proactive_label_en = 'Appellant',
|
||||
role_reactive_label_en = 'Appellee'
|
||||
WHERE code = 'upc.apl.unified';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Antragsteller (Nichtigkeit)',
|
||||
role_reactive_label_de = 'Antragsgegner (Nichtigkeit)',
|
||||
role_proactive_label_en = 'Revocation claimant',
|
||||
role_reactive_label_en = 'Revocation defendant'
|
||||
WHERE code = 'upc.rev.cfi';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Einsprechende(r)',
|
||||
role_reactive_label_de = 'Patentinhaber(in)',
|
||||
role_proactive_label_en = 'Opponent',
|
||||
role_reactive_label_en = 'Patentee'
|
||||
WHERE code IN ('epa.opp.opd', 'epa.opp.boa');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Post-migration NOTICE — informational only.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 137] post: backfilled role-label distribution:';
|
||||
FOR rec IN
|
||||
SELECT code,
|
||||
role_proactive_label_de,
|
||||
role_reactive_label_de
|
||||
FROM paliad.proceeding_types
|
||||
WHERE role_proactive_label_de IS NOT NULL
|
||||
ORDER BY code
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 137] % proactive=% reactive=%',
|
||||
rec.code, rec.role_proactive_label_de, rec.role_reactive_label_de;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -0,0 +1,71 @@
|
||||
-- 138_appeal_target_backfill_merits_order DOWN — t-paliad-303, m/paliad#134
|
||||
--
|
||||
-- Removes 'schadensbemessung' from the merits-track rules and
|
||||
-- 'bucheinsicht' from the order-track rules, restoring the pre-137
|
||||
-- shape (endentscheidung-only / anordnung-only / kostenentscheidung-only).
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
|
||||
-- paliad.deadline_rules).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 138 DOWN: t-paliad-303 — strip Schadensbemessung/Bucheinsicht from applies_to_target per m/paliad#134',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Strip new targets via array_remove.
|
||||
--
|
||||
-- WHERE clauses pinned to upc.apl.unified to avoid touching unrelated
|
||||
-- rules that might have been added later under other proceeding types.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- 1a. Remove schadensbemessung from merits-track rows.
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = array_remove(dr.applies_to_target, 'schadensbemessung')
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'schadensbemessung' = ANY(dr.applies_to_target);
|
||||
|
||||
-- 1b. Remove bucheinsicht from order-track rows.
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = array_remove(dr.applies_to_target, 'bucheinsicht')
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'bucheinsicht' = ANY(dr.applies_to_target);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Sanity check — no row may carry the new targets after the down.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
schad_left int;
|
||||
buch_left int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO schad_left
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'schadensbemessung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO buch_left
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'bucheinsicht' = ANY(dr.applies_to_target);
|
||||
|
||||
IF schad_left > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry schadensbemessung', schad_left;
|
||||
END IF;
|
||||
IF buch_left > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry bucheinsicht', buch_left;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 138 DOWN] stripped schadensbemessung + bucheinsicht from upc.apl.unified rules';
|
||||
END $$;
|
||||
@@ -0,0 +1,232 @@
|
||||
-- 138_appeal_target_backfill_merits_order — t-paliad-303, m/paliad#134
|
||||
--
|
||||
-- Slice B1 (mig 134) introduced the unified upc.apl.unified proceeding type
|
||||
-- with 5 appeal_target enum values: endentscheidung, kostenentscheidung,
|
||||
-- anordnung, schadensbemessung, bucheinsicht. The first three each carry
|
||||
-- rules; schadensbemessung and bucheinsicht returned an empty timeline
|
||||
-- because no rules referenced them yet.
|
||||
--
|
||||
-- m's 2026-05-26 decision (#134): extend applies_to_target on the existing
|
||||
-- rules — Schadensbemessung := merits track (R.224 anchored on R.118
|
||||
-- substantive decisions), Bucheinsicht := order track (R.220.2 +
|
||||
-- R.224.2.b + R.235.2 + R.237 + R.238.2 etc.). Legal premise verified
|
||||
-- against the 16 live rules — every endentscheidung rule is a generic
|
||||
-- R.224 merits step, every anordnung rule is a generic R.220/224/235/237/
|
||||
-- 238 order step. No rule carries content specific to a particular kind
|
||||
-- of underlying decision/order. Audit on the comment trail of #134.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
|
||||
-- paliad.deadline_rules — both UPDATEs below trigger it).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 138: t-paliad-303 — extend applies_to_target for Schadensbemessung (merits) + Bucheinsicht (order) per m/paliad#134',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Audit-first DO block.
|
||||
--
|
||||
-- Resolve upc.apl.unified, count the rows we are about to touch, and
|
||||
-- RAISE EXCEPTION if anything looks wrong (proceeding type missing,
|
||||
-- merits/order rule counts off, or a rule already carries the new
|
||||
-- target — which would mean an earlier partial run).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
upc_apl_id int;
|
||||
merits_count int;
|
||||
order_count int;
|
||||
schad_already int;
|
||||
buch_already int;
|
||||
BEGIN
|
||||
SELECT id INTO upc_apl_id
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.unified';
|
||||
IF upc_apl_id IS NULL THEN
|
||||
RAISE EXCEPTION '[mig 138] upc.apl.unified proceeding_type not found — mig 134 must run first';
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 138] upc.apl.unified proceeding_type_id = %', upc_apl_id;
|
||||
|
||||
SELECT COUNT(*) INTO merits_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'endentscheidung' = ANY(applies_to_target);
|
||||
SELECT COUNT(*) INTO order_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'anordnung' = ANY(applies_to_target);
|
||||
|
||||
RAISE NOTICE '[mig 138] live counts: endentscheidung=% anordnung=%', merits_count, order_count;
|
||||
IF merits_count <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] expected 7 endentscheidung rules under upc.apl.unified, got %', merits_count;
|
||||
END IF;
|
||||
IF order_count <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] expected 7 anordnung rules under upc.apl.unified, got %', order_count;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO schad_already
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'schadensbemessung' = ANY(applies_to_target);
|
||||
SELECT COUNT(*) INTO buch_already
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'bucheinsicht' = ANY(applies_to_target);
|
||||
IF schad_already > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138] % rules already carry schadensbemessung — partial run?', schad_already;
|
||||
END IF;
|
||||
IF buch_already > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138] % rules already carry bucheinsicht — partial run?', buch_already;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 138] rules to extend with schadensbemessung (merits track):';
|
||||
FOR rec IN
|
||||
SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.proceeding_type_id = upc_apl_id
|
||||
AND dr.is_active = true
|
||||
AND 'endentscheidung' = ANY(dr.applies_to_target)
|
||||
ORDER BY dr.sequence_order, dr.rule_code NULLS LAST
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 138] merits % % % pre=% → post=%',
|
||||
COALESCE(rec.rule_code, '(no-code)'),
|
||||
COALESCE(rec.legal_source, '(no-source)'),
|
||||
rec.name,
|
||||
rec.applies_to_target,
|
||||
rec.applies_to_target || 'schadensbemessung'::text;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE '[mig 138] rules to extend with bucheinsicht (order track):';
|
||||
FOR rec IN
|
||||
SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.proceeding_type_id = upc_apl_id
|
||||
AND dr.is_active = true
|
||||
AND 'anordnung' = ANY(dr.applies_to_target)
|
||||
ORDER BY dr.sequence_order, dr.rule_code NULLS LAST
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 138] order % % % pre=% → post=%',
|
||||
COALESCE(rec.rule_code, '(no-code)'),
|
||||
COALESCE(rec.legal_source, '(no-source)'),
|
||||
rec.name,
|
||||
rec.applies_to_target,
|
||||
rec.applies_to_target || 'bucheinsicht'::text;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Extend applies_to_target.
|
||||
--
|
||||
-- Narrow WHERE clauses key off upc.apl.unified + existing target +
|
||||
-- absence of new target, so the UPDATEs are idempotent in spirit
|
||||
-- (the audit block above already RAISE EXCEPTIONed if any row
|
||||
-- already had the new value).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- 2a. Schadensbemessung := merits track (7 rules expected).
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = applies_to_target || 'schadensbemessung'::text
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'endentscheidung' = ANY(dr.applies_to_target)
|
||||
AND NOT ('schadensbemessung' = ANY(dr.applies_to_target));
|
||||
|
||||
-- 2b. Bucheinsicht := order track (7 rules expected).
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = applies_to_target || 'bucheinsicht'::text
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'anordnung' = ANY(dr.applies_to_target)
|
||||
AND NOT ('bucheinsicht' = ANY(dr.applies_to_target));
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Post-migration sanity check.
|
||||
--
|
||||
-- Hard-fail on any divergence: the two new targets must each cover
|
||||
-- 7 rules, the original three targets must be unchanged in count,
|
||||
-- and no rule has lost its prior target.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
schad_post int;
|
||||
buch_post int;
|
||||
end_post int;
|
||||
anord_post int;
|
||||
cost_post int;
|
||||
target_distribution record;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO schad_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'schadensbemessung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO buch_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'bucheinsicht' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO end_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'endentscheidung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO anord_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'anordnung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO cost_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'kostenentscheidung' = ANY(dr.applies_to_target);
|
||||
|
||||
RAISE NOTICE '[mig 138] post: schadensbemessung=% bucheinsicht=% endentscheidung=% anordnung=% kostenentscheidung=%',
|
||||
schad_post, buch_post, end_post, anord_post, cost_post;
|
||||
|
||||
IF schad_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — expected 7 schadensbemessung rules, got %', schad_post;
|
||||
END IF;
|
||||
IF buch_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — expected 7 bucheinsicht rules, got %', buch_post;
|
||||
END IF;
|
||||
IF end_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — endentscheidung count drifted: expected 7, got %', end_post;
|
||||
END IF;
|
||||
IF anord_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — anordnung count drifted: expected 7, got %', anord_post;
|
||||
END IF;
|
||||
IF cost_post <> 2 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — kostenentscheidung count drifted: expected 2, got %', cost_post;
|
||||
END IF;
|
||||
|
||||
FOR target_distribution IN
|
||||
SELECT unnest(applies_to_target) AS target, COUNT(*) AS n
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true
|
||||
GROUP BY unnest(applies_to_target)
|
||||
ORDER BY 1
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 138] post: applies_to_target=% count=%',
|
||||
target_distribution.target, target_distribution.n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -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 $$;
|
||||
47
internal/db/migrations/140_drop_deadline_rules.down.sql
Normal file
47
internal/db/migrations/140_drop_deadline_rules.down.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- 140_drop_deadline_rules (down) — Slice B.4, t-paliad-305
|
||||
--
|
||||
-- Best-effort recovery from the deadline_rules_pre_140 snapshot. The
|
||||
-- original triggers (mig 079 audit), indexes, CHECK constraints (mig
|
||||
-- 135 primary_party), and FK constraints on the new tables are NOT
|
||||
-- recreated here — restoring the working state requires replaying
|
||||
-- migrations 078/079/091/095/098/122/128/134/135 against the restored
|
||||
-- table.
|
||||
--
|
||||
-- Use this only for catastrophic recovery. The normal revert path
|
||||
-- for B.4 is to re-deploy the previous container image (which still
|
||||
-- writes via the dual-write helper to a paliad.deadline_rules that no
|
||||
-- longer exists) — that would crash on first write, so true revert
|
||||
-- requires this down + a code revert + a snapshot restore.
|
||||
|
||||
-- Drop the INSTEAD OF triggers + functions
|
||||
DROP TRIGGER IF EXISTS deadline_rules_unified_insert ON paliad.deadline_rules_unified;
|
||||
DROP TRIGGER IF EXISTS deadline_rules_unified_update ON paliad.deadline_rules_unified;
|
||||
DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_insert_trigger();
|
||||
DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_update_trigger();
|
||||
|
||||
-- Recreate paliad.deadline_rules from snapshot.
|
||||
CREATE TABLE paliad.deadline_rules AS TABLE paliad.deadline_rules_pre_140;
|
||||
|
||||
-- Re-add the PK constraint (CREATE TABLE AS doesn't carry constraints).
|
||||
ALTER TABLE paliad.deadline_rules ADD PRIMARY KEY (id);
|
||||
|
||||
-- Re-point the FKs back to deadline_rules.
|
||||
ALTER TABLE paliad.appointments
|
||||
DROP CONSTRAINT IF EXISTS appointments_deadline_rule_id_fkey;
|
||||
ALTER TABLE paliad.appointments
|
||||
ADD CONSTRAINT appointments_deadline_rule_id_fkey
|
||||
FOREIGN KEY (deadline_rule_id) REFERENCES paliad.deadline_rules(id);
|
||||
|
||||
ALTER TABLE paliad.deadline_rule_backfill_orphans
|
||||
DROP CONSTRAINT IF EXISTS deadline_rule_backfill_orphans_resolved_rule_id_fkey;
|
||||
ALTER TABLE paliad.deadline_rule_backfill_orphans
|
||||
ADD CONSTRAINT deadline_rule_backfill_orphans_resolved_rule_id_fkey
|
||||
FOREIGN KEY (resolved_rule_id) REFERENCES paliad.deadline_rules(id);
|
||||
|
||||
-- Re-add deadlines.rule_id from the snapshot's data (via sequencing_rule_id
|
||||
-- which inherited deadline_rules.id during mig 136).
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN rule_id uuid;
|
||||
UPDATE paliad.deadlines SET rule_id = sequencing_rule_id WHERE sequencing_rule_id IS NOT NULL;
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD CONSTRAINT fristen_rule_id_fkey
|
||||
FOREIGN KEY (rule_id) REFERENCES paliad.deadline_rules(id);
|
||||
448
internal/db/migrations/140_drop_deadline_rules.up.sql
Normal file
448
internal/db/migrations/140_drop_deadline_rules.up.sql
Normal file
@@ -0,0 +1,448 @@
|
||||
-- 140_drop_deadline_rules — Slice B.4 destructive drop (t-paliad-305 / m/paliad#93)
|
||||
--
|
||||
-- HARD STOPS:
|
||||
-- * Audit-first: snapshot paliad.deadline_rules → paliad.deadline_rules_pre_140
|
||||
-- in the SAME TRANSACTION as the DROP, per m's snapshot policy
|
||||
-- (precedent migs 091/093/095/098). The whole .up.sql runs inside a
|
||||
-- single transaction because the migration runner wraps it; if any
|
||||
-- statement fails, the snapshot CREATE TABLE rolls back with the
|
||||
-- destructive DROP.
|
||||
-- * No data loss: paliad.deadline_rules has been a write-side shadow
|
||||
-- since B.3 (B.2 dual-write keeps sequencing_rules + procedural_events
|
||||
-- + legal_sources current). Drift verified clean before this slice
|
||||
-- (deadline_rules=231, sequencing_rules=231, 0 mismatches across
|
||||
-- counts/FKs/lifecycle/is_active).
|
||||
--
|
||||
-- What this migration does:
|
||||
-- 1. Snapshot deadline_rules → deadline_rules_pre_140 (preserves audit
|
||||
-- trail of the table's final state for forensic + revert paths).
|
||||
-- 2. Final reconciliation: catch any deadlines whose
|
||||
-- sequencing_rule_id/procedural_event_id columns drifted from the
|
||||
-- legacy rule_id (no live drift today — defensive).
|
||||
-- 3. Drop the audit trigger on deadline_rules (it can't fire on a
|
||||
-- gone table; the trigger function itself stays for the historical
|
||||
-- paliad.deadline_rule_audit reads).
|
||||
-- 4. Re-point FKs that currently target deadline_rules.id over to
|
||||
-- sequencing_rules.id. The id values are identical (sequencing_rules
|
||||
-- inherited deadline_rules.id during mig 136 backfill), so no data
|
||||
-- migration is needed — just the constraint swap. Affects:
|
||||
-- - paliad.appointments.deadline_rule_id
|
||||
-- - paliad.deadline_rule_backfill_orphans.resolved_rule_id
|
||||
-- 5. Drop paliad.deadlines.rule_id column. Per design §5.4 step 16:
|
||||
-- "DROP COLUMN paliad.deadlines.rule_id (keep rule_code +
|
||||
-- custom_rule_text as the human-readable denormalized columns —
|
||||
-- they're the safety net for orphaned deadlines per t-paliad-258)."
|
||||
-- The new sequencing_rule_id + procedural_event_id columns from
|
||||
-- mig 136 are the FK back-links from B.4 forward.
|
||||
-- 6. DROP TABLE paliad.deadline_rules.
|
||||
-- 7. INSTEAD OF triggers on paliad.deadline_rules_unified that route
|
||||
-- INSERTs/UPDATEs to the underlying sr+pe+ls tables. Lets the
|
||||
-- RuleEditorService keep its existing SQL shape (one INSERT, one
|
||||
-- UPDATE per write method) with only a table-name swap. The
|
||||
-- triggers project the legacy column shape back to the three new
|
||||
-- tables exactly as the dual-write helper did in B.2.
|
||||
--
|
||||
-- Down: best-effort restore from the snapshot. The original triggers,
|
||||
-- indexes, and FKs are NOT recreated — operator must replay historical
|
||||
-- migrations 078/079/091/095/098/122 to bring the table back to a
|
||||
-- working shape. The down path is for catastrophic recovery, not casual
|
||||
-- revert.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Snapshot — must precede the destructive ops (same TX).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rules_pre_140 IS
|
||||
'Snapshot of paliad.deadline_rules taken in mig 140 (Slice B.4, '
|
||||
't-paliad-305) before the destructive DROP. Mirrors precedent '
|
||||
'pre_091/093/095/098. Read-only forensic + revert source.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Final reconciliation — should be a no-op (drift was 0 going
|
||||
-- into this slice). Belt-and-braces against a write that snuck
|
||||
-- in between drift-check and this migration.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET sequencing_rule_id = d.rule_id,
|
||||
procedural_event_id = sr.procedural_event_id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.id = d.rule_id
|
||||
AND d.rule_id IS NOT NULL
|
||||
AND (d.sequencing_rule_id IS DISTINCT FROM d.rule_id
|
||||
OR d.procedural_event_id IS DISTINCT FROM sr.procedural_event_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Drop the deadline_rules audit trigger. The trigger function
|
||||
-- (paliad.deadline_rule_audit_trigger) stays defined for any
|
||||
-- historical references; mig 079 created it.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Re-point FKs from deadline_rules → sequencing_rules.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.appointments
|
||||
DROP CONSTRAINT IF EXISTS appointments_deadline_rule_id_fkey;
|
||||
ALTER TABLE paliad.appointments
|
||||
ADD CONSTRAINT appointments_deadline_rule_id_fkey
|
||||
FOREIGN KEY (deadline_rule_id) REFERENCES paliad.sequencing_rules(id);
|
||||
|
||||
ALTER TABLE paliad.deadline_rule_backfill_orphans
|
||||
DROP CONSTRAINT IF EXISTS deadline_rule_backfill_orphans_resolved_rule_id_fkey;
|
||||
ALTER TABLE paliad.deadline_rule_backfill_orphans
|
||||
ADD CONSTRAINT deadline_rule_backfill_orphans_resolved_rule_id_fkey
|
||||
FOREIGN KEY (resolved_rule_id) REFERENCES paliad.sequencing_rules(id);
|
||||
|
||||
-- Drop the deadlines→deadline_rules FK before we drop the column.
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP CONSTRAINT IF EXISTS fristen_rule_id_fkey;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. Drop paliad.deadlines.rule_id (column + remaining indexes).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS rule_id;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6a. Drop the deadline_search materialized view, which has a
|
||||
-- direct dependency on paliad.deadline_rules (mig 077). We
|
||||
-- recreate it after the DROP, re-pointed at deadline_rules_unified
|
||||
-- so reads keep working. All 11 indexes are recreated alongside.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6. DROP TABLE paliad.deadline_rules. Now that:
|
||||
-- - dependent FKs are re-pointed to sequencing_rules,
|
||||
-- - the audit trigger is dropped,
|
||||
-- - deadlines.rule_id is gone,
|
||||
-- - the deadline_search matview is gone,
|
||||
-- nothing references the table anymore. The self-FKs
|
||||
-- (deadline_rules.parent_id, .draft_of) drop with the table.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DROP TABLE paliad.deadline_rules;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 7. INSTEAD OF triggers on the view — routes writes to sr+pe+ls.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_insert_trigger()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
v_legal_source_id uuid;
|
||||
v_pe_id uuid;
|
||||
v_code text;
|
||||
BEGIN
|
||||
-- legal_sources upsert (no-op if NEW.legal_source is NULL)
|
||||
IF NEW.legal_source IS NOT NULL THEN
|
||||
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
||||
VALUES (NEW.legal_source,
|
||||
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
|
||||
ON CONFLICT (citation) DO NOTHING;
|
||||
SELECT id INTO v_legal_source_id
|
||||
FROM paliad.legal_sources
|
||||
WHERE citation = NEW.legal_source;
|
||||
END IF;
|
||||
|
||||
-- Mint synthetic code when submission_code is NULL — same recipe
|
||||
-- as mig 136 + B.2 dual-write helper. Stays byte-identical.
|
||||
v_code := COALESCE(NEW.submission_code,
|
||||
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
|
||||
|
||||
-- procedural_events upsert. ON CONFLICT (code) deliberately leaves
|
||||
-- lifecycle_state / published_at / is_active alone — those track
|
||||
-- the procedural-event concept's own lifecycle, not the inserting
|
||||
-- sequencing-rule's lifecycle (e.g. a CloneAsDraft of a published
|
||||
-- rule creates a draft sr that shares the published PE; the PE
|
||||
-- should stay 'published'). Identity columns DO update so an
|
||||
-- admin editing a draft's name still flips the lawyer-visible
|
||||
-- label (1:1 today; revisit when 1:N becomes a real pattern).
|
||||
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)
|
||||
VALUES
|
||||
(v_code, NEW.name, NEW.name_en, NEW.description, NEW.event_type,
|
||||
NEW.primary_party, v_legal_source_id, NEW.concept_id,
|
||||
COALESCE(NEW.lifecycle_state, 'draft'), NEW.published_at,
|
||||
COALESCE(NEW.is_active, true))
|
||||
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 / published_at / is_active deliberately omitted
|
||||
updated_at = now()
|
||||
RETURNING id INTO v_pe_id;
|
||||
|
||||
-- sequencing_rules insert. id is the caller-supplied NEW.id so
|
||||
-- existing FK back-links (deadlines.sequencing_rule_id) resolve.
|
||||
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)
|
||||
VALUES
|
||||
(NEW.id, v_pe_id, NEW.proceeding_type_id, NEW.parent_id, NEW.trigger_event_id,
|
||||
COALESCE(NEW.duration_value, 0), COALESCE(NEW.duration_unit, 'months'),
|
||||
COALESCE(NEW.timing, 'after'),
|
||||
NEW.alt_duration_value, NEW.alt_duration_unit, NEW.alt_rule_code, NEW.anchor_alt,
|
||||
NEW.combine_op, NEW.condition_expr, NEW.primary_party,
|
||||
COALESCE(NEW.sequence_order, 0),
|
||||
COALESCE(NEW.is_spawn, false), NEW.spawn_label, NEW.spawn_proceeding_type_id,
|
||||
COALESCE(NEW.is_bilateral, false), COALESCE(NEW.is_court_set, false),
|
||||
COALESCE(NEW.priority, 'mandatory'),
|
||||
NEW.rule_code, NEW.rule_codes, NEW.deadline_notes, NEW.deadline_notes_en,
|
||||
NEW.choices_offered, NEW.applies_to_target,
|
||||
COALESCE(NEW.lifecycle_state, 'draft'), NEW.draft_of,
|
||||
NEW.published_at, COALESCE(NEW.is_active, true),
|
||||
COALESCE(NEW.created_at, now()), COALESCE(NEW.updated_at, now()));
|
||||
|
||||
RETURN NEW;
|
||||
END $fn$;
|
||||
|
||||
CREATE TRIGGER deadline_rules_unified_insert
|
||||
INSTEAD OF INSERT ON paliad.deadline_rules_unified
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_insert_trigger();
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_update_trigger()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
v_legal_source_id uuid;
|
||||
v_code text;
|
||||
BEGIN
|
||||
-- legal_sources upsert (only if NEW.legal_source is non-NULL).
|
||||
-- A change FROM non-NULL TO NULL clears legal_source_id on the
|
||||
-- procedural_event below — same shape as mig 136 / B.2 behaviour.
|
||||
IF NEW.legal_source IS NOT NULL THEN
|
||||
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
||||
VALUES (NEW.legal_source,
|
||||
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
|
||||
ON CONFLICT (citation) DO NOTHING;
|
||||
SELECT id INTO v_legal_source_id
|
||||
FROM paliad.legal_sources
|
||||
WHERE citation = NEW.legal_source;
|
||||
END IF;
|
||||
|
||||
v_code := COALESCE(NEW.submission_code,
|
||||
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
|
||||
|
||||
-- Update procedural_events keyed by the existing PE link on
|
||||
-- sequencing_rules. lifecycle_state / published_at / is_active on
|
||||
-- PE are NOT mirrored from the per-sequencing-rule UPDATE — see
|
||||
-- the INSERT trigger comment for the rationale (a draft sr that
|
||||
-- shares its PE with a published peer must not flip the PE to
|
||||
-- draft). Identity columns DO mirror so editing name/code from
|
||||
-- the admin UI continues to reach the lawyer-visible label.
|
||||
UPDATE paliad.procedural_events
|
||||
SET code = v_code,
|
||||
name = NEW.name,
|
||||
name_en = NEW.name_en,
|
||||
description = NEW.description,
|
||||
event_kind = NEW.event_type,
|
||||
primary_party_default = NEW.primary_party,
|
||||
legal_source_id = v_legal_source_id,
|
||||
concept_id = NEW.concept_id,
|
||||
updated_at = now()
|
||||
WHERE id = (SELECT procedural_event_id
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id = NEW.id);
|
||||
|
||||
-- Update sequencing_rules (1:1 by id).
|
||||
UPDATE paliad.sequencing_rules
|
||||
SET proceeding_type_id = NEW.proceeding_type_id,
|
||||
parent_id = NEW.parent_id,
|
||||
trigger_event_id = NEW.trigger_event_id,
|
||||
duration_value = NEW.duration_value,
|
||||
duration_unit = NEW.duration_unit,
|
||||
timing = NEW.timing,
|
||||
alt_duration_value = NEW.alt_duration_value,
|
||||
alt_duration_unit = NEW.alt_duration_unit,
|
||||
alt_rule_code = NEW.alt_rule_code,
|
||||
anchor_alt = NEW.anchor_alt,
|
||||
combine_op = NEW.combine_op,
|
||||
condition_expr = NEW.condition_expr,
|
||||
primary_party = NEW.primary_party,
|
||||
sequence_order = NEW.sequence_order,
|
||||
is_spawn = NEW.is_spawn,
|
||||
spawn_label = NEW.spawn_label,
|
||||
spawn_proceeding_type_id = NEW.spawn_proceeding_type_id,
|
||||
is_bilateral = NEW.is_bilateral,
|
||||
is_court_set = NEW.is_court_set,
|
||||
priority = NEW.priority,
|
||||
rule_code = NEW.rule_code,
|
||||
rule_codes = NEW.rule_codes,
|
||||
deadline_notes = NEW.deadline_notes,
|
||||
deadline_notes_en = NEW.deadline_notes_en,
|
||||
choices_offered = NEW.choices_offered,
|
||||
applies_to_target = NEW.applies_to_target,
|
||||
lifecycle_state = NEW.lifecycle_state,
|
||||
draft_of = NEW.draft_of,
|
||||
published_at = NEW.published_at,
|
||||
is_active = NEW.is_active,
|
||||
updated_at = now()
|
||||
WHERE id = NEW.id;
|
||||
|
||||
RETURN NEW;
|
||||
END $fn$;
|
||||
|
||||
CREATE TRIGGER deadline_rules_unified_update
|
||||
INSTEAD OF UPDATE ON paliad.deadline_rules_unified
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_update_trigger();
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 8. POST assertions.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_snapshot_count int;
|
||||
v_sr_count int;
|
||||
v_view_count int;
|
||||
v_dr_table_exists int;
|
||||
v_rule_id_col int;
|
||||
BEGIN
|
||||
-- B.2 dual-write was implemented only for the active+published lifecycle
|
||||
-- (the scope of the read paths and B.4's pre-flip drift check). Archived
|
||||
-- + draft rows in deadline_rules were never replicated to sequencing_rules
|
||||
-- (they had no production read path). Snapshot includes them all (CREATE
|
||||
-- TABLE AS is unfiltered), so we compare on the same filter B.2 actually
|
||||
-- maintained. Drafts/archived rows are preserved in paliad.deadline_rules_pre_140
|
||||
-- for forensic + future-backfill use.
|
||||
SELECT COUNT(*) INTO v_snapshot_count
|
||||
FROM paliad.deadline_rules_pre_140
|
||||
WHERE is_active = true AND lifecycle_state = 'published';
|
||||
SELECT COUNT(*) INTO v_sr_count
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE is_active = true AND lifecycle_state = 'published';
|
||||
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
|
||||
IF v_snapshot_count <> v_sr_count THEN
|
||||
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot active+published has % rows, sequencing_rules active+published has % rows — dual-write drift',
|
||||
v_snapshot_count, v_sr_count;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_dr_table_exists
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'paliad' AND table_name = 'deadline_rules';
|
||||
IF v_dr_table_exists > 0 THEN
|
||||
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadline_rules table still exists after DROP';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_rule_id_col
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad' AND table_name = 'deadlines' AND column_name = 'rule_id';
|
||||
IF v_rule_id_col > 0 THEN
|
||||
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadlines.rule_id column still exists after DROP';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 140] OK — deadline_rules dropped, snapshot=% rows, sequencing_rules=% rows, view (filtered)=% rows, INSTEAD OF triggers active',
|
||||
v_snapshot_count, v_sr_count, v_view_count;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 8. Recreate paliad.deadline_search materialized view against
|
||||
-- deadline_rules_unified (same column shape — sr.id is the new
|
||||
-- dr.id, etc.). Definition mirrors mig 077; only the FROM table
|
||||
-- name changes. All 11 indexes restored.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||
SELECT 'rule'::text AS kind,
|
||||
('r:'::text || (dr.id)::text) AS row_key,
|
||||
dc.id AS concept_id,
|
||||
dc.slug AS concept_slug,
|
||||
dc.name_de AS concept_name_de,
|
||||
dc.name_en AS concept_name_en,
|
||||
dc.description AS concept_description,
|
||||
dc.aliases AS concept_aliases,
|
||||
dc.party AS concept_party,
|
||||
dc.category AS concept_category,
|
||||
dc.sort_order AS concept_sort_order,
|
||||
dr.id AS rule_id,
|
||||
NULL::bigint AS trigger_event_id,
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name_de,
|
||||
pt.name_en AS proceeding_name_en,
|
||||
pt.jurisdiction,
|
||||
pt.display_order AS proceeding_display_order,
|
||||
dr.submission_code AS rule_local_code,
|
||||
dr.name AS rule_name_de,
|
||||
dr.name_en AS rule_name_en,
|
||||
dr.legal_source,
|
||||
dr.rule_code,
|
||||
dr.duration_value,
|
||||
dr.duration_unit,
|
||||
dr.timing,
|
||||
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||
FROM paliad.deadline_rules_unified dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
|
||||
WHERE dr.is_active AND pt.is_active AND pt.category = 'fristenrechner'::text
|
||||
UNION ALL
|
||||
SELECT 'trigger'::text AS kind,
|
||||
('t:'::text || (te.id)::text) AS row_key,
|
||||
dc.id AS concept_id,
|
||||
dc.slug AS concept_slug,
|
||||
dc.name_de AS concept_name_de,
|
||||
dc.name_en AS concept_name_en,
|
||||
dc.description AS concept_description,
|
||||
dc.aliases AS concept_aliases,
|
||||
dc.party AS concept_party,
|
||||
dc.category AS concept_category,
|
||||
dc.sort_order AS concept_sort_order,
|
||||
NULL::uuid AS rule_id,
|
||||
te.id AS trigger_event_id,
|
||||
NULL::text AS proceeding_code,
|
||||
NULL::text AS proceeding_name_de,
|
||||
NULL::text AS proceeding_name_en,
|
||||
'cross-cutting'::text AS jurisdiction,
|
||||
9999 AS proceeding_display_order,
|
||||
te.code AS rule_local_code,
|
||||
te.name_de AS rule_name_de,
|
||||
te.name AS rule_name_en,
|
||||
dr_trig.legal_source,
|
||||
NULL::text AS rule_code,
|
||||
NULL::integer AS duration_value,
|
||||
NULL::text AS duration_unit,
|
||||
NULL::text AS timing,
|
||||
dc.party AS effective_party
|
||||
FROM paliad.trigger_events te
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||
LEFT JOIN paliad.deadline_rules_unified dr_trig
|
||||
ON dr_trig.trigger_event_id = te.id
|
||||
AND dr_trig.proceeding_type_id IS NULL
|
||||
AND dr_trig.is_active
|
||||
AND dr_trig.lifecycle_state = 'published'::text
|
||||
WHERE te.is_active
|
||||
WITH NO DATA;
|
||||
|
||||
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
|
||||
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
|
||||
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
|
||||
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
|
||||
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
|
||||
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
|
||||
|
||||
REFRESH MATERIALIZED VIEW paliad.deadline_search;
|
||||
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).';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- t-paliad-315: revert building blocks library.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_building_block_admin_versions;
|
||||
DROP TABLE IF EXISTS paliad.submission_building_blocks;
|
||||
118
internal/db/migrations/149_submission_building_blocks.up.sql
Normal file
118
internal/db/migrations/149_submission_building_blocks.up.sql
Normal file
@@ -0,0 +1,118 @@
|
||||
-- t-paliad-315 (m/paliad#141): Composer Slice C — building blocks library.
|
||||
--
|
||||
-- Per the design at docs/design-submission-generator-v2-2026-05-26.md §4.4
|
||||
-- and the Q2 / Q9 ratifications:
|
||||
--
|
||||
-- Q2 (m, 2026-05-26): building blocks are plain text paste sources.
|
||||
-- No building_block_id reference is stored on submission_sections —
|
||||
-- insertion is a one-way copy of content_md_<lang> into the section.
|
||||
-- This table records the library; submission_sections doesn't know
|
||||
-- where its content came from.
|
||||
--
|
||||
-- Q9 (m, 2026-05-26): four visibility tiers — private / team / firm
|
||||
-- / global. Picker filtering and RLS SELECT predicate both honour
|
||||
-- the tier. Tier upgrades (private → team/firm/global) go through
|
||||
-- admin moderation in later slices; Slice C starts with admin-only
|
||||
-- mutations (no user-initiated rows yet).
|
||||
--
|
||||
-- The _admin_versions companion table mirrors the email-templates
|
||||
-- retention=20 audit history. It is INTERNAL to the admin editor —
|
||||
-- not referenced from submission_sections, not exposed to the lawyer.
|
||||
-- It exists so accidental delete + accidental overwrite are
|
||||
-- recoverable.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_building_blocks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL,
|
||||
firm text, -- e.g. 'HLC', NULL = cross-firm
|
||||
section_key text NOT NULL, -- which section kind this block fits
|
||||
proceeding_family text, -- 'de.inf.lg', NULL = any family
|
||||
title_de text NOT NULL,
|
||||
title_en text NOT NULL,
|
||||
description_de text,
|
||||
description_en text,
|
||||
content_md_de text NOT NULL DEFAULT '',
|
||||
content_md_en text NOT NULL DEFAULT '',
|
||||
author_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
visibility text NOT NULL, -- 'private' | 'team' | 'firm' | 'global'
|
||||
is_published bool NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
|
||||
CONSTRAINT submission_building_blocks_visibility_check
|
||||
CHECK (visibility IN ('private', 'team', 'firm', 'global')),
|
||||
CONSTRAINT submission_building_blocks_unique_slug_per_firm
|
||||
UNIQUE (slug, firm)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_building_blocks_section_visibility_idx
|
||||
ON paliad.submission_building_blocks (section_key, visibility, firm, proceeding_family)
|
||||
WHERE deleted_at IS NULL AND is_published;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_building_blocks_author_idx
|
||||
ON paliad.submission_building_blocks (author_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
ALTER TABLE paliad.submission_building_blocks ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT policy: coarse-grained RLS that admits every non-deleted
|
||||
-- block to any authenticated user. The Go-side BuildingBlockService
|
||||
-- applies the fine-grained tier predicate (private / team / firm /
|
||||
-- global) using branding.Name + team-membership joins. This split
|
||||
-- keeps the SQL simple and lets the tier semantics evolve in code
|
||||
-- without RLS migrations.
|
||||
--
|
||||
-- The exception below is 'private': only the author sees their own
|
||||
-- private rows. That's the hard line where a tier upgrade is
|
||||
-- substantive enough to warrant DB-level enforcement.
|
||||
DROP POLICY IF EXISTS submission_building_blocks_select ON paliad.submission_building_blocks;
|
||||
CREATE POLICY submission_building_blocks_select
|
||||
ON paliad.submission_building_blocks FOR SELECT TO authenticated
|
||||
USING (
|
||||
deleted_at IS NULL
|
||||
AND (
|
||||
visibility <> 'private'
|
||||
OR author_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT / UPDATE / DELETE intentionally absent — admin mutations
|
||||
-- happen at the Go handler layer with explicit adminGate. RLS without
|
||||
-- mutation policies denies them by default.
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_building_blocks_set_updated_at ON paliad.submission_building_blocks;
|
||||
CREATE TRIGGER submission_building_blocks_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_building_blocks
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_building_blocks IS
|
||||
't-paliad-315: Composer building-block library. Plain text paste sources for section content (no lineage tracked on sections per Q2 ratification). 4-tier visibility per Q9.';
|
||||
|
||||
|
||||
-- _admin_versions: append-only history per block. Admin-side only;
|
||||
-- not referenced from submission_sections. Retention 20 per block,
|
||||
-- GCed in the same transaction as the Save (mirrors email-templates).
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_building_block_admin_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
building_block_id uuid NOT NULL REFERENCES paliad.submission_building_blocks(id) ON DELETE CASCADE,
|
||||
content_md_de text NOT NULL,
|
||||
content_md_en text NOT NULL,
|
||||
title_de text NOT NULL,
|
||||
title_en text NOT NULL,
|
||||
edited_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_building_block_admin_versions_block_idx
|
||||
ON paliad.submission_building_block_admin_versions (building_block_id, created_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_building_block_admin_versions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Admin-only audit; the handler layer gates this via adminGate and
|
||||
-- writes via SECURITY DEFINER paths or admin-role SQL. No RLS SELECT
|
||||
-- policy exists, so non-admin users get an empty result set.
|
||||
|
||||
COMMENT ON TABLE paliad.submission_building_block_admin_versions IS
|
||||
't-paliad-315: append-only history per building block. Admin-side only; retention 20 rows per block, GCed at Save time.';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-317: revert specialist base seed rows.
|
||||
|
||||
DELETE FROM paliad.submission_bases WHERE slug IN ('lg-duesseldorf', 'upc-formal');
|
||||
128
internal/db/migrations/150_submission_bases_specialist.up.sql
Normal file
128
internal/db/migrations/150_submission_bases_specialist.up.sql
Normal file
@@ -0,0 +1,128 @@
|
||||
-- t-paliad-317 (m/paliad#141): Composer Slice E — specialist bases.
|
||||
--
|
||||
-- Two firm-agnostic bases for proceeding-family-specific styling:
|
||||
--
|
||||
-- lg-duesseldorf — DE LG (de.inf.lg) conservative German legal style.
|
||||
-- Times New Roman 11pt; black headings.
|
||||
-- upc-formal — UPC court of first instance (upc.inf.cfi) formal
|
||||
-- style. Calibri 11pt body; UPC-blue (1F3864) headings;
|
||||
-- Cambria italic for blockquotes.
|
||||
--
|
||||
-- The .docx body for each is a minimal Composer-mode skeleton with
|
||||
-- the 10 default section anchors and an empty rels envelope. The
|
||||
-- styles.xml declares the {prefix}-Body / -Heading1/2/3 / -ListBullet
|
||||
-- / -ListNumber / -Quote paragraph styles + a "Hyperlink" character
|
||||
-- style (matches the MD walker's emitted r:id="rIdComposerN" link
|
||||
-- runs from Slice D).
|
||||
--
|
||||
-- Generator: scripts/gen-submission-base/main.go (each preset hard-
|
||||
-- codes the typography). The .docx files are uploaded to Gitea at
|
||||
-- 6 - material/Templates/Word/Paliad/Composer/{slug}.docx as mAi.
|
||||
--
|
||||
-- The mig is additive only: ON CONFLICT (slug) DO NOTHING keeps a
|
||||
-- re-run safe and existing rows untouched.
|
||||
|
||||
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
|
||||
('lg-duesseldorf', NULL, 'de.inf.lg',
|
||||
'LG-Düsseldorf-Stil', 'LG-Düsseldorf style',
|
||||
'Konservativer DE-LG-Stil: Times New Roman 11pt, schlichte Überschriften.',
|
||||
'Conservative DE LG style: Times New Roman 11pt, plain headings.',
|
||||
'6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx',
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'stylemap', jsonb_build_object(
|
||||
'paragraph', 'LG-Body',
|
||||
'heading_1', 'LG-Heading1',
|
||||
'heading_2', 'LG-Heading2',
|
||||
'heading_3', 'LG-Heading3',
|
||||
'list_bullet', 'LG-ListBullet',
|
||||
'list_numbered', 'LG-ListNumber',
|
||||
'blockquote', 'LG-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}}\n{{project.court}}',
|
||||
'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}}\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}}',
|
||||
'seed_md_en', E'{{user.display_name}}')
|
||||
)
|
||||
),
|
||||
'{}'::text[]
|
||||
),
|
||||
('upc-formal', NULL, 'upc.inf.cfi',
|
||||
'UPC-Verfahren', 'UPC formal',
|
||||
'UPC-Verfahrensstil: Calibri 11pt, UPC-blaue Überschriften, Cambria-Zitate.',
|
||||
'UPC court style: Calibri 11pt, UPC-blue headings, Cambria quotes.',
|
||||
'6 - material/Templates/Word/Paliad/Composer/upc-formal.docx',
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'stylemap', jsonb_build_object(
|
||||
'paragraph', 'UPC-Body',
|
||||
'heading_1', 'UPC-Heading1',
|
||||
'heading_2', 'UPC-Heading2',
|
||||
'heading_3', 'UPC-Heading3',
|
||||
'list_bullet', 'UPC-ListBullet',
|
||||
'list_numbered', 'UPC-ListNumber',
|
||||
'blockquote', 'UPC-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 the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC-Aktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}',
|
||||
'seed_md_en', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC case number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}'),
|
||||
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,31 @@
|
||||
-- 151_dedupe_null_procedural_events (down) — t-paliad-319 / m/paliad#144
|
||||
--
|
||||
-- Best-effort restore from paliad.procedural_events_pre_151 and
|
||||
-- paliad.sequencing_rules_pre_151. Re-points the reparented
|
||||
-- sequencing_rules back at their original procedural_event_id and
|
||||
-- reactivates the archived duplicates with the lifecycle_state +
|
||||
-- is_active they had before the up migration.
|
||||
--
|
||||
-- Catastrophic-recovery path only; the normal revert is to leave the
|
||||
-- dedupe in place (it is purely cosmetic).
|
||||
|
||||
-- 1. Re-point sequencing_rules.procedural_event_id back to its
|
||||
-- pre-mig-151 value. The snapshot row is keyed by sr.id so the
|
||||
-- join is 1:1 and idempotent.
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET procedural_event_id = s.original_procedural_event_id,
|
||||
updated_at = now()
|
||||
FROM paliad.sequencing_rules_pre_151 s
|
||||
WHERE sr.id = s.id;
|
||||
|
||||
-- 2. Reactivate the archived duplicates with their snapshot lifecycle.
|
||||
UPDATE paliad.procedural_events pe
|
||||
SET is_active = s.is_active,
|
||||
lifecycle_state = s.lifecycle_state,
|
||||
updated_at = now()
|
||||
FROM paliad.procedural_events_pre_151 s
|
||||
WHERE pe.id = s.id;
|
||||
|
||||
-- 3. Drop the snapshot tables — the data is back in place.
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_151;
|
||||
DROP TABLE IF EXISTS paliad.procedural_events_pre_151;
|
||||
229
internal/db/migrations/151_dedupe_null_procedural_events.up.sql
Normal file
229
internal/db/migrations/151_dedupe_null_procedural_events.up.sql
Normal file
@@ -0,0 +1,229 @@
|
||||
-- 151_dedupe_null_procedural_events — t-paliad-319 / m/paliad#144
|
||||
--
|
||||
-- Purpose: ~14 paliad.procedural_events rows with synthetic null.<8hex>
|
||||
-- codes (minted by mig 136 from the legacy paliad.deadline_rules rows
|
||||
-- whose submission_code was NULL) share user-visible names. The
|
||||
-- /admin/procedural-events list shows multiple entries for the same legal
|
||||
-- concept (worst offender: "Mängelbeseitigung / Zahlung" × 6). This
|
||||
-- migration consolidates every name-group onto a single canonical row,
|
||||
-- reparents the sequencing_rules pointing at the duplicates, and archives
|
||||
-- the duplicates without deleting them.
|
||||
--
|
||||
-- Scope verified live before write (Supabase MCP, 2026-05-26):
|
||||
-- * 5 name-groups, 14 duplicate rows total (1 canonical + 1–5 dups per
|
||||
-- group). Every duplicate has exactly 1 sequencing_rule pointing at it.
|
||||
-- * 0 paliad.deadlines reference any duplicate.
|
||||
-- * 0 procedural_events.draft_of references any duplicate.
|
||||
-- * No audit trigger on procedural_events or sequencing_rules — only
|
||||
-- the INSTEAD OF triggers on deadline_rules_unified (mig 140), which
|
||||
-- do not fire on direct table writes. No set_config('paliad.audit_reason')
|
||||
-- needed.
|
||||
--
|
||||
-- Canonical selection: ROW_NUMBER() OVER (PARTITION BY name ORDER BY
|
||||
-- created_at, id::text). Every duplicate in current data shares the same
|
||||
-- created_at (mig 136 bulk insert), so the deterministic tiebreaker is
|
||||
-- the UUID's lexicographic order.
|
||||
--
|
||||
-- Hard constraints honoured:
|
||||
-- * No deletions. Duplicates flip to is_active=false +
|
||||
-- lifecycle_state='archived'. The rows stay in the table for audit.
|
||||
-- * Reparent sequencing_rules.procedural_event_id duplicate → canonical
|
||||
-- BEFORE archiving, so no FK ever points at an archived PE.
|
||||
-- * Snapshot the affected procedural_events + sequencing_rules into
|
||||
-- paliad.procedural_events_pre_151 / paliad.sequencing_rules_pre_151
|
||||
-- in the same TX, mirroring precedent (migs 091/093/095/098/140).
|
||||
--
|
||||
-- Down: best-effort restore from the snapshots. See .down.sql.
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) in a
|
||||
-- TEMP table used by every subsequent step.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TEMP TABLE tmp_pe_dedupe ON COMMIT DROP AS
|
||||
WITH dupe_names AS (
|
||||
SELECT name
|
||||
FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
GROUP BY name
|
||||
HAVING COUNT(*) > 1
|
||||
),
|
||||
ranked AS (
|
||||
SELECT pe.id,
|
||||
pe.code,
|
||||
pe.name,
|
||||
pe.created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY pe.name
|
||||
ORDER BY pe.created_at, pe.id::text
|
||||
) AS rn
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.code LIKE 'null.%'
|
||||
AND pe.name IN (SELECT name FROM dupe_names)
|
||||
),
|
||||
canonicals AS (
|
||||
SELECT name,
|
||||
id AS canonical_id,
|
||||
code AS canonical_code
|
||||
FROM ranked
|
||||
WHERE rn = 1
|
||||
)
|
||||
SELECT r.id AS duplicate_id,
|
||||
r.code AS duplicate_code,
|
||||
r.name,
|
||||
c.canonical_id,
|
||||
c.canonical_code
|
||||
FROM ranked r
|
||||
JOIN canonicals c ON c.name = r.name
|
||||
WHERE r.rn > 1;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Snapshot. Captures the rows that change so .down has a clean
|
||||
-- source of truth; mirrors the pre_091/093/095/098/140 precedent.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.procedural_events_pre_151 AS
|
||||
SELECT pe.*
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
COMMENT ON TABLE paliad.procedural_events_pre_151 IS
|
||||
'Snapshot (mig 151, t-paliad-319) of the null.* procedural_events '
|
||||
'duplicates that were archived in favour of their canonical name-mate. '
|
||||
'Read-only forensic + revert source. Mirrors precedent pre_091/093/'
|
||||
'095/098/140.';
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules_pre_151 AS
|
||||
SELECT sr.id,
|
||||
sr.procedural_event_id AS original_procedural_event_id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules_pre_151 IS
|
||||
'Snapshot (mig 151, t-paliad-319) of sequencing_rules.procedural_event_id '
|
||||
'before reparenting from null.* duplicates onto their canonical PE. '
|
||||
'Read-only forensic + revert source.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Audit log — per-row NOTICE so the migration output captures
|
||||
-- exactly which duplicate folded into which canonical, including
|
||||
-- the sr_count for the duplicate (always 1 in current data, but
|
||||
-- the RAISE keeps the audit honest if the scope grows later).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
v_dup_count int;
|
||||
v_grp_count int;
|
||||
BEGIN
|
||||
SELECT COUNT(*), COUNT(DISTINCT name)
|
||||
INTO v_dup_count, v_grp_count
|
||||
FROM tmp_pe_dedupe;
|
||||
|
||||
RAISE NOTICE '[mig 151] dedupe scope: % duplicate rows across % name-groups',
|
||||
v_dup_count, v_grp_count;
|
||||
|
||||
FOR rec IN
|
||||
SELECT d.duplicate_id,
|
||||
d.duplicate_code,
|
||||
d.name,
|
||||
d.canonical_id,
|
||||
d.canonical_code,
|
||||
(SELECT COUNT(*)
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.procedural_event_id = d.duplicate_id) AS sr_count
|
||||
FROM tmp_pe_dedupe d
|
||||
ORDER BY d.name, d.duplicate_id
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 151] dup % (%) -> canonical % (%) — sr_count=%',
|
||||
rec.duplicate_id, rec.duplicate_code,
|
||||
rec.canonical_id, rec.canonical_code,
|
||||
rec.sr_count;
|
||||
RAISE NOTICE '[mig 151] name: %', rec.name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Reparent sequencing_rules.procedural_event_id duplicate → canonical.
|
||||
-- sequencing_rules_pe_proc_lifecycle_idx is non-unique, so collapsing
|
||||
-- multiple sr onto one PE is by design.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET procedural_event_id = d.canonical_id,
|
||||
updated_at = now()
|
||||
FROM tmp_pe_dedupe d
|
||||
WHERE sr.procedural_event_id = d.duplicate_id;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 5. Archive the duplicates. No deletion — audit trail preserved.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.procedural_events pe
|
||||
SET is_active = false,
|
||||
lifecycle_state = 'archived',
|
||||
updated_at = now()
|
||||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 6. POST assertions. Any failure rolls the migration back.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_surviving_groups int;
|
||||
v_expected_count int;
|
||||
v_archived_count int;
|
||||
v_orphan_sr int;
|
||||
BEGIN
|
||||
-- (a) Acceptance criterion 2: no name-group still has >1 active+
|
||||
-- published null.* row.
|
||||
SELECT COUNT(*) INTO v_surviving_groups
|
||||
FROM (
|
||||
SELECT name
|
||||
FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
GROUP BY name
|
||||
HAVING COUNT(*) > 1
|
||||
) s;
|
||||
|
||||
IF v_surviving_groups > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 151] FAILED POST: % name-groups still have >1 active+published null.* rows',
|
||||
v_surviving_groups;
|
||||
END IF;
|
||||
|
||||
-- (b) Every targeted duplicate is now archived.
|
||||
SELECT COUNT(*) INTO v_expected_count FROM tmp_pe_dedupe;
|
||||
|
||||
SELECT COUNT(*) INTO v_archived_count
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe)
|
||||
AND pe.is_active = false
|
||||
AND pe.lifecycle_state = 'archived';
|
||||
|
||||
IF v_archived_count <> v_expected_count THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 151] FAILED POST: archived %/% duplicates',
|
||||
v_archived_count, v_expected_count;
|
||||
END IF;
|
||||
|
||||
-- (c) Acceptance criterion 4: no sequencing_rule still points at
|
||||
-- an archived duplicate.
|
||||
SELECT COUNT(*) INTO v_orphan_sr
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
IF v_orphan_sr > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 151] FAILED POST: % sequencing_rules still point at archived PE duplicates',
|
||||
v_orphan_sr;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 151] OK — archived % duplicates across % name-groups; 0 orphan sequencing_rules',
|
||||
v_archived_count,
|
||||
(SELECT COUNT(DISTINCT name) FROM tmp_pe_dedupe);
|
||||
END $$;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 152_dedupe_identical_sequencing_rule_clones (down) — t-paliad-321
|
||||
--
|
||||
-- Best-effort revert from paliad.sequencing_rules_pre_152. Flips the
|
||||
-- archived rows back to is_active=true / lifecycle_state='published'.
|
||||
-- Does NOT undo the deadlines.sequencing_rule_id reparent — that would
|
||||
-- require remembering the previous pointer per row, which the snapshot
|
||||
-- on sequencing_rules doesn't carry. In live data the reparent was a
|
||||
-- no-op (zero deadlines pointed at duplicates), so this is fine.
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET is_active = true,
|
||||
lifecycle_state = 'published',
|
||||
updated_at = now()
|
||||
FROM paliad.sequencing_rules_pre_152 snap
|
||||
WHERE sr.id = snap.id;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_152;
|
||||
@@ -0,0 +1,240 @@
|
||||
-- 152_dedupe_identical_sequencing_rule_clones — t-paliad-321 / m/paliad#144 follow-up
|
||||
--
|
||||
-- Purpose: mig 151 archived 5 of 6 duplicate procedural_events for
|
||||
-- "Mängelbeseitigung / Zahlung" and reparented their sequencing_rules
|
||||
-- onto the canonical PE. The 6 sequencing_rules themselves remained
|
||||
-- active. Because every one of them is a byte-for-byte clone (same
|
||||
-- proceeding_type_id=NULL, rule_code=NULL, duration 14d, primary_party=NULL,
|
||||
-- everything else NULL, lifecycle_state='published') and only sequence_order
|
||||
-- differs, the admin shows six indistinguishable rows for one legal
|
||||
-- concept. This mig archives 5 of the 6 keeping the lexicographically
|
||||
-- lowest UUID as canonical.
|
||||
--
|
||||
-- Scope verified live before write (Supabase MCP, 2026-05-26):
|
||||
-- * Exactly 1 clone-group surfaces by the full-signature query
|
||||
-- below: 6 "Mängelbeseitigung / Zahlung" sequencing_rules with
|
||||
-- all-NULL discriminators and (duration_value=14, duration_unit='days').
|
||||
-- * 0 paliad.deadlines reference the 5 to-be-archived rows
|
||||
-- (verified via deadlines.sequencing_rule_id JOIN; the column
|
||||
-- formerly named deadlines.rule_id was dropped in mig 140 / B.4).
|
||||
-- * Other name-groups in the live corpus — "Antrag auf
|
||||
-- Patentänderung"×4, "Beginn des Hauptsacheverfahrens"×2,
|
||||
-- "Berufungsbegründung-R.220.1"×2, "Berufungsschrift-R.220.1"×2 —
|
||||
-- do NOT collapse under this signature because their
|
||||
-- proceeding_type_id / rule_code / duration / primary_party
|
||||
-- differ. They are legitimately distinct rules per proceeding;
|
||||
-- this mig leaves them alone.
|
||||
--
|
||||
-- Hard constraints honoured (mirrors mig 151):
|
||||
-- * No deletions. Archived rows flip to is_active=false +
|
||||
-- lifecycle_state='archived'. Rows stay in the table for audit.
|
||||
-- * Reparent paliad.deadlines.sequencing_rule_id duplicate →
|
||||
-- canonical BEFORE archiving, so no live deadline keeps pointing
|
||||
-- at an archived sequencing_rule. (deadlines.rule_id column
|
||||
-- dropped in mig 140; the back-link lives on sequencing_rule_id
|
||||
-- now — same UUID semantics.)
|
||||
-- * Snapshot the affected rows into paliad.sequencing_rules_pre_152
|
||||
-- in the same TX, mirroring precedent (migs 091/093/095/098/140/151).
|
||||
-- * set_config('paliad.audit_reason') is defensively called even
|
||||
-- though no audit trigger fires on sequencing_rules today (mig 151
|
||||
-- §comments documented this). Future audit trigger would inherit
|
||||
-- the reason automatically.
|
||||
--
|
||||
-- Generic-shape rationale: the audit query below uses the FULL
|
||||
-- signature paliadin specified — procedural_event_id, proceeding_type_id,
|
||||
-- rule_code, duration_value, duration_unit, primary_party, condition_expr,
|
||||
-- trigger_event_id, alt_*, anchor_alt, combine_op, parent_id, is_spawn,
|
||||
-- spawn_*. A NOTICE surfaces every group BEFORE the archive step so an
|
||||
-- operator running the deploy logs sees what's about to be touched.
|
||||
-- If new groups appear after future seeds, this mig is safe to re-run
|
||||
-- conceptually (it would archive any new clones) but only fires once
|
||||
-- via the applied_migrations protocol.
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) into a
|
||||
-- TEMP table used by every subsequent step.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TEMP TABLE tmp_sr_dedupe ON COMMIT DROP AS
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id, procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr, trigger_event_id, alt_duration_value,
|
||||
alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
|
||||
parent_id, is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY
|
||||
procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr::text, trigger_event_id,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
|
||||
spawn_proceeding_type_id
|
||||
ORDER BY created_at, id::text
|
||||
) AS rn,
|
||||
COUNT(*) OVER (
|
||||
PARTITION BY
|
||||
procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr::text, trigger_event_id,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
|
||||
spawn_proceeding_type_id
|
||||
) AS grp_size
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
)
|
||||
SELECT
|
||||
r.id AS duplicate_id,
|
||||
canon.id AS canonical_id,
|
||||
r.procedural_event_id,
|
||||
(SELECT name FROM paliad.procedural_events WHERE id = r.procedural_event_id) AS pe_name
|
||||
FROM ranked r
|
||||
JOIN ranked canon
|
||||
ON canon.procedural_event_id IS NOT DISTINCT FROM r.procedural_event_id
|
||||
AND canon.proceeding_type_id IS NOT DISTINCT FROM r.proceeding_type_id
|
||||
AND canon.rule_code IS NOT DISTINCT FROM r.rule_code
|
||||
AND canon.duration_value IS NOT DISTINCT FROM r.duration_value
|
||||
AND canon.duration_unit IS NOT DISTINCT FROM r.duration_unit
|
||||
AND canon.primary_party IS NOT DISTINCT FROM r.primary_party
|
||||
AND canon.condition_expr::text IS NOT DISTINCT FROM r.condition_expr::text
|
||||
AND canon.trigger_event_id IS NOT DISTINCT FROM r.trigger_event_id
|
||||
AND canon.alt_duration_value IS NOT DISTINCT FROM r.alt_duration_value
|
||||
AND canon.alt_duration_unit IS NOT DISTINCT FROM r.alt_duration_unit
|
||||
AND canon.alt_rule_code IS NOT DISTINCT FROM r.alt_rule_code
|
||||
AND canon.anchor_alt IS NOT DISTINCT FROM r.anchor_alt
|
||||
AND canon.combine_op IS NOT DISTINCT FROM r.combine_op
|
||||
AND canon.parent_id IS NOT DISTINCT FROM r.parent_id
|
||||
AND canon.is_spawn IS NOT DISTINCT FROM r.is_spawn
|
||||
AND canon.spawn_label IS NOT DISTINCT FROM r.spawn_label
|
||||
AND canon.spawn_proceeding_type_id IS NOT DISTINCT FROM r.spawn_proceeding_type_id
|
||||
AND canon.rn = 1
|
||||
WHERE r.rn > 1 AND r.grp_size > 1;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Surface every clone-group as a NOTICE before archiving.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
total_to_archive int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO total_to_archive FROM tmp_sr_dedupe;
|
||||
RAISE NOTICE '[mig 152] PRE: % sequencing_rules row(s) will be archived', total_to_archive;
|
||||
FOR rec IN
|
||||
SELECT pe_name, canonical_id, COUNT(*) AS dup_count, array_agg(duplicate_id::text ORDER BY duplicate_id::text) AS dup_ids
|
||||
FROM tmp_sr_dedupe
|
||||
GROUP BY pe_name, canonical_id
|
||||
ORDER BY pe_name
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 152] % canonical=% duplicates=% ids=%',
|
||||
rec.pe_name, rec.canonical_id, rec.dup_count, rec.dup_ids;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Snapshot the rows about to be archived (only the duplicates;
|
||||
-- the canonicals stay in the live table). Matches precedent.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules_pre_152 AS
|
||||
SELECT sr.*
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN tmp_sr_dedupe d ON d.duplicate_id = sr.id;
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules_pre_152 IS
|
||||
'Snapshot of paliad.sequencing_rules rows archived by mig 152 '
|
||||
'(identical clones — Mängelbeseitigung / Zahlung × 5). Mirrors '
|
||||
'precedent pre_091/093/095/098/140/151. Read-only revert source. '
|
||||
't-paliad-321 / m/paliad#144 follow-up.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Reparent paliad.deadlines.sequencing_rule_id duplicate → canonical
|
||||
-- BEFORE archiving. Today's live data has 0 deadlines pointing at
|
||||
-- any duplicate, but the statement is safe + defensive against a
|
||||
-- race between drift-check and apply.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET sequencing_rule_id = m.canonical_id,
|
||||
procedural_event_id = (SELECT procedural_event_id
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id = m.canonical_id),
|
||||
updated_at = now()
|
||||
FROM tmp_sr_dedupe m
|
||||
WHERE d.sequencing_rule_id = m.duplicate_id;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 5. Defensive audit-reason. Sequencing_rules has no audit trigger
|
||||
-- today (mig 151 §scope verified), but set_config is transactional
|
||||
-- and a future audit trigger inherits the reason automatically.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
SELECT set_config('paliad.audit_reason',
|
||||
'mig 152: archive identical sequencing_rule clones (mig 151 follow-up; t-paliad-321)',
|
||||
true);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 6. Archive the duplicates.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.sequencing_rules
|
||||
SET is_active = false,
|
||||
lifecycle_state = 'archived',
|
||||
updated_at = now()
|
||||
WHERE id IN (SELECT duplicate_id FROM tmp_sr_dedupe);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 7. POST assertions.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_archived int;
|
||||
v_remaining_dupes int;
|
||||
v_orphan_deadlines int;
|
||||
BEGIN
|
||||
-- a. Did the expected number of rows get archived?
|
||||
SELECT COUNT(*) INTO v_archived
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id IN (SELECT duplicate_id FROM tmp_sr_dedupe)
|
||||
AND lifecycle_state = 'archived'
|
||||
AND is_active = false;
|
||||
IF v_archived <> (SELECT COUNT(*) FROM tmp_sr_dedupe) THEN
|
||||
RAISE EXCEPTION '[mig 152] FAILED POST: expected % rows archived, got %',
|
||||
(SELECT COUNT(*) FROM tmp_sr_dedupe), v_archived;
|
||||
END IF;
|
||||
|
||||
-- b. No clone group of size > 1 should remain in active+published.
|
||||
SELECT COUNT(*) INTO v_remaining_dupes FROM (
|
||||
SELECT 1
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE is_active = true AND lifecycle_state = 'published'
|
||||
GROUP BY procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr::text, trigger_event_id,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
|
||||
spawn_proceeding_type_id
|
||||
HAVING COUNT(*) > 1
|
||||
) g;
|
||||
IF v_remaining_dupes > 0 THEN
|
||||
RAISE EXCEPTION '[mig 152] FAILED POST: % clone group(s) still active+published after archive', v_remaining_dupes;
|
||||
END IF;
|
||||
|
||||
-- c. No deadline points at an archived sequencing_rule.
|
||||
SELECT COUNT(*) INTO v_orphan_deadlines
|
||||
FROM paliad.deadlines d
|
||||
JOIN paliad.sequencing_rules sr ON sr.id = d.sequencing_rule_id
|
||||
WHERE sr.lifecycle_state = 'archived';
|
||||
IF v_orphan_deadlines > 0 THEN
|
||||
RAISE EXCEPTION '[mig 152] FAILED POST: % live deadline(s) still point at an archived sequencing_rule', v_orphan_deadlines;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 152] OK — archived=%, remaining clone groups=0, orphan deadlines=0',
|
||||
v_archived;
|
||||
END $$;
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,77 @@ import (
|
||||
// is mapped to 409 Conflict so the editor UI can show a clear "must
|
||||
// clone first" hint.
|
||||
|
||||
// Slice B.5 (t-paliad-305) JSON envelope renames:
|
||||
//
|
||||
// - submission_code → code (procedural-event identifier)
|
||||
// - event_type → event_kind (procedural-event taxonomy)
|
||||
//
|
||||
// Wire compatibility: every response emits BOTH the legacy and the
|
||||
// canonical keys for one slice (see Deprecation HTTP header on the
|
||||
// response). Input bodies accept either name on the request; the
|
||||
// canonical key wins when both are present.
|
||||
//
|
||||
// adminRuleResponse wraps models.DeadlineRule (= litigationplanner.Rule)
|
||||
// to add the canonical `code` + `event_kind` fields alongside the
|
||||
// historical `submission_code` + `event_type` already on Rule's tags.
|
||||
// The embedded *models.DeadlineRule carries every existing tag through
|
||||
// json.Marshal unchanged; the wrapper only ADDS the two new keys.
|
||||
//
|
||||
// ProceedingTypeCode (t-paliad-321) is the joined paliad.proceeding_types.code
|
||||
// for the row's proceeding_type_id. NULL on event-rooted rules. Lets the
|
||||
// /admin/procedural-events list disambiguate same-named rules at a glance
|
||||
// (e.g. "Berufungsbegründung" rows differ only by proceeding code).
|
||||
type adminRuleResponse struct {
|
||||
*models.DeadlineRule
|
||||
Code *string `json:"code,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
ProceedingTypeCode *string `json:"proceeding_type_code,omitempty"`
|
||||
}
|
||||
|
||||
// wrapRuleResponse builds the dual-emit wrapper from a service result.
|
||||
// Same values, two keys per concept — no semantic change. Pass a non-nil
|
||||
// ptCode to populate the proceeding_type_code field; nil leaves it
|
||||
// absent (e.g. on event-rooted rules with NULL proceeding_type_id).
|
||||
func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
|
||||
if r == nil {
|
||||
return adminRuleResponse{}
|
||||
}
|
||||
return adminRuleResponse{
|
||||
DeadlineRule: r,
|
||||
Code: r.SubmissionCode,
|
||||
EventKind: r.EventType,
|
||||
}
|
||||
}
|
||||
|
||||
// wrapRuleListResponse maps a slice of service results into the
|
||||
// dual-emit wrapper. Used by the LIST endpoint. ptCodes is an
|
||||
// optional id → code lookup populated by handleAdminListRules from a
|
||||
// single batch query against paliad.proceeding_types; nil leaves
|
||||
// every row's proceeding_type_code empty (the LIST endpoint always
|
||||
// passes a populated map; other callers don't need it).
|
||||
func wrapRuleListResponse(rows []models.DeadlineRule, ptCodes map[int]string) []adminRuleResponse {
|
||||
out := make([]adminRuleResponse, len(rows))
|
||||
for i := range rows {
|
||||
out[i] = wrapRuleResponse(&rows[i])
|
||||
if ptCodes != nil && rows[i].ProceedingTypeID != nil {
|
||||
if code, ok := ptCodes[*rows[i].ProceedingTypeID]; ok {
|
||||
out[i].ProceedingTypeCode = &code
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adminRuleDeprecationHeaders writes the IETF "Deprecation" + "Sunset"
|
||||
// HTTP headers signaling that the legacy `submission_code` /
|
||||
// `event_type` JSON keys are being retired in favour of `code` /
|
||||
// `event_kind`. RFC 8594 (Sunset) + draft-ietf-httpapi-deprecation-header.
|
||||
// Clients should migrate within one slice cycle.
|
||||
func adminRuleDeprecationHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Deprecation", `true; key="submission_code,event_type"`)
|
||||
w.Header().Set("Link", `<https://mgit.msbls.de/m/paliad/issues/93>; rel="deprecation"`)
|
||||
}
|
||||
|
||||
// GET /admin/api/rules — paginated list with filters.
|
||||
func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
@@ -73,7 +145,16 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
// t-paliad-321: batch-fetch proceeding_type.code for every rule
|
||||
// row that carries a non-NULL proceeding_type_id, so the LIST
|
||||
// response can show a Proceeding column without an N+1 join.
|
||||
ptCodes, err := dbSvc.ruleEditor.LoadProceedingTypeCodes(r.Context(), rows)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows, ptCodes))
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/{id}
|
||||
@@ -91,7 +172,8 @@ func handleAdminGetRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules — create draft.
|
||||
@@ -108,12 +190,15 @@ func handleAdminCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
|
||||
body.CreateRuleInput.CoalesceCanonicalKeys()
|
||||
row, err := dbSvc.ruleEditor.Create(r.Context(), body.CreateRuleInput, body.Reason)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// PATCH /admin/api/rules/{id} — partial update of a draft.
|
||||
@@ -134,12 +219,15 @@ func handleAdminPatchRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
|
||||
body.RulePatch.CoalesceCanonicalKeys()
|
||||
row, err := dbSvc.ruleEditor.UpdateDraft(r.Context(), id, body.RulePatch, body.Reason)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/clone-as-draft
|
||||
@@ -161,7 +249,8 @@ func handleAdminCloneAsDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/publish
|
||||
@@ -183,7 +272,8 @@ func handleAdminPublishRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/archive
|
||||
@@ -205,7 +295,8 @@ func handleAdminArchiveRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/restore
|
||||
@@ -227,7 +318,8 @@ func handleAdminRestoreRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/{id}/audit?offset=N&limit=M
|
||||
@@ -299,21 +391,6 @@ func handleAdminPreviewRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/export-migrations?since=<audit_id>
|
||||
func handleAdminExportRuleMigrations(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
since := r.URL.Query().Get("since")
|
||||
out, err := dbSvc.ruleEditor.ExportMigrationsSince(r.Context(), since)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page handlers — serve the static SPA shells. Auth + admin gate live
|
||||
// at the route registration in handlers.go.
|
||||
@@ -327,10 +404,6 @@ func handleAdminRulesEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-edit.html")
|
||||
}
|
||||
|
||||
func handleAdminRulesExportPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-export.html")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// helpers
|
||||
// =============================================================================
|
||||
@@ -438,3 +511,66 @@ func handleAdminResolveOrphan(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
||||
}
|
||||
|
||||
// Slice B.6 (t-paliad-305) — 301 redirect helpers for the legacy
|
||||
// /admin/rules* paths. New canonical paths live under
|
||||
// /admin/procedural-events; the redirects keep external bookmarks,
|
||||
// audit-log entries, and curl scripts working through one
|
||||
// deprecation cycle.
|
||||
//
|
||||
// Three flavours:
|
||||
//
|
||||
// * redirectToProceduralEvents(newPath) — fixed redirect target
|
||||
// (used by the parameter-less paths /admin/rules and
|
||||
// /admin/api/rules).
|
||||
// * redirectToProceduralEventEdit — page path with {id}/edit suffix.
|
||||
// * redirectToProceduralEventAPI(suffix) — JSON API paths that carry
|
||||
// an {id} and optional suffix (/clone-as-draft, /publish, …).
|
||||
//
|
||||
// All emit 301 Moved Permanently — caches and browsers learn the new
|
||||
// URL once and stop hitting the legacy path. The IETF Deprecation
|
||||
// header is added so machine clients see the migration signal
|
||||
// alongside the redirect.
|
||||
|
||||
// redirectToProceduralEvents returns an http.HandlerFunc that 301s to
|
||||
// the supplied destination path. Query string is preserved.
|
||||
func redirectToProceduralEvents(dst string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
target := dst
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
w.Header().Set("Deprecation", `true; path="/admin/rules"`)
|
||||
w.Header().Set("Link", `</admin/procedural-events>; rel="successor-version"`)
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
}
|
||||
|
||||
// redirectToProceduralEventEdit 301s GET /admin/rules/{id}/edit →
|
||||
// /admin/procedural-events/{id}/edit.
|
||||
func redirectToProceduralEventEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
target := "/admin/procedural-events/" + id + "/edit"
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
w.Header().Set("Deprecation", `true; path="/admin/rules/{id}/edit"`)
|
||||
w.Header().Set("Link", `</admin/procedural-events/{id}/edit>; rel="successor-version"`)
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
// redirectToProceduralEventAPI 301s /admin/api/rules/{id}[/suffix] →
|
||||
// /admin/api/procedural-events/{id}[/suffix]. The optional suffix
|
||||
// covers /clone-as-draft, /publish, /archive, /restore, /audit, /preview.
|
||||
func redirectToProceduralEventAPI(suffix string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
target := "/admin/api/procedural-events/" + id + suffix
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
w.Header().Set("Deprecation", `true; path="/admin/api/rules/{id}`+suffix+`"`)
|
||||
w.Header().Set("Link", `</admin/api/procedural-events/{id}`+suffix+`>; rel="successor-version"`)
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -111,8 +113,34 @@ var fileRegistry = map[string]fileEntry{
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
|
||||
},
|
||||
// t-paliad-317 Composer Slice E — specialist firm-agnostic bases.
|
||||
// Both live under Composer/ (not under HLC/) so a future non-HLC
|
||||
// deployment serves the same cross-firm files. Body = anchor-only
|
||||
// per Slice B; styles.xml carries the preset's typography.
|
||||
composerBaseLGDuesseldorfSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
|
||||
DownloadName: "LG-Düsseldorf Stil.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
|
||||
},
|
||||
composerBaseUPCFormalSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/upc-formal.docx",
|
||||
DownloadName: "UPC formal.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/Composer/upc-formal.docx",
|
||||
},
|
||||
}
|
||||
|
||||
// t-paliad-317 Composer Slice E — slugs for the new specialist bases.
|
||||
const (
|
||||
composerBaseLGDuesseldorfSlug = "submission/composer/lg-duesseldorf.docx"
|
||||
composerBaseUPCFormalSlug = "submission/composer/upc-formal.docx"
|
||||
)
|
||||
|
||||
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||
// the shared fileRegistry cache. Exported via a const so handler code
|
||||
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
||||
@@ -402,6 +430,37 @@ func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
|
||||
}
|
||||
|
||||
// composerBaseSlugMap routes a Composer base.slug to the existing
|
||||
// fileRegistry slug whose Gitea object backs it (t-paliad-313 Slice B).
|
||||
// Slice A seeded two bases that already share .docx files with the v1
|
||||
// fallback chain — no new Gitea uploads needed for those. Future bases
|
||||
// (e.g. lg-duesseldorf, upc-formal in Slice E) register their own
|
||||
// fileRegistry entries via the same shape and add a row here.
|
||||
var composerBaseSlugMap = map[string]string{
|
||||
"hlc-letterhead": firmSkeletonSubmissionSlug,
|
||||
"neutral": skeletonSubmissionSlug,
|
||||
"lg-duesseldorf": composerBaseLGDuesseldorfSlug,
|
||||
"upc-formal": composerBaseUPCFormalSlug,
|
||||
}
|
||||
|
||||
// fetchComposerBaseBytes returns the .docx bytes for a Composer base,
|
||||
// pulled from the shared Gitea proxy cache. ErrComposerBaseNotProxied
|
||||
// when the slug has no registered fileRegistry entry — a base authored
|
||||
// without a file-registry mapping (rare; admin oversight) renders as
|
||||
// "Vorlagenbasis nicht erreichbar" upstream of this call.
|
||||
var ErrComposerBaseNotProxied = errors.New("composer base: Gitea slug not registered")
|
||||
|
||||
func fetchComposerBaseBytes(ctx context.Context, base *services.SubmissionBase) ([]byte, string, error) {
|
||||
if base == nil {
|
||||
return nil, "", fmt.Errorf("composer base: nil base")
|
||||
}
|
||||
slug, ok := composerBaseSlugMap[base.Slug]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("%w: base slug %q", ErrComposerBaseNotProxied, base.Slug)
|
||||
}
|
||||
return fetchSubmissionTemplateSlug(ctx, slug)
|
||||
}
|
||||
|
||||
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
|
||||
// the firm-skeleton and universal-skeleton accessors. Factored out so
|
||||
// the two paths can't drift apart on caching semantics.
|
||||
|
||||
@@ -69,6 +69,14 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// stay in the result list. Default false preserves the legacy
|
||||
// suppression. HiddenCount on the response is independent.
|
||||
IncludeHidden bool `json:"includeHidden,omitempty"`
|
||||
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC
|
||||
// Berufung (upc.apl) timeline to the rule subset whose
|
||||
// applies_to_target contains the requested slug. Empty = no
|
||||
// filter. Valid values: endentscheidung | kostenentscheidung
|
||||
// | anordnung | schadensbemessung | bucheinsicht. Unknown
|
||||
// slugs are silently dropped (no filter) so a stale frontend
|
||||
// chip doesn't 400 the request.
|
||||
AppealTarget string `json:"appealTarget,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -116,6 +124,7 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
@@ -116,10 +116,27 @@ type Services struct {
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||
SubmissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A + B — base catalog,
|
||||
// per-draft section rows, render-pipeline assembler. All three
|
||||
// nil in DATABASE_URL-less deploys (the Composer surfaces return
|
||||
// 503 / hide the picker).
|
||||
SubmissionBase *services.BaseService
|
||||
SubmissionSection *services.SectionService
|
||||
SubmissionComposer *services.SubmissionComposer
|
||||
|
||||
// t-paliad-315 Composer Slice C — building-block library + admin
|
||||
// editor. Per Q2: paste sources only, no lineage on sections.
|
||||
SubmissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// 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 +199,13 @@ 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,
|
||||
submissionComposer: svc.SubmissionComposer,
|
||||
submissionBuildingBlock: svc.SubmissionBuildingBlock,
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +424,23 @@ 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-313 (m/paliad#141) Composer Slice B — per-section PATCH
|
||||
// for inline editor autosave. URL keyed on draft_id + section_id;
|
||||
// owner-scoped via SubmissionDraftService.Get.
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}/sections/{section_id}", handlePatchSubmissionSection)
|
||||
// t-paliad-318 (m/paliad#141) Composer Slice F — add custom
|
||||
// section, delete section, reorder.
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections", handleCreateSubmissionSection)
|
||||
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}/sections/{section_id}", handleDeleteSubmissionSection)
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections/reorder", handleReorderSubmissionSections)
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks
|
||||
// library. Lawyer-facing picker + paste mechanic.
|
||||
protected.HandleFunc("GET /api/submission-building-blocks", handleListBuildingBlocks)
|
||||
protected.HandleFunc("POST /api/submission-building-blocks/{block_id}/insert-into/{section_id}", handleInsertBlockIntoSection)
|
||||
// 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 +485,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)
|
||||
@@ -657,6 +705,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("DELETE /api/admin/firm-dashboard-default", adminGate(users, handleDeleteFirmDashboardDefault))
|
||||
protected.HandleFunc("POST /api/me/dashboard-layout/promote", adminGate(users, handlePromoteDashboardLayoutToFirmDefault))
|
||||
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — admin building blocks editor.
|
||||
protected.HandleFunc("GET /admin/submission-building-blocks", adminGate(users, gateOnboarded(handleAdminBuildingBlocksPage)))
|
||||
protected.HandleFunc("GET /api/admin/submission-building-blocks", adminGate(users, handleAdminListBuildingBlocks))
|
||||
protected.HandleFunc("POST /api/admin/submission-building-blocks", adminGate(users, handleAdminCreateBuildingBlock))
|
||||
protected.HandleFunc("GET /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminGetBuildingBlock))
|
||||
protected.HandleFunc("PATCH /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminUpdateBuildingBlock))
|
||||
protected.HandleFunc("DELETE /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminDeleteBuildingBlock))
|
||||
protected.HandleFunc("GET /api/admin/submission-building-blocks/{block_id}/versions", adminGate(users, handleAdminListBuildingBlockVersions))
|
||||
protected.HandleFunc("POST /api/admin/submission-building-blocks/{block_id}/restore/{version_id}", adminGate(users, handleAdminRestoreBuildingBlockVersion))
|
||||
|
||||
protected.HandleFunc("GET /api/admin/email-templates", adminGate(users, handleAdminListEmailTemplates))
|
||||
protected.HandleFunc("GET /api/admin/email-templates/{key}/variables", adminGate(users, handleAdminEmailTemplateVariables))
|
||||
protected.HandleFunc("GET /api/admin/email-templates/{key}/{lang}", adminGate(users, handleAdminGetEmailTemplate))
|
||||
@@ -669,20 +727,43 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// t-paliad-089 — admin Event-Type moderation panel.
|
||||
// t-paliad-191 Slice 11a — admin rule-editor API.
|
||||
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
|
||||
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
|
||||
protected.HandleFunc("GET /admin/rules/export", adminGate(users, gateOnboarded(handleAdminRulesExportPage)))
|
||||
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
|
||||
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
|
||||
protected.HandleFunc("GET /admin/api/rules/export-migrations", adminGate(users, handleAdminExportRuleMigrations))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
|
||||
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
|
||||
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, handleAdminPublishRule))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, handleAdminArchiveRule))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, handleAdminRestoreRule))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, handleAdminPreviewRule))
|
||||
// Slice B.6 (t-paliad-305) — canonical URL paths under
|
||||
// /admin/procedural-events with 301 redirects from the legacy
|
||||
// /admin/rules paths so existing bookmarks and audit-log
|
||||
// entries continue to resolve. New paths point at the same
|
||||
// handlers; the canonical-URL name aligns with the umbrella
|
||||
// term locked in Slice A.
|
||||
protected.HandleFunc("GET /admin/procedural-events", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
|
||||
protected.HandleFunc("GET /admin/procedural-events/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
|
||||
protected.HandleFunc("GET /admin/api/procedural-events", adminGate(users, handleAdminListRules))
|
||||
protected.HandleFunc("GET /admin/api/procedural-events/{id}", adminGate(users, handleAdminGetRule))
|
||||
protected.HandleFunc("POST /admin/api/procedural-events", adminGate(users, handleAdminCreateRule))
|
||||
protected.HandleFunc("PATCH /admin/api/procedural-events/{id}", adminGate(users, handleAdminPatchRule))
|
||||
protected.HandleFunc("POST /admin/api/procedural-events/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
|
||||
protected.HandleFunc("POST /admin/api/procedural-events/{id}/publish", adminGate(users, handleAdminPublishRule))
|
||||
protected.HandleFunc("POST /admin/api/procedural-events/{id}/archive", adminGate(users, handleAdminArchiveRule))
|
||||
protected.HandleFunc("POST /admin/api/procedural-events/{id}/restore", adminGate(users, handleAdminRestoreRule))
|
||||
protected.HandleFunc("GET /admin/api/procedural-events/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
|
||||
protected.HandleFunc("GET /admin/api/procedural-events/{id}/preview", adminGate(users, handleAdminPreviewRule))
|
||||
|
||||
// Legacy /admin/rules paths — 301 redirect to the canonical
|
||||
// /admin/procedural-events paths. One-slice deprecation window
|
||||
// per design §8.2 (B.6 optional; m authorised the rename
|
||||
// 2026-05-26). After the next slice that audits external
|
||||
// references, these can be retired entirely.
|
||||
protected.HandleFunc("GET /admin/rules", adminGate(users, redirectToProceduralEvents("/admin/procedural-events")))
|
||||
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, redirectToProceduralEventEdit))
|
||||
protected.HandleFunc("GET /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
|
||||
protected.HandleFunc("POST /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
|
||||
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, redirectToProceduralEventAPI("/clone-as-draft")))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, redirectToProceduralEventAPI("/publish")))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, redirectToProceduralEventAPI("/archive")))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, redirectToProceduralEventAPI("/restore")))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, redirectToProceduralEventAPI("/audit")))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, redirectToProceduralEventAPI("/preview")))
|
||||
|
||||
protected.HandleFunc("GET /admin/api/orphans", adminGate(users, handleAdminListOrphans))
|
||||
protected.HandleFunc("POST /admin/api/orphans/{id}/resolve", adminGate(users, handleAdminResolveOrphan))
|
||||
|
||||
|
||||
@@ -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,19 @@ type dbServices struct {
|
||||
// t-paliad-238 — submission draft editor.
|
||||
submissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-313 — Composer base catalog + per-draft sections +
|
||||
// (Slice B) the render pipeline assembling base + sections into a
|
||||
// final .docx + (Slice C) building-block library.
|
||||
submissionBase *services.BaseService
|
||||
submissionSection *services.SectionService
|
||||
submissionComposer *services.SubmissionComposer
|
||||
submissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
482
internal/handlers/submission_building_blocks.go
Normal file
482
internal/handlers/submission_building_blocks.go
Normal file
@@ -0,0 +1,482 @@
|
||||
package handlers
|
||||
|
||||
// Composer building-block handlers — t-paliad-315 Slice C.
|
||||
//
|
||||
// Two surfaces:
|
||||
//
|
||||
// 1. Lawyer-facing picker (any authenticated user):
|
||||
// GET /api/submission-building-blocks?section_key=…&proceeding_family=…&q=…
|
||||
// POST /api/submission-building-blocks/{block_id}/insert-into/{section_id}
|
||||
//
|
||||
// The picker list is visibility-tier-filtered (private/team/firm/
|
||||
// global) at the service layer. Insert is the paste mechanic
|
||||
// ratified by Q2 (m, 2026-05-26): plain text copy of
|
||||
// content_md_<lang> into submission_sections.content_md_<lang>.
|
||||
// No lineage stamped on the section.
|
||||
//
|
||||
// 2. Admin editor (adminGate via auth.RequireAdminFunc):
|
||||
// GET /api/admin/submission-building-blocks
|
||||
// POST /api/admin/submission-building-blocks
|
||||
// GET /api/admin/submission-building-blocks/{block_id}
|
||||
// PATCH /api/admin/submission-building-blocks/{block_id}
|
||||
// DELETE /api/admin/submission-building-blocks/{block_id}
|
||||
// GET /api/admin/submission-building-blocks/{block_id}/versions
|
||||
// POST /api/admin/submission-building-blocks/{block_id}/restore/{version_id}
|
||||
//
|
||||
// Plus the page route /admin/submission-building-blocks (list +
|
||||
// edit shell, hydrated client-side).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// blockJSON is the on-the-wire shape for both the picker and admin
|
||||
// surfaces.
|
||||
type buildingBlockJSON struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Firm *string `json:"firm,omitempty"`
|
||||
SectionKey string `json:"section_key"`
|
||||
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
||||
TitleDE string `json:"title_de"`
|
||||
TitleEN string `json:"title_en"`
|
||||
DescriptionDE *string `json:"description_de,omitempty"`
|
||||
DescriptionEN *string `json:"description_en,omitempty"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
AuthorID *uuid.UUID `json:"author_id,omitempty"`
|
||||
Visibility string `json:"visibility"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type buildingBlockListResponse struct {
|
||||
Blocks []buildingBlockJSON `json:"blocks"`
|
||||
}
|
||||
|
||||
// blockJSONFromService projects services.BuildingBlock into the wire shape.
|
||||
func blockJSONFromService(b *services.BuildingBlock) buildingBlockJSON {
|
||||
return buildingBlockJSON{
|
||||
ID: b.ID,
|
||||
Slug: b.Slug,
|
||||
Firm: b.Firm,
|
||||
SectionKey: b.SectionKey,
|
||||
ProceedingFamily: b.ProceedingFamily,
|
||||
TitleDE: b.TitleDE,
|
||||
TitleEN: b.TitleEN,
|
||||
DescriptionDE: b.DescriptionDE,
|
||||
DescriptionEN: b.DescriptionEN,
|
||||
ContentMDDE: b.ContentMDDE,
|
||||
ContentMDEN: b.ContentMDEN,
|
||||
AuthorID: b.AuthorID,
|
||||
Visibility: b.Visibility,
|
||||
IsPublished: b.IsPublished,
|
||||
CreatedAt: b.CreatedAt,
|
||||
UpdatedAt: b.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Lawyer-facing picker
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func handleListBuildingBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
filter := services.BlockListFilter{
|
||||
SectionKey: strings.TrimSpace(q.Get("section_key")),
|
||||
ProceedingFamily: strings.TrimSpace(q.Get("proceeding_family")),
|
||||
Search: strings.TrimSpace(q.Get("q")),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := dbSvc.submissionBuildingBlock.ListVisible(ctx, uid, filter)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]buildingBlockJSON, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, blockJSONFromService(&rows[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, buildingBlockListResponse{Blocks: out})
|
||||
}
|
||||
|
||||
func handleInsertBlockIntoSection(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil || dbSvc.submissionSection == nil || dbSvc.submissionDraft == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Visibility on the section: section.draft_id must point to a
|
||||
// draft the caller owns. Composer Slice B's same owner gate.
|
||||
sec, err := dbSvc.submissionSection.Get(ctx, sectionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := dbSvc.submissionDraft.Get(ctx, uid, sec.DraftID); err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := dbSvc.submissionBuildingBlock.InsertIntoSection(ctx, uid, blockID, sectionID, dbSvc.submissionSection)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Admin editor
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func handleAdminListBuildingBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
rows, err := dbSvc.submissionBuildingBlock.ListAllForAdmin(ctx)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]buildingBlockJSON, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, blockJSONFromService(&rows[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, buildingBlockListResponse{Blocks: out})
|
||||
}
|
||||
|
||||
func handleAdminGetBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
b, err := dbSvc.submissionBuildingBlock.GetForAdmin(ctx, blockID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, blockJSONFromService(b))
|
||||
}
|
||||
|
||||
type buildingBlockCreateInput struct {
|
||||
Slug string `json:"slug"`
|
||||
Firm *string `json:"firm,omitempty"`
|
||||
SectionKey string `json:"section_key"`
|
||||
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
||||
TitleDE string `json:"title_de"`
|
||||
TitleEN string `json:"title_en"`
|
||||
DescriptionDE *string `json:"description_de,omitempty"`
|
||||
DescriptionEN *string `json:"description_en,omitempty"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
Visibility string `json:"visibility"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
}
|
||||
|
||||
func handleAdminCreateBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
var in buildingBlockCreateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
b, err := dbSvc.submissionBuildingBlock.Create(ctx, uid, services.CreateInput{
|
||||
Slug: in.Slug,
|
||||
Firm: in.Firm,
|
||||
SectionKey: in.SectionKey,
|
||||
ProceedingFamily: in.ProceedingFamily,
|
||||
TitleDE: in.TitleDE,
|
||||
TitleEN: in.TitleEN,
|
||||
DescriptionDE: in.DescriptionDE,
|
||||
DescriptionEN: in.DescriptionEN,
|
||||
ContentMDDE: in.ContentMDDE,
|
||||
ContentMDEN: in.ContentMDEN,
|
||||
Visibility: in.Visibility,
|
||||
IsPublished: in.IsPublished,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) || errors.Is(err, services.ErrBuildingBlockInvalidVisibility) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, blockJSONFromService(b))
|
||||
}
|
||||
|
||||
type buildingBlockUpdateInput struct {
|
||||
Slug *string `json:"slug,omitempty"`
|
||||
Firm *string `json:"firm,omitempty"`
|
||||
FirmSet bool `json:"-"`
|
||||
SectionKey *string `json:"section_key,omitempty"`
|
||||
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
||||
ProceedingFamilySet bool `json:"-"`
|
||||
TitleDE *string `json:"title_de,omitempty"`
|
||||
TitleEN *string `json:"title_en,omitempty"`
|
||||
DescriptionDE *string `json:"description_de,omitempty"`
|
||||
DescriptionDESet bool `json:"-"`
|
||||
DescriptionEN *string `json:"description_en,omitempty"`
|
||||
DescriptionENSet bool `json:"-"`
|
||||
ContentMDDE *string `json:"content_md_de,omitempty"`
|
||||
ContentMDEN *string `json:"content_md_en,omitempty"`
|
||||
Visibility *string `json:"visibility,omitempty"`
|
||||
IsPublished *bool `json:"is_published,omitempty"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
func (u *buildingBlockUpdateInput) UnmarshalJSON(data []byte) error {
|
||||
type alias buildingBlockUpdateInput
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
*u = buildingBlockUpdateInput(a)
|
||||
raw := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
_, u.FirmSet = raw["firm"]
|
||||
_, u.ProceedingFamilySet = raw["proceeding_family"]
|
||||
_, u.DescriptionDESet = raw["description_de"]
|
||||
_, u.DescriptionENSet = raw["description_en"]
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleAdminUpdateBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in buildingBlockUpdateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
patch := services.UpdatePatch{
|
||||
Slug: in.Slug,
|
||||
SectionKey: in.SectionKey,
|
||||
TitleDE: in.TitleDE,
|
||||
TitleEN: in.TitleEN,
|
||||
ContentMDDE: in.ContentMDDE,
|
||||
ContentMDEN: in.ContentMDEN,
|
||||
Visibility: in.Visibility,
|
||||
IsPublished: in.IsPublished,
|
||||
Note: in.Note,
|
||||
}
|
||||
if in.FirmSet {
|
||||
patch.Firm = &in.Firm
|
||||
}
|
||||
if in.ProceedingFamilySet {
|
||||
patch.ProceedingFamily = &in.ProceedingFamily
|
||||
}
|
||||
if in.DescriptionDESet {
|
||||
patch.DescriptionDE = &in.DescriptionDE
|
||||
}
|
||||
if in.DescriptionENSet {
|
||||
patch.DescriptionEN = &in.DescriptionEN
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
b, err := dbSvc.submissionBuildingBlock.Update(ctx, uid, blockID, patch)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrBuildingBlockInvalidVisibility) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, blockJSONFromService(b))
|
||||
}
|
||||
|
||||
func handleAdminDeleteBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := dbSvc.submissionBuildingBlock.SoftDelete(ctx, uid, blockID); err != nil {
|
||||
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func handleAdminListBuildingBlockVersions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
rows, err := dbSvc.submissionBuildingBlock.ListVersions(ctx, blockID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"versions": rows})
|
||||
}
|
||||
|
||||
func handleAdminRestoreBuildingBlockVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
versionID, ok := parseUUIDPath(w, r, "version_id", "version id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
b, err := dbSvc.submissionBuildingBlock.RestoreVersion(ctx, uid, blockID, versionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block or version not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, blockJSONFromService(b))
|
||||
}
|
||||
|
||||
// handleAdminBuildingBlocksPage serves the admin editor shell. The
|
||||
// client bundle hydrates the list + edit UI.
|
||||
func handleAdminBuildingBlocksPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-submission-building-blocks.html")
|
||||
}
|
||||
@@ -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)
|
||||
@@ -501,16 +566,10 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
return
|
||||
}
|
||||
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
|
||||
writeSubmissionExportError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -523,7 +582,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
|
||||
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
|
||||
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
|
||||
@@ -538,6 +597,82 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// exportSubmissionDraft is the shared render entry point used by both
|
||||
// the project-scoped and global export handlers (t-paliad-313 Slice B).
|
||||
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
|
||||
// Composer pipeline assembles the document; otherwise the v1
|
||||
// template-only path stays the fallback. composerUsed = true means the
|
||||
// metadata jsonb on the audit row carries "composer": true so admins
|
||||
// can tell the two paths apart in the feed.
|
||||
//
|
||||
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
|
||||
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
|
||||
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
|
||||
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
|
||||
switch {
|
||||
case err == nil:
|
||||
baseBytes, baseSHA, err := fetchComposerBaseBytes(ctx, base)
|
||||
if err == nil {
|
||||
sections, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("list sections: %w", err)
|
||||
}
|
||||
bag, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, err
|
||||
}
|
||||
docx, err := dbSvc.submissionComposer.Compose(ctx, services.ComposeOptions{
|
||||
Sections: sections,
|
||||
Base: base,
|
||||
BaseBytes: baseBytes,
|
||||
Lang: resolved.Lang,
|
||||
Vars: bag,
|
||||
Missing: services.DefaultMissingMarker(resolved.Lang),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("composer: %w", err)
|
||||
}
|
||||
return docx, resolved, baseSHA, true, nil
|
||||
}
|
||||
log.Printf("submission_drafts: composer base bytes fetch failed (draft=%s base=%s): %v — falling back to v1 path", d.ID, base.Slug, err)
|
||||
case errors.Is(err, services.ErrBaseNotFound):
|
||||
log.Printf("submission_drafts: composer base missing (draft=%s base_id=%s) — falling back to v1 path", d.ID, *d.BaseID)
|
||||
default:
|
||||
return nil, nil, "", false, fmt.Errorf("composer base lookup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// v1 fallback: template-only render via resolveSubmissionTemplate +
|
||||
// SubmissionDraftService.Export. Unchanged behaviour for
|
||||
// pre-Composer drafts.
|
||||
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("template upstream: %w", err)
|
||||
}
|
||||
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("render: %w", err)
|
||||
}
|
||||
return docx, resolved, tplSHA, false, nil
|
||||
}
|
||||
|
||||
// writeSubmissionExportError maps a render-time error to an HTTP
|
||||
// response. The shape mirrors what the handlers used to inline.
|
||||
func writeSubmissionExportError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "template upstream"):
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
case strings.Contains(msg, "composer:") || strings.Contains(msg, "render:") || strings.Contains(msg, "list sections"):
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
}
|
||||
}
|
||||
|
||||
// handleSubmissionDraftPage serves dist/submission-draft.html for the
|
||||
// dedicated draft editor at /projects/{id}/submissions/{code}/draft
|
||||
// (and …/draft/{draft_id}). Project visibility is enforced server-side
|
||||
@@ -713,6 +848,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 +862,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 +873,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 +922,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 {
|
||||
@@ -890,16 +1038,10 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
return
|
||||
}
|
||||
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
|
||||
writeSubmissionExportError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -910,7 +1052,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
|
||||
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
|
||||
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
|
||||
@@ -952,6 +1094,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 +1301,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 +1317,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,
|
||||
}
|
||||
@@ -1160,7 +1332,7 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
// 'user' with scope_root = draft.user_id; the audit feed therefore
|
||||
// surfaces these exports on the user's row rather than against a
|
||||
// (non-existent) project.
|
||||
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string) error {
|
||||
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string, composerUsed bool) error {
|
||||
meta := map[string]any{
|
||||
"submission_code": d.SubmissionCode,
|
||||
"draft_id": d.ID.String(),
|
||||
@@ -1168,6 +1340,15 @@ func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *ser
|
||||
"filename": filename,
|
||||
"template_sha": templateSHA,
|
||||
}
|
||||
// t-paliad-313 Slice B — composer flag in metadata so admins can
|
||||
// tell the two render paths apart in the audit feed without
|
||||
// adding a new event_type.
|
||||
if composerUsed {
|
||||
meta["composer"] = true
|
||||
if d.BaseID != nil {
|
||||
meta["base_id"] = d.BaseID.String()
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(meta)
|
||||
var (
|
||||
actorID any
|
||||
|
||||
332
internal/handlers/submission_sections.go
Normal file
332
internal/handlers/submission_sections.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package handlers
|
||||
|
||||
// Submission section handlers — Composer Slice B (t-paliad-313). Backs
|
||||
// the inline editor on /projects/{id}/submissions/{code}/draft/{draft_id}
|
||||
// where the lawyer types prose into each section.
|
||||
//
|
||||
// Endpoint:
|
||||
//
|
||||
// PATCH /api/submission-drafts/{draft_id}/sections/{section_id}
|
||||
//
|
||||
// Body shape (all fields optional — absent = no change):
|
||||
//
|
||||
// {
|
||||
// "content_md_de": "...",
|
||||
// "content_md_en": "...",
|
||||
// "included": true|false,
|
||||
// "label_de": "...",
|
||||
// "label_en": "...",
|
||||
// "order_index": 3
|
||||
// }
|
||||
//
|
||||
// Visibility: ownership of the draft is checked via
|
||||
// SubmissionDraftService.Get (404 on no-access), then the section is
|
||||
// fetched + verified to belong to that draft. The DB-side RLS policy
|
||||
// (mig 148) enforces the same gate independently.
|
||||
//
|
||||
// Returns 200 + the refreshed section row on success.
|
||||
//
|
||||
// This is global-scoped (no /projects/{id}/ prefix) because the
|
||||
// section's owning draft already carries the project_id; routing on
|
||||
// section_id alone keeps the URL shape stable across project-scoped
|
||||
// and project-less drafts.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// submissionSectionPatchInput is the JSON shape accepted by PATCH.
|
||||
type submissionSectionPatchInput struct {
|
||||
ContentMDDE *string `json:"content_md_de,omitempty"`
|
||||
ContentMDEN *string `json:"content_md_en,omitempty"`
|
||||
Included *bool `json:"included,omitempty"`
|
||||
LabelDE *string `json:"label_de,omitempty"`
|
||||
LabelEN *string `json:"label_en,omitempty"`
|
||||
OrderIndex *int `json:"order_index,omitempty"`
|
||||
}
|
||||
|
||||
// submissionSectionPatchTimeout caps the round-trip.
|
||||
const submissionSectionPatchTimeout = 10 * time.Second
|
||||
|
||||
func handlePatchSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||||
return
|
||||
}
|
||||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Owner-scope on the draft (RLS mirror; this gives us the typed
|
||||
// 404 + the path for the "section belongs to a different draft"
|
||||
// case below).
|
||||
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := dbSvc.submissionSection.Get(ctx, sectionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if existing.DraftID != draft.ID {
|
||||
// Section exists but doesn't belong to this draft — surface as
|
||||
// 404 to keep the "no fishing for foreign drafts" property.
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var input submissionSectionPatchInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
patch := services.SectionPatch{
|
||||
ContentMDDE: input.ContentMDDE,
|
||||
ContentMDEN: input.ContentMDEN,
|
||||
Included: input.Included,
|
||||
LabelDE: input.LabelDE,
|
||||
LabelEN: input.LabelEN,
|
||||
OrderIndex: input.OrderIndex,
|
||||
}
|
||||
updated, err := dbSvc.submissionSection.Update(ctx, sectionID, patch)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice F — add custom section / delete section / reorder
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type submissionSectionCreateInput struct {
|
||||
SectionKey string `json:"section_key"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
ContentMDDE string `json:"content_md_de,omitempty"`
|
||||
ContentMDEN string `json:"content_md_en,omitempty"`
|
||||
OrderIndex int `json:"order_index,omitempty"`
|
||||
}
|
||||
|
||||
// handleCreateSubmissionSection backs POST /api/submission-drafts/{draft_id}/sections.
|
||||
// Adds a new (custom) section to the draft. Owner-scoped via
|
||||
// SubmissionDraftService.Get.
|
||||
func handleCreateSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||||
return
|
||||
}
|
||||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var input submissionSectionCreateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
created, err := dbSvc.submissionSection.Create(ctx, services.SectionCreateInput{
|
||||
DraftID: draftID,
|
||||
SectionKey: input.SectionKey,
|
||||
Kind: input.Kind,
|
||||
LabelDE: input.LabelDE,
|
||||
LabelEN: input.LabelEN,
|
||||
ContentMDDE: input.ContentMDDE,
|
||||
ContentMDEN: input.ContentMDEN,
|
||||
OrderIndex: input.OrderIndex,
|
||||
Included: true,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, sectionJSONFromService(created))
|
||||
}
|
||||
|
||||
// handleDeleteSubmissionSection backs DELETE /api/submission-drafts/{draft_id}/sections/{section_id}.
|
||||
// Owner-scoped via SubmissionDraftService.Get + section-belongs-to-draft cross-check.
|
||||
func handleDeleteSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||||
return
|
||||
}
|
||||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||||
defer cancel()
|
||||
|
||||
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
sec, err := dbSvc.submissionSection.Get(ctx, sectionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if sec.DraftID != draft.ID {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.submissionSection.Delete(ctx, sectionID); err != nil {
|
||||
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
type submissionSectionReorderInput struct {
|
||||
SectionOrder []string `json:"section_order"`
|
||||
}
|
||||
|
||||
// handleReorderSubmissionSections backs POST /api/submission-drafts/{draft_id}/sections/reorder.
|
||||
// Accepts a sequence of section_ids; rewrites every row's order_index
|
||||
// to (1, 2, 3, …) × 10 in the supplied order. Returns the refreshed
|
||||
// section list.
|
||||
func handleReorderSubmissionSections(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||||
return
|
||||
}
|
||||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var input submissionSectionReorderInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
order := make([]uuid.UUID, 0, len(input.SectionOrder))
|
||||
for _, raw := range input.SectionOrder {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid section id in order list"})
|
||||
return
|
||||
}
|
||||
order = append(order, id)
|
||||
}
|
||||
|
||||
rows, err := dbSvc.submissionSection.Reorder(ctx, draftID, order)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]submissionSectionJSON, 0, len(rows))
|
||||
for _, sec := range rows {
|
||||
out = append(out, sectionJSONFromService(&sec))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sections": out})
|
||||
}
|
||||
|
||||
// sectionJSONFromService projects a services.SubmissionSection into the
|
||||
// JSON shape the editor consumes — the same shape buildSubmissionDraftView
|
||||
// emits under .sections[].
|
||||
func sectionJSONFromService(sec *services.SubmissionSection) submissionSectionJSON {
|
||||
return 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,63 +4,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
||||
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
|
||||
// from Postgres breaks the row scan with "unsupported Scan, storing
|
||||
// driver.Value type <nil> into type *json.RawMessage" — exactly the
|
||||
// error that hid every approval_request from the inbox when m's first
|
||||
// "create" lifecycle row arrived with NULL pre_image (m's dogfood
|
||||
// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column
|
||||
// fixes the scan and preserves inline JSON output (no base64 cast).
|
||||
type NullableJSON []byte
|
||||
|
||||
func (n *NullableJSON) Scan(value any) error {
|
||||
if value == nil {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
*n = append((*n)[:0], v...)
|
||||
return nil
|
||||
case string:
|
||||
*n = []byte(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("NullableJSON: unsupported scan type %T", value)
|
||||
}
|
||||
|
||||
func (n NullableJSON) Value() (driver.Value, error) {
|
||||
if len(n) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
func (n NullableJSON) MarshalJSON() ([]byte, error) {
|
||||
if len(n) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
func (n *NullableJSON) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
*n = append((*n)[:0], data...)
|
||||
return nil
|
||||
}
|
||||
// NullableJSON is a jsonb column that may be NULL. Canonical definition
|
||||
// (with sql.Scanner / driver.Valuer / json.Marshaler / json.Unmarshaler)
|
||||
// lives in pkg/litigationplanner — kept here as a type alias so every
|
||||
// existing models.NullableJSON reference continues to compile.
|
||||
type NullableJSON = litigationplanner.NullableJSON
|
||||
|
||||
// User extends auth.users with firm-specific profile fields. Created by the
|
||||
// Phase D onboarding flow; without a row here, the user can't see any Projects.
|
||||
@@ -307,7 +264,14 @@ type Deadline struct {
|
||||
OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"`
|
||||
WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"`
|
||||
Source string `db:"source" json:"source"`
|
||||
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
|
||||
// Slice B.4 (mig 140, t-paliad-305): paliad.deadlines.rule_id column
|
||||
// dropped; the back-link now lives on `sequencing_rule_id` (FK to
|
||||
// paliad.sequencing_rules). Same UUID values (sequencing_rules.id
|
||||
// inherited deadline_rules.id during mig 136 backfill), so internal
|
||||
// Go references to `RuleID` continue to carry the same semantic
|
||||
// pointer. The JSON name stays `rule_id` for frontend backward-compat
|
||||
// — B.5 will rename if/when frontend is updated.
|
||||
RuleID *uuid.UUID `db:"sequencing_rule_id" json:"rule_id,omitempty"`
|
||||
// RuleCode is the legal citation ("RoP.023", "R.151") attached at
|
||||
// save time — see migration 032. Free text by design; survives
|
||||
// changes to paliad.deadline_rules and accepts citations from
|
||||
@@ -584,111 +548,54 @@ type Party struct {
|
||||
}
|
||||
|
||||
// DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.).
|
||||
type DeadlineRule struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
||||
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
DurationValue int `db:"duration_value" json:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
||||
Timing *string `db:"timing" json:"timing,omitempty"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||
DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"`
|
||||
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
|
||||
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
|
||||
// this rule's concept (joined via paliad.deadline_concept_event_types
|
||||
// where is_default = true). Lets the deadline create form auto-populate
|
||||
// the Typ chip when the user picks this rule. Hydrated by the service
|
||||
// layer; not a column. NULL when the concept has no mapped event_type.
|
||||
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
|
||||
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// Canonical definition lives in pkg/litigationplanner.Rule — kept here
|
||||
// as a type alias so every existing models.DeadlineRule reference (sqlx
|
||||
// scans, hydration, projection service) continues to compile.
|
||||
type DeadlineRule = litigationplanner.Rule
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Phase 3 unified-rule columns (mig 078, t-paliad-182).
|
||||
// Slice 9 (t-paliad-195) dropped the legacy IsMandatory /
|
||||
// IsOptional / ConditionFlag / ConditionRuleID fields — they
|
||||
// were superseded by Priority / ConditionExpr / IsCourtSet and
|
||||
// the unified calculator no longer reads them.
|
||||
// ---------------------------------------------------------------
|
||||
// SequencingRule is the Slice B.5 (t-paliad-305) canonical name for what
|
||||
// the legacy schema called a "deadline rule". Alias to DeadlineRule so
|
||||
// existing call-sites compile unchanged while new code can adopt the
|
||||
// procedural-event vocabulary. Same struct, same db / json tags.
|
||||
type SequencingRule = DeadlineRule
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
||||
// proceeding-rooted rules. Exactly one of (proceeding_type_id,
|
||||
// trigger_event_id) is set after Slice 3.
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
// ProceduralEvent mirrors paliad.procedural_events — the "what kind of
|
||||
// step is this in the proceeding" identity row. New struct introduced
|
||||
// in Slice B.5 (t-paliad-305) for code that needs the procedural-event
|
||||
// columns alone. Most consumers still pull the merged shape via
|
||||
// SequencingRule through the paliad.deadline_rules_unified view; this
|
||||
// struct unlocks per-PE reads/writes without going through the view.
|
||||
type ProceduralEvent struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
EventKind *string `db:"event_kind" json:"event_kind,omitempty"`
|
||||
PrimaryPartyDefault *string `db:"primary_party_default" json:"primary_party_default,omitempty"`
|
||||
LegalSourceID *uuid.UUID `db:"legal_source_id" json:"legal_source_id,omitempty"`
|
||||
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
||||
// when is_spawn=true and this is non-NULL, the calculator follows
|
||||
// the FK and emits the target proceeding's root rule chain. Slice
|
||||
// 7 backfills the 8 live is_spawn=true rows.
|
||||
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
||||
|
||||
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
||||
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
||||
// NULL = single-anchor arithmetic.
|
||||
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
||||
|
||||
// ConditionExpr is the jsonb gating expression replacing
|
||||
// ConditionFlag (design §2.4). Grammar:
|
||||
// {"flag": "<name>"}
|
||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||
// {"op":"not", "args":[<node>]}
|
||||
// NULL or {} = unconditional. NullableJSON so a NULL column scans
|
||||
// cleanly (the row mishap that hid approval rows from the inbox
|
||||
// must not recur on rule rows).
|
||||
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
||||
|
||||
// Priority is the 4-way unified enum replacing
|
||||
// (IsMandatory, IsOptional). Values: 'mandatory' (default),
|
||||
// 'recommended', 'optional', 'informational'. Backfilled in
|
||||
// Slice 2; legacy callers read IsMandatory + IsOptional until
|
||||
// Slice 4 cuts them over.
|
||||
Priority string `db:"priority" json:"priority"`
|
||||
|
||||
// IsCourtSet replaces the runtime heuristic
|
||||
// (primary_party='court' OR event_type IN ('hearing','decision',
|
||||
// 'order')). Backfilled in Slice 2; legacy callers read the
|
||||
// heuristic until Slice 4.
|
||||
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
||||
|
||||
// LifecycleState drives the rule-editor flow (design §4.2):
|
||||
// 'draft' (admin work-in-progress) | 'published' (live, calculator-
|
||||
// visible) | 'archived' (historical, retained for audit). Every
|
||||
// pre-Slice-1 row defaults to 'published' via the migration.
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
|
||||
// DraftOf points at the published rule this draft will replace on
|
||||
// publish. NULL on published / archived rows. NULL also on net-
|
||||
// new drafts that have no prior published peer.
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
|
||||
// PublishedAt records when the row entered LifecycleState='published'.
|
||||
// NULL while draft, set on publish, retained through archive.
|
||||
// Distinct from UpdatedAt (moves on every edit).
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
|
||||
// ChoicesOffered declares which per-event-card choice-kinds this
|
||||
// rule offers on the Verfahrensablauf timeline (mig 129,
|
||||
// t-paliad-265). NULL = no caret affordance (default). See the
|
||||
// COMMENT on paliad.deadline_rules.choices_offered for the value
|
||||
// shape. The engine and the frontend both read this column.
|
||||
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
|
||||
// LegalSource mirrors paliad.legal_sources — the source-of-law citation
|
||||
// rows that procedural events anchor against. pretty_de / pretty_en are
|
||||
// nullable on disk; readers fall back to
|
||||
// internal/services/submission_vars.go:legalSourcePretty when missing.
|
||||
type LegalSource struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Citation string `db:"citation" json:"citation"`
|
||||
Jurisdiction string `db:"jurisdiction" json:"jurisdiction"`
|
||||
PrettyDE *string `db:"pretty_de" json:"pretty_de,omitempty"`
|
||||
PrettyEN *string `db:"pretty_en" json:"pretty_en,omitempty"`
|
||||
Notes *string `db:"notes" json:"notes,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||
@@ -721,43 +628,19 @@ type DeadlineRuleAudit struct {
|
||||
MigrationExported bool `db:"migration_exported" json:"migration_exported"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
|
||||
// management) or the lowercase dot-separated fristenrechner codes
|
||||
// (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md.
|
||||
type ProceedingType struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
||||
Category *string `db:"category" json:"category,omitempty"`
|
||||
DefaultColor string `db:"default_color" json:"default_color"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
|
||||
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
|
||||
// that fires when no rule has IsRootEvent=true. Populated for UPC Appeal
|
||||
// (mig 121) so the caption reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// NULL on most proceedings — they already carry a root rule.
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||
}
|
||||
// ProceedingType is one of the litigation conceptual codes (INF / REV /
|
||||
// CCR / APM / APP / AMD / ZPO_CIVIL) or the lowercase dot-separated
|
||||
// fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md. Canonical
|
||||
// definition lives in pkg/litigationplanner.ProceedingType — kept here
|
||||
// as a type alias so every existing models.ProceedingType reference
|
||||
// continues to compile.
|
||||
type ProceedingType = litigationplanner.ProceedingType
|
||||
|
||||
// TriggerEvent is a UPC procedural event that can start one or more deadlines
|
||||
// running. Powers the "Was kommt nach…" Fristenrechner mode (event-driven
|
||||
// lookup, mirrored from youpc data.events).
|
||||
type TriggerEvent struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameDE string `db:"name_de" json:"name_de"`
|
||||
Description string `db:"description" json:"description"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
||||
// whose semantic anchor is an event rather than a parent rule.
|
||||
// Canonical definition lives in pkg/litigationplanner.TriggerEvent.
|
||||
type TriggerEvent = litigationplanner.TriggerEvent
|
||||
|
||||
// EventDeadline is a single deadline that flows from a TriggerEvent. Mirrors
|
||||
// youpc data.deadlines + the trigger half of data.deadline_events.
|
||||
|
||||
@@ -220,6 +220,14 @@ func (s *AichatPaliadinService) RunTurnStream(ctx context.Context, req TurnReque
|
||||
}
|
||||
|
||||
if streamErr != nil {
|
||||
// Aichat persona without streaming support — graceful fallback to
|
||||
// the one-shot /chat/turn endpoint. Same body shape; we adapt the
|
||||
// non-streaming response into a single StreamChunk so the caller
|
||||
// sees identical event ordering.
|
||||
if strings.Contains(streamErr.Error(), "unsupported_streaming") {
|
||||
log.Printf("paliadin: persona %q lacks streaming support — falling back to one-shot turn %s", s.cfg.Persona, turnID)
|
||||
return s.fallbackOneShotFromStream(ctx, turnID, body, events, startedAt, session)
|
||||
}
|
||||
// Don't overwrite an existing error_code we may have set above.
|
||||
_ = s.markTurnError(ctx, turnID, classifyAichatError(streamErr))
|
||||
return nil, streamErr
|
||||
@@ -255,6 +263,80 @@ func (s *AichatPaliadinService) RunTurnStream(ctx context.Context, req TurnReque
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fallbackOneShotFromStream runs the same `body` against aichat's
|
||||
// non-streaming /chat/turn endpoint and adapts the response into the
|
||||
// StreamingPaliadin contract — a single StreamChunk + StreamMeta +
|
||||
// StreamConversation, followed by `events` being closed by the
|
||||
// outer RunTurnStream's defer. Used when the configured persona doesn't
|
||||
// support streaming (aichat returns HTTP 400 unsupported_streaming).
|
||||
//
|
||||
// Identical persistence shape as the one-shot RunTurn: completeTurn +
|
||||
// markPrimed/clearPrimed. No new turn row (already inserted by
|
||||
// RunTurnStream). No primer rebuild (already in body).
|
||||
func (s *AichatPaliadinService) fallbackOneShotFromStream(
|
||||
ctx context.Context,
|
||||
turnID uuid.UUID,
|
||||
body aichatTurnRequest,
|
||||
events chan<- StreamEvent,
|
||||
startedAt time.Time,
|
||||
session string,
|
||||
) (*TurnResult, error) {
|
||||
var resp aichatTurnResponse
|
||||
if err := s.callHTTP(ctx, http.MethodPost, "/chat/turn", body, &resp); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, classifyAichatError(err))
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamError,
|
||||
Code: classifyAichatError(err),
|
||||
Message: err.Error(),
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.PaneSpawned {
|
||||
s.clearPrimed(session)
|
||||
} else {
|
||||
s.markPrimed(session)
|
||||
}
|
||||
|
||||
cleanBody := resp.Response
|
||||
tokens := approxTokenCount(cleanBody)
|
||||
chipCount := countChips(cleanBody)
|
||||
finished := time.Now().UTC()
|
||||
durationMS := int(finished.Sub(startedAt) / time.Millisecond)
|
||||
|
||||
tmeta := trailerMeta{
|
||||
UsedTools: resp.Meta.UsedTools,
|
||||
ClassifierTag: resp.Meta.ClassifierTag,
|
||||
RowsSeen: coerceAichatRowsSeen(resp.Meta.RowsSeen),
|
||||
}
|
||||
|
||||
// Emit the response as a single chunk so the frontend renders it.
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamChunk,
|
||||
Content: cleanBody,
|
||||
})
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamMeta,
|
||||
UsedTools: tmeta.UsedTools,
|
||||
ClassifierTag: tmeta.ClassifierTag,
|
||||
RowsSeen: tmeta.RowsSeen,
|
||||
})
|
||||
|
||||
if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, tmeta, chipCount); err != nil {
|
||||
log.Printf("paliadin: complete turn %s (fallback one-shot): %v", turnID, err)
|
||||
}
|
||||
|
||||
return &TurnResult{
|
||||
TurnID: turnID,
|
||||
Response: cleanBody,
|
||||
UsedTools: tmeta.UsedTools,
|
||||
RowsSeen: tmeta.RowsSeen,
|
||||
ChipCount: chipCount,
|
||||
ClassifierTag: tmeta.ClassifierTag,
|
||||
DurationMS: durationMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// streamFrame is one decoded SSE event.
|
||||
type streamFrame struct {
|
||||
event string // "" → default (data:) event
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,24 @@ import (
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// DeadlineRuleService reads paliad.deadline_rules + paliad.proceeding_types.
|
||||
// Rules are static reference data; no visibility check needed.
|
||||
// DeadlineRuleService reads paliad.deadline_rules_unified (mig 139 view
|
||||
// projecting paliad.sequencing_rules + procedural_events +
|
||||
// legal_sources back to the legacy column shape after mig 140 dropped
|
||||
// the underlying table) + paliad.proceeding_types. Rules are static
|
||||
// reference data; no visibility check needed.
|
||||
type DeadlineRuleService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// SequencingRuleService is the Slice B.5 (t-paliad-305) canonical name
|
||||
// for DeadlineRuleService. Alias preserves every existing call-site
|
||||
// while new code can adopt the procedural-event vocabulary.
|
||||
type SequencingRuleService = DeadlineRuleService
|
||||
|
||||
// NewSequencingRuleService is the canonical constructor name; alias to
|
||||
// NewDeadlineRuleService for now. Both return the same underlying type.
|
||||
var NewSequencingRuleService = NewDeadlineRuleService
|
||||
|
||||
// NewDeadlineRuleService wires the service to the pool.
|
||||
func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
|
||||
return &DeadlineRuleService{db: db}
|
||||
@@ -35,10 +47,14 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
||||
choices_offered`
|
||||
choices_offered, applies_to_target`
|
||||
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active`
|
||||
category, default_color, sort_order, is_active,
|
||||
trigger_event_label_de, trigger_event_label_en,
|
||||
appeal_target,
|
||||
role_proactive_label_de, role_proactive_label_en,
|
||||
role_reactive_label_de, role_reactive_label_en`
|
||||
|
||||
// List returns active rules, optionally filtered by proceeding type.
|
||||
// Each row has ConceptDefaultEventTypeID hydrated from
|
||||
@@ -51,13 +67,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`)
|
||||
}
|
||||
@@ -96,7 +112,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
|
||||
@@ -148,7 +164,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)
|
||||
@@ -171,10 +187,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
|
||||
)
|
||||
@@ -192,7 +208,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 {
|
||||
@@ -207,6 +223,44 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for the
|
||||
// given id set, keyed by id. Returns nil, nil for an empty input set so
|
||||
// callers can blindly forward whatever they accumulated. Inactive rows
|
||||
// are included — the conditional-label resolution in fristenrechner.go
|
||||
// surfaces the trigger event's display name even when the catalog row
|
||||
// has been retired, which is preferable to silently falling back to
|
||||
// the (wrong) parent_id name.
|
||||
//
|
||||
// Used by FristenrechnerService.Calculate to redirect a conditional
|
||||
// rule's "abhängig von …" chip from parent_id to trigger_event_id —
|
||||
// the actual semantic anchor for rules whose data-model parent is the
|
||||
// proceeding root but whose real trigger sits in the trigger_events
|
||||
// catalog (e.g. R.262(2) Erwiderung auf Vertraulichkeitsantrag → the
|
||||
// opposing party's confidentiality application). See m/paliad#126.
|
||||
func (s *DeadlineRuleService) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT id, code, name, name_de, description, is_active, created_at
|
||||
FROM paliad.trigger_events
|
||||
WHERE id IN (?)`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build trigger_events IN query: %w", err)
|
||||
}
|
||||
query = s.db.Rebind(query)
|
||||
|
||||
var rows []models.TriggerEvent
|
||||
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("load trigger_events by ids %v: %w", ids, err)
|
||||
}
|
||||
out := make(map[int64]models.TriggerEvent, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.ID] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListByTriggerEvent returns active rules scoped to a single trigger
|
||||
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
|
||||
// These rules carry proceeding_type_id IS NULL (event-rooted) and have
|
||||
@@ -222,7 +276,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 {
|
||||
@@ -250,7 +304,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)
|
||||
@@ -285,7 +339,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 {
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// DeadlineSearchService backs the unified Fristenrechner search bar
|
||||
@@ -921,130 +923,15 @@ func roundScore(v float64) float64 {
|
||||
return float64(int(v*10000+0.5)) / 10000
|
||||
}
|
||||
|
||||
// FormatLegalSourceDisplay renders a structured legal_source code into
|
||||
// the form HLC users read in pleadings:
|
||||
//
|
||||
// UPC.RoP.23.1 → "UPC RoP R.23(1)"
|
||||
// UPC.RoP.139 → "UPC RoP R.139"
|
||||
// DE.PatG.82.1 → "PatG §82(1)"
|
||||
// DE.ZPO.276.1 → "ZPO §276(1)"
|
||||
// EU.EPÜ.108 → "EPÜ Art.108"
|
||||
// EU.EPC-R.79.1 → "EPC R.79(1)"
|
||||
// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
|
||||
//
|
||||
// Returns the empty string for an empty input. Unknown jurisdictions
|
||||
// fall through with the structured form preserved (caller decides
|
||||
// whether to display).
|
||||
// FormatLegalSourceDisplay + BuildLegalSourceURL are canonically
|
||||
// defined in pkg/litigationplanner — kept here as thin re-exports so
|
||||
// the existing in-package + handler call-sites compile unchanged.
|
||||
func FormatLegalSourceDisplay(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
// Malformed — return as-is so the caller still has something.
|
||||
return src
|
||||
}
|
||||
code := parts[1]
|
||||
rest := parts[2:]
|
||||
var prefix string
|
||||
switch code {
|
||||
case "RoP":
|
||||
prefix = "UPC RoP R."
|
||||
case "PatG":
|
||||
prefix = "PatG §"
|
||||
case "ZPO":
|
||||
prefix = "ZPO §"
|
||||
case "EPÜ":
|
||||
prefix = "EPÜ Art."
|
||||
case "EPC-R":
|
||||
prefix = "EPC R."
|
||||
case "RPBA":
|
||||
prefix = "RPBA Art."
|
||||
default:
|
||||
prefix = code + " "
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(prefix) + len(src))
|
||||
b.WriteString(prefix)
|
||||
b.WriteString(rest[0])
|
||||
for _, p := range rest[1:] {
|
||||
b.WriteByte('(')
|
||||
b.WriteString(p)
|
||||
b.WriteByte(')')
|
||||
}
|
||||
return b.String()
|
||||
return lp.FormatLegalSourceDisplay(src)
|
||||
}
|
||||
|
||||
// BuildLegalSourceURL maps a structured legal_source code to a
|
||||
// youpc.org/laws permalink when the cited body is hosted there. Today
|
||||
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
|
||||
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
|
||||
// home yet, so the helper returns the empty string for those and the
|
||||
// caller renders the display string as plain text.
|
||||
//
|
||||
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
|
||||
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
|
||||
// the law-number position are dropped; youpc resolves the page at
|
||||
// <type>.<number> granularity. The law-number is zero-padded to 3
|
||||
// digits to match how youpc stores law_number (laws-data.json carries
|
||||
// "001" / "023" / "220" forms).
|
||||
//
|
||||
// URL shape uses the hash-fragment form that youpc itself emits from
|
||||
// its laws-page redirect (handlers/laws.go:215+229) — the canonical
|
||||
// in-app deep link target. The `/laws/:type/:number` pretty route also
|
||||
// resolves the same page but redirects to the hash form anyway.
|
||||
//
|
||||
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
|
||||
// UPC.RoP.139 → https://youpc.org/laws#UPCRoP.139
|
||||
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
|
||||
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
|
||||
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
|
||||
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
|
||||
func BuildLegalSourceURL(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
var lawType string
|
||||
switch parts[0] + "." + parts[1] {
|
||||
case "UPC.RoP":
|
||||
lawType = "UPCRoP"
|
||||
case "UPC.UPCA":
|
||||
lawType = "UPCA"
|
||||
case "UPC.UPCS":
|
||||
lawType = "UPCS"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
number := padLawNumber(parts[2])
|
||||
if number == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://youpc.org/laws#" + lawType + "." + number
|
||||
}
|
||||
|
||||
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
|
||||
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
|
||||
// 112a) pass through unchanged so the URL still resolves. Empty input
|
||||
// returns the empty string.
|
||||
func padLawNumber(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if len(s) >= 3 {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat("0", 3-len(s)) + s
|
||||
return lp.BuildLegalSourceURL(src)
|
||||
}
|
||||
|
||||
// RefreshSearchView re-populates the materialised view. Safe to call on
|
||||
|
||||
@@ -65,8 +65,13 @@ func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uui
|
||||
return NewPendingApprovalError(rid, role)
|
||||
}
|
||||
|
||||
// Slice B.4 (mig 140, t-paliad-305): rule_id column dropped from
|
||||
// paliad.deadlines. sequencing_rule_id holds the same UUID and is the
|
||||
// FK to paliad.sequencing_rules. SELECT-column lists below pull
|
||||
// sequencing_rule_id into the Deadline.RuleID field (db tag adjusted in
|
||||
// internal/models/models.go).
|
||||
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
|
||||
warning_date, source, sequencing_rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
|
||||
notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at`
|
||||
|
||||
@@ -272,7 +277,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.sequencing_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`
|
||||
@@ -539,7 +544,11 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
if input.RuleID != nil && input.CustomRuleText != nil {
|
||||
return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
|
||||
}
|
||||
appendSet("rule_id", input.RuleID)
|
||||
// Slice B.4 (t-paliad-305): rule_id column dropped; the FK
|
||||
// back-link now lives on sequencing_rule_id. Same UUID value.
|
||||
// The procedural_event_id mirror is derived in
|
||||
// syncDeadlineDualLinks below after the primary UPDATE lands.
|
||||
appendSet("sequencing_rule_id", input.RuleID)
|
||||
var customText *string
|
||||
if input.CustomRuleText != nil {
|
||||
trimmed := strings.TrimSpace(*input.CustomRuleText)
|
||||
@@ -585,6 +594,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.4 (mig 140, t-paliad-305): rule_id column gone;
|
||||
// sequencing_rule_id holds the back-link. When the patch updated
|
||||
// it (auto/custom swap from t-paliad-258), mirror the FK onto
|
||||
// procedural_event_id so the joined view continues to resolve.
|
||||
// Idempotent: no-op when sequencing_rule_id is unchanged.
|
||||
if input.RuleSet {
|
||||
if err := syncDeadlineProceduralEventID(ctx, tx, deadlineID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if input.EventTypeIDs != nil && s.eventTypes != nil {
|
||||
|
||||
50
internal/services/dual_write.go
Normal file
50
internal/services/dual_write.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Slice B.4 retirement of B.2 dual-write (t-paliad-305 / m/paliad#93).
|
||||
//
|
||||
// Mig 140 dropped paliad.deadline_rules and installed INSTEAD OF
|
||||
// triggers on paliad.deadline_rules_unified that route writes to
|
||||
// procedural_events + sequencing_rules + legal_sources. The legacy
|
||||
// dual-write helper (syncDualWriteFromDeadlineRule) and the drift-check
|
||||
// loop (CheckDualWriteDrift / StartDualWriteDriftCheckLoop) reference
|
||||
// paliad.deadline_rules, which no longer exists — they would crash on
|
||||
// first call if kept.
|
||||
//
|
||||
// Survivor: syncDeadlineProceduralEventID — keeps paliad.deadlines's
|
||||
// new procedural_event_id column in sync with sequencing_rule_id after
|
||||
// any UPDATE that touched the latter. Still useful as a "derive from
|
||||
// canonical pointer" helper.
|
||||
//
|
||||
// The DualWriteDriftReport struct + HasDrift method are retired with
|
||||
// the loop they served.
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// syncDeadlineProceduralEventID mirrors paliad.deadlines.sequencing_rule_id
|
||||
// onto procedural_event_id. Call this within an open transaction AFTER
|
||||
// any UPDATE that mutates paliad.deadlines.sequencing_rule_id (today's
|
||||
// callers: DeadlineService.Update on the RuleSet branch, and the
|
||||
// RuleEditorService orphan-resolve path which sets both columns in one
|
||||
// statement so doesn't need this helper).
|
||||
//
|
||||
// Idempotent: NULL sequencing_rule_id collapses procedural_event_id to
|
||||
// NULL via the subquery returning NULL. Slice B.4 (t-paliad-305).
|
||||
func syncDeadlineProceduralEventID(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE paliad.deadlines d
|
||||
SET procedural_event_id = (
|
||||
SELECT sr.procedural_event_id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.id = d.sequencing_rule_id
|
||||
)
|
||||
WHERE d.id = $1`, deadlineID); err != nil {
|
||||
return fmt.Errorf("sync deadline procedural_event_id for %s: %w", deadlineID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
297
internal/services/dual_write_test.go
Normal file
297
internal/services/dual_write_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Slice B.4 (mig 140, t-paliad-305): the legacy paliad.deadline_rules
|
||||
// table is gone and so is CheckDualWriteDrift — there's no parallel
|
||||
// side to compare against. The INSTEAD OF triggers on the view
|
||||
// guarantee parity by construction (single TX fan-out from one
|
||||
// SQL write to three target tables).
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -507,15 +507,21 @@ func TestUIDeadline_IsConditional_UncertainAnchors(t *testing.T) {
|
||||
wantParentCode string
|
||||
}{
|
||||
// Symptom A — backward-anchored on the court-set oral hearing.
|
||||
// Pre-pass fix: order-of-evaluation no longer matters.
|
||||
// Pre-pass fix: order-of-evaluation no longer matters. These
|
||||
// rules have no trigger_event_id, so ParentRuleCode stays on
|
||||
// the parent_id-derived value.
|
||||
{"upc.inf.cfi.translation_request", true, "upc.inf.cfi.oral"},
|
||||
{"upc.inf.cfi.interpreter_cost", true, "upc.inf.cfi.oral"},
|
||||
// R.118(4) chain — parent=decision (court-set).
|
||||
// R.118(4) chain — parent=decision (court-set). No trigger_event_id.
|
||||
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
|
||||
// Symptom B — optional + both anchored on SoC (trigger anchor).
|
||||
{"upc.inf.cfi.confidentiality_response", true, "upc.inf.cfi.soc"},
|
||||
// Symptom B — optional + both, data-model parent is SoC but the
|
||||
// real trigger is the opposing party's confidentiality application.
|
||||
// m/paliad#126 / t-paliad-294: ParentRuleCode now reflects the
|
||||
// trigger_events catalog row (id=25), NOT the parent_id chain.
|
||||
{"upc.inf.cfi.confidentiality_response", true, "application_to_request_confidentiality_from_the_public"},
|
||||
// Negative control — mandatory rule anchored on SoC must keep
|
||||
// its concrete date (no IsConditional, real DueDate).
|
||||
// its concrete date (no IsConditional, real DueDate). No
|
||||
// trigger_event_id, so parent_id-derived code stays.
|
||||
{"upc.inf.cfi.sod", false, "upc.inf.cfi.soc"},
|
||||
}
|
||||
|
||||
@@ -546,6 +552,52 @@ func TestUIDeadline_IsConditional_UncertainAnchors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// m/paliad#126 / t-paliad-294: the conditional chip for R.262(2)
|
||||
// reads from the trigger_events catalog (id=25), so the user sees
|
||||
// the actual semantic anchor instead of the parent_id-derived
|
||||
// "Klageerhebung". Pin the exact DE + EN strings so a future
|
||||
// rename of the catalog row surfaces here.
|
||||
t.Run("R.262(2) conditional label uses trigger_event_id, not parent_id", func(t *testing.T) {
|
||||
d, ok := byCode["upc.inf.cfi.confidentiality_response"]
|
||||
if !ok {
|
||||
t.Fatalf("confidentiality_response missing from response")
|
||||
}
|
||||
const wantNameDE = "Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit"
|
||||
const wantNameEN = "Application to request confidentiality from the public"
|
||||
if d.ParentRuleName != wantNameDE {
|
||||
t.Errorf("ParentRuleName = %q, want %q (trigger_events.name_de for id=25)", d.ParentRuleName, wantNameDE)
|
||||
}
|
||||
if d.ParentRuleNameEN != wantNameEN {
|
||||
t.Errorf("ParentRuleNameEN = %q, want %q (trigger_events.name for id=25)", d.ParentRuleNameEN, wantNameEN)
|
||||
}
|
||||
// Negative guard — neither label should leak the SoC ("Klageerhebung"),
|
||||
// which is the regression the fix exists to prevent.
|
||||
if d.ParentRuleName == "Klageerhebung" || d.ParentRuleNameEN == "Statement of Claim" {
|
||||
t.Errorf("conditional label still resolves via parent_id (SoC); fix regressed")
|
||||
}
|
||||
})
|
||||
|
||||
// Generalisation guard — translations_lodge also carries a real
|
||||
// trigger_event_id (113 = judge-rapporteur's order). Its
|
||||
// conditional chip should reference the order, not its parent_id
|
||||
// (Zwischenverfahren). Locks in the "any rule with trigger_event_id
|
||||
// uses THAT, not parent_id" contract from m/paliad#126.
|
||||
t.Run("translations_lodge conditional label uses trigger_event_id", func(t *testing.T) {
|
||||
d, ok := byCode["upc.inf.cfi.translations_lodge"]
|
||||
if !ok {
|
||||
t.Skip("upc.inf.cfi.translations_lodge missing from response — data drift?")
|
||||
}
|
||||
if !d.IsConditional {
|
||||
t.Skipf("translations_lodge IsConditional=false in current corpus; trigger-event override is only user-visible on conditional rows. Skip but keep the generalisation guard.")
|
||||
}
|
||||
if d.ParentRuleName == "Zwischenverfahren" {
|
||||
t.Errorf("translations_lodge still labelled via parent_id (Zwischenverfahren); should follow trigger_event_id=113")
|
||||
}
|
||||
if d.ParentRuleCode != "order_of_the_judge_rapporteur_to_lodge_translations" {
|
||||
t.Errorf("ParentRuleCode = %q, want trigger_events.code for id=113", d.ParentRuleCode)
|
||||
}
|
||||
})
|
||||
|
||||
// Override path: when the user anchors the oral hearing, the
|
||||
// backward-anchored R.109(1) flips back to a concrete date and
|
||||
// IsConditional clears. This is the click-to-edit unblock.
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// Country and regime constants — keep in sync with the paliad.countries
|
||||
@@ -229,38 +231,14 @@ func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, country, regime
|
||||
// Feiertag" — so a 27-day shift across UPC vacation no longer looks like a
|
||||
// math bug. See t-paliad-119.
|
||||
//
|
||||
// Date fields are JSON-serialised as YYYY-MM-DD strings (the same convention
|
||||
// as UIDeadline.DueDate / OriginalDate) so the frontend doesn't need a
|
||||
// separate RFC3339 parser. Holidays carries the same string-date shape.
|
||||
type AdjustmentReason struct {
|
||||
// Kind is the dominant cause; longest cause wins when several apply
|
||||
// (vacation > public_holiday > weekend).
|
||||
Kind string `json:"kind"`
|
||||
// Holidays collects every named holiday encountered while walking past
|
||||
// the non-working run, deduped by (date, name). May be empty when the
|
||||
// only cause is a weekend.
|
||||
Holidays []HolidayDTO `json:"holidays,omitempty"`
|
||||
// VacationName, VacationStart and VacationEnd describe the contiguous
|
||||
// vacation block the original date sits in. Populated only when Kind
|
||||
// == "vacation". Span boundaries are the first/last vacation day in
|
||||
// the block (excludes the weekends that pad it).
|
||||
VacationName string `json:"vacationName,omitempty"`
|
||||
VacationStart string `json:"vacationStart,omitempty"`
|
||||
VacationEnd string `json:"vacationEnd,omitempty"`
|
||||
// OriginalWeekday is the English weekday name of the original date —
|
||||
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
|
||||
// can localise it.
|
||||
OriginalWeekday string `json:"originalWeekday,omitempty"`
|
||||
}
|
||||
|
||||
// HolidayDTO is the JSON shape for a holiday emitted in AdjustmentReason —
|
||||
// distinct from Holiday so dates serialise as YYYY-MM-DD strings.
|
||||
type HolidayDTO struct {
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
IsVacation bool `json:"isVacation,omitempty"`
|
||||
IsClosure bool `json:"isClosure,omitempty"`
|
||||
}
|
||||
// Canonical AdjustmentReason + HolidayDTO definitions live in
|
||||
// pkg/litigationplanner — kept here as type aliases so every existing
|
||||
// reference (HolidayService methods, JSON serialisation, projection
|
||||
// service) continues to compile.
|
||||
type (
|
||||
AdjustmentReason = litigationplanner.AdjustmentReason
|
||||
HolidayDTO = litigationplanner.HolidayDTO
|
||||
)
|
||||
|
||||
// AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an
|
||||
// explanation. Reason is nil when wasAdjusted is false.
|
||||
|
||||
219
internal/services/lookup_events_test.go
Normal file
219
internal/services/lookup_events_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// TestLookupEvents covers the multi-axis catalog query API from Slice
|
||||
// B2 (m/paliad#124 §18.2). Skipped when TEST_DATABASE_URL is unset,
|
||||
// mirroring TestCalculateRule.
|
||||
//
|
||||
// Cases:
|
||||
// - jurisdiction=UPC, depth=all-following → every active+published
|
||||
// UPC rule, anchor depth=1 for all (no parent_id outside the
|
||||
// filtered set lights up depth>1 because the entire UPC subset is
|
||||
// a single anchor cohort).
|
||||
// - proceeding_type_id (upc.inf.cfi) + party=defendant + depth=next
|
||||
// → defendant rules in upc.inf.cfi at depth=1 + direct children
|
||||
// of those at depth=2.
|
||||
// - unknown jurisdiction value → silently ignored, no filter applied.
|
||||
// - empty axes → all rules (no filter on any axis).
|
||||
func TestLookupEvents(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
catalog := &paliadCatalog{rules: rules}
|
||||
|
||||
t.Run("jurisdiction=UPC, all-following returns the UPC corpus", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected non-empty UPC corpus")
|
||||
}
|
||||
// Every match must be a UPC rule.
|
||||
for _, m := range matches {
|
||||
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
|
||||
t.Errorf("non-UPC row leaked into UPC-axis query: code=%s jurisdiction=%v",
|
||||
m.ProceedingType.Code, m.ProceedingType.Jurisdiction)
|
||||
}
|
||||
if m.DepthFromAnchor < 1 {
|
||||
t.Errorf("depth=%d for rule %s, want >= 1", m.DepthFromAnchor, m.Rule.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("party=defendant scopes to defendant rules", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
Party: "defendant",
|
||||
}, lp.EventLookupDepthNext)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected at least one defendant rule across the UPC corpus")
|
||||
}
|
||||
// Anchor matches (depth=1) must be primary_party=defendant.
|
||||
// Depth=2 children appear under EventLookupDepthNext only as
|
||||
// expansion from anchors — they may carry any party.
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
|
||||
t.Errorf("anchor row %s (depth=1) is not defendant: %v",
|
||||
m.Rule.Name, m.Rule.PrimaryParty)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown jurisdiction value silently falls through", func(t *testing.T) {
|
||||
matchesAll, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{},
|
||||
lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents (all): %v", err)
|
||||
}
|
||||
matchesUnknown, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "XX-not-a-real-jurisdiction",
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents (unknown): %v", err)
|
||||
}
|
||||
if len(matchesAll) != len(matchesUnknown) {
|
||||
t.Errorf("unknown jurisdiction should fall through to no-filter; got %d vs all-axes %d",
|
||||
len(matchesUnknown), len(matchesAll))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=endentscheidung returns upc.apl merits rules", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
AppealTarget: lp.AppealTargetEndentscheidung,
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// Should hit the 7 rules under the unified upc.apl that
|
||||
// carry applies_to_target={endentscheidung} (Slice B1 mig 134).
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl endentscheidung rules after B1 mig")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue // children of anchors may be from other targets
|
||||
}
|
||||
found := false
|
||||
for _, t := range m.Rule.AppliesToTarget {
|
||||
if t == lp.AppealTargetEndentscheidung {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("anchor row %s missing endentscheidung target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=schadensbemessung returns upc.apl merits rules (mig 138 backfill)", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
AppealTarget: lp.AppealTargetSchadensbemessung,
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 merits-track
|
||||
// rules under upc.apl.unified with applies_to_target ⊇ {schadensbemessung}
|
||||
// because R.224 is uniform across substantive R.118 decisions.
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl schadensbemessung rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, t := range m.Rule.AppliesToTarget {
|
||||
if t == lp.AppealTargetSchadensbemessung {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("anchor row %s missing schadensbemessung target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=bucheinsicht returns upc.apl order rules (mig 138 backfill)", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
AppealTarget: lp.AppealTargetBucheinsicht,
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 order-track
|
||||
// rules under upc.apl.unified with applies_to_target ⊇ {bucheinsicht}
|
||||
// because R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are
|
||||
// uniform across the orders they appeal.
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl bucheinsicht rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, t := range m.Rule.AppliesToTarget {
|
||||
if t == lp.AppealTargetBucheinsicht {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("anchor row %s missing bucheinsicht target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,191 +1,63 @@
|
||||
package services
|
||||
|
||||
// proceeding_mapping bridges the two proceeding-type vocabularies in the
|
||||
// codebase: the **litigation** conceptual category (INF / REV / APP /
|
||||
// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding
|
||||
// + Pipeline-A rules, and the **fristenrechner** code category
|
||||
// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator
|
||||
// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects
|
||||
// bind to fristenrechner codes directly, but the litigation→fristenrechner
|
||||
// mapping is still needed for the ~40 Pipeline-A rules that remain on
|
||||
// litigation proceedings and for any other surface that thinks in
|
||||
// litigation terms.
|
||||
//
|
||||
// The mapping table here is the single source of truth — see
|
||||
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
|
||||
// design rationale + ambiguity notes, and
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md for the
|
||||
// lowercase dot-separated naming convention applied by mig 096
|
||||
// (t-paliad-206). **Never silent FK promotion**: every ambiguous case
|
||||
// returns ok=false so callers can degrade gracefully ("no narrowing")
|
||||
// instead of guessing.
|
||||
import lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
|
||||
// Stable code constants — the strings landed by mig 096. Use these
|
||||
// throughout the codebase so a future rename only needs to touch this
|
||||
// file. The id-anchored FKs (deadline_rules.proceeding_type_id,
|
||||
// projects.proceeding_type_id) are unaffected by the rename.
|
||||
// proceeding_mapping bridges the two proceeding-type vocabularies in
|
||||
// the codebase. The canonical implementations now live in
|
||||
// pkg/litigationplanner — this file keeps the existing service-level
|
||||
// names alive as re-exports so the rest of internal/services + tests
|
||||
// compile without an import-rewrite.
|
||||
//
|
||||
// See pkg/litigationplanner/proceeding_mapping.go for the logic +
|
||||
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
|
||||
// design rationale.
|
||||
|
||||
// Stable code constants — re-exported from the package so existing
|
||||
// services / handlers can keep using the bare names.
|
||||
const (
|
||||
CodeUPCInfringement = "upc.inf.cfi"
|
||||
CodeUPCRevocation = "upc.rev.cfi"
|
||||
CodeUPCCounterclaim = "upc.ccr.cfi"
|
||||
CodeUPCPreliminary = "upc.pi.cfi"
|
||||
CodeUPCDamages = "upc.dmgs.cfi"
|
||||
CodeUPCDiscovery = "upc.disc.cfi"
|
||||
CodeUPCAppealMerits = "upc.apl.merits"
|
||||
CodeUPCAppealOrder = "upc.apl.order"
|
||||
CodeUPCAppealCost = "upc.apl.cost"
|
||||
CodeDEInfringementLG = "de.inf.lg"
|
||||
CodeDEInfringementOLG = "de.inf.olg"
|
||||
CodeDEInfringementBGH = "de.inf.bgh"
|
||||
CodeDENullityBPatG = "de.null.bpatg"
|
||||
CodeDENullityBGH = "de.null.bgh"
|
||||
CodeEPAGrant = "epa.grant.exa"
|
||||
CodeEPAOpposition = "epa.opp.opd"
|
||||
CodeEPAOppositionAppeal = "epa.opp.boa"
|
||||
CodeDPMAOpposition = "dpma.opp.dpma"
|
||||
CodeDPMAAppealBPatG = "dpma.appeal.bpatg"
|
||||
CodeDPMAAppealBGH = "dpma.appeal.bgh"
|
||||
CodeUPCInfringement = lp.CodeUPCInfringement
|
||||
CodeUPCRevocation = lp.CodeUPCRevocation
|
||||
CodeUPCCounterclaim = lp.CodeUPCCounterclaim
|
||||
CodeUPCPreliminary = lp.CodeUPCPreliminary
|
||||
CodeUPCDamages = lp.CodeUPCDamages
|
||||
CodeUPCDiscovery = lp.CodeUPCDiscovery
|
||||
CodeUPCAppealMerits = lp.CodeUPCAppealMerits
|
||||
CodeUPCAppealOrder = lp.CodeUPCAppealOrder
|
||||
CodeUPCAppealCost = lp.CodeUPCAppealCost
|
||||
CodeDEInfringementLG = lp.CodeDEInfringementLG
|
||||
CodeDEInfringementOLG = lp.CodeDEInfringementOLG
|
||||
CodeDEInfringementBGH = lp.CodeDEInfringementBGH
|
||||
CodeDENullityBPatG = lp.CodeDENullityBPatG
|
||||
CodeDENullityBGH = lp.CodeDENullityBGH
|
||||
CodeEPAGrant = lp.CodeEPAGrant
|
||||
CodeEPAOpposition = lp.CodeEPAOpposition
|
||||
CodeEPAOppositionAppeal = lp.CodeEPAOppositionAppeal
|
||||
CodeDPMAOpposition = lp.CodeDPMAOpposition
|
||||
CodeDPMAAppealBPatG = lp.CodeDPMAAppealBPatG
|
||||
CodeDPMAAppealBGH = lp.CodeDPMAAppealBGH
|
||||
)
|
||||
|
||||
// MapLitigationToFristenrechner returns the fristenrechner code +
|
||||
// condition flags implied by a (litigationCode, jurisdiction) pair.
|
||||
//
|
||||
// Inputs are case-sensitive — pass the canonical upper-snake form
|
||||
// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous
|
||||
// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero
|
||||
// fristenrechner code; callers should treat that as "no narrowing"
|
||||
// and leave the cascade wide-open rather than auto-pick.
|
||||
//
|
||||
// Condition flags are returned as a slice so callers can apply them
|
||||
// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr,
|
||||
// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag
|
||||
// context applies.
|
||||
// Delegates to litigationplanner.MapLitigationToFristenrechner.
|
||||
func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) {
|
||||
switch litigationCode {
|
||||
case "INF":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, nil, true
|
||||
case "DE":
|
||||
return CodeDEInfringementLG, nil, true
|
||||
}
|
||||
case "REV":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCRevocation, nil, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "CCR":
|
||||
// Counterclaim revocation — UPC fold-in is structural (the
|
||||
// counterclaim lives inside an upc.inf.cfi proceeding with the
|
||||
// with_ccr flag). DE Nichtigkeit is conceptually the same
|
||||
// adversarial-validity test, no separate flag.
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, []string{"with_ccr"}, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "AMD":
|
||||
// Amendment-application bundled into upc.inf.cfi via with_amend.
|
||||
// No DE / EPA / DPMA analogue today.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCInfringement, []string{"with_amend"}, true
|
||||
}
|
||||
case "APP":
|
||||
// Appeal is ambiguous in DE (OLG vs BGH) and the project
|
||||
// model doesn't carry the instance hint we'd need to
|
||||
// disambiguate. UPC is unambiguous — upc.apl.merits covers
|
||||
// the merits appeal track for inf/rev/ccr/damages.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCAppealMerits, nil, true
|
||||
}
|
||||
case "APM":
|
||||
// Preliminary injunction / urgency procedure — UPC-only
|
||||
// concept in the fristenrechner taxonomy.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCPreliminary, nil, true
|
||||
}
|
||||
case "OPP":
|
||||
// Opposition — primarily EPA. DPMA has dpma.opp.dpma but it
|
||||
// doesn't surface from the litigation vocabulary today.
|
||||
if jurisdiction == "EPA" {
|
||||
return CodeEPAOpposition, nil, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
return lp.MapLitigationToFristenrechner(litigationCode, jurisdiction)
|
||||
}
|
||||
|
||||
// ResolveCounterclaimRouting handles the determinator's
|
||||
// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown
|
||||
// for taxonomic completeness, but no rules are attached to it. When the
|
||||
// cascade resolves to upc.ccr.cfi we route the rule lookup back to
|
||||
// upc.inf.cfi with a default with_ccr=true flag — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1.
|
||||
//
|
||||
// `code` is the proceeding code the cascade resolved to. If it's
|
||||
// upc.ccr.cfi, the function returns (CodeUPCInfringement,
|
||||
// []string{"with_ccr"}, true). For any other code the function returns
|
||||
// (code, nil, false) and callers proceed with the code unchanged. The
|
||||
// boolean signals "routing was applied"; the caller can surface the hint
|
||||
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
|
||||
// weiter." in the UI.
|
||||
// ResolveCounterclaimRouting handles the determinator's upc.ccr.cfi
|
||||
// illustrative-peer route. Delegates to
|
||||
// litigationplanner.ResolveCounterclaimRouting.
|
||||
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
|
||||
if route, ok := SubTrackRoutings[code]; ok {
|
||||
return route.ParentCode, route.DefaultFlags, true
|
||||
}
|
||||
return code, nil, false
|
||||
return lp.ResolveCounterclaimRouting(code)
|
||||
}
|
||||
|
||||
// SubTrackRouting describes a proceeding type that has no native rules
|
||||
// of its own and is normally rendered inside a parent proceeding's flow
|
||||
// with one or more condition flags enabled. The Procedure Roadmap
|
||||
// (verfahrensablauf) routes calc requests for these codes to the parent
|
||||
// proceeding + default flags, but preserves the user-picked code/name
|
||||
// in the response identity and surfaces a contextual note explaining
|
||||
// the framing — see m/paliad#58 and the design doc cited above.
|
||||
//
|
||||
// Adding a new sub-track is a data-only change here: extend
|
||||
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
|
||||
// renderer picks it up automatically. The note copy lives in this file
|
||||
// because it's semantic to the routing, not UI chrome.
|
||||
type SubTrackRouting struct {
|
||||
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
|
||||
Code string
|
||||
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
|
||||
ParentCode string
|
||||
// DefaultFlags are merged into the user's flag set so the
|
||||
// gated rules render. Order is preserved.
|
||||
DefaultFlags []string
|
||||
// NoteDE / NoteEN are the contextual banner above the timeline,
|
||||
// explaining that the proceeding type is normally a sub-track.
|
||||
// Plain text — the frontend renders them as a banner.
|
||||
NoteDE string
|
||||
NoteEN string
|
||||
}
|
||||
|
||||
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
|
||||
// The pattern generalises to other "sub-track" proceeding types (e.g.
|
||||
// R.30 application to amend the patent as a standalone roadmap, R.46
|
||||
// preliminary objection) once they have a proceeding-type code of their
|
||||
// own. New entries here are picked up by the spawn-as-standalone
|
||||
// renderer in FristenrechnerService.Calculate without further wiring.
|
||||
var SubTrackRoutings = map[string]SubTrackRouting{
|
||||
CodeUPCCounterclaim: {
|
||||
Code: CodeUPCCounterclaim,
|
||||
ParentCode: CodeUPCInfringement,
|
||||
DefaultFlags: []string{"with_ccr"},
|
||||
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
|
||||
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
|
||||
},
|
||||
}
|
||||
// SubTrackRoutings exposes the sub-track routing registry. SubTrackRouting
|
||||
// is aliased in fristenrechner.go.
|
||||
var SubTrackRoutings = lp.SubTrackRoutings
|
||||
|
||||
// LookupSubTrackRouting returns the sub-track routing for a proceeding
|
||||
// code, or (zero, false) if the code is not a sub-track. Used by the
|
||||
// fristenrechner Calculate path to spawn the parent flow with the sub-
|
||||
// track's default flags.
|
||||
// code, or (zero, false) if the code is not a sub-track. Delegates to
|
||||
// litigationplanner.LookupSubTrackRouting.
|
||||
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
|
||||
r, ok := SubTrackRoutings[code]
|
||||
return r, ok
|
||||
return lp.LookupSubTrackRouting(code)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -1805,7 +1805,7 @@ func (s *ProjectionService) parentHasAnchoredActual(ctx context.Context, project
|
||||
err := s.db.GetContext(ctx, &count, `
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM paliad.deadlines
|
||||
WHERE project_id = $1 AND rule_id = $2
|
||||
WHERE project_id = $1 AND sequencing_rule_id = $2
|
||||
AND (completed_at IS NOT NULL
|
||||
OR status = 'completed'
|
||||
OR source = 'anchor')
|
||||
@@ -1843,7 +1843,7 @@ func (s *ProjectionService) upsertAnchorDeadline(ctx context.Context, userID, pr
|
||||
var existingID uuid.UUID
|
||||
err := s.db.GetContext(ctx, &existingID,
|
||||
`SELECT id FROM paliad.deadlines
|
||||
WHERE project_id = $1 AND rule_id = $2
|
||||
WHERE project_id = $1 AND sequencing_rule_id = $2
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`, projectID, rule.ID)
|
||||
switch {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -212,14 +212,21 @@ func (s *RuleEditorService) ResolveOrphan(ctx context.Context, orphanID uuid.UUI
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
// Slice B.4 (mig 140, t-paliad-305): paliad.deadlines.rule_id column
|
||||
// dropped. Back-link lives on sequencing_rule_id (same UUIDs as
|
||||
// before — sr.id inherited dr.id at mig 136 backfill).
|
||||
// procedural_event_id is derived from the same sequencing_rules row.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadlines
|
||||
SET rule_id = $1,
|
||||
updated_at = $2
|
||||
WHERE id = $3`,
|
||||
`UPDATE paliad.deadlines d
|
||||
SET sequencing_rule_id = $1,
|
||||
procedural_event_id = (SELECT procedural_event_id
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id = $1),
|
||||
updated_at = $2
|
||||
WHERE d.id = $3`,
|
||||
ruleID, now, oc.DeadlineID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("set deadline rule_id: %w", err)
|
||||
return fmt.Errorf("set deadline sequencing_rule_id: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rule_backfill_orphans
|
||||
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// RuleEditorService owns the admin-only rule lifecycle for Phase 3
|
||||
@@ -75,7 +77,13 @@ type RulePatch struct {
|
||||
NameEN *string `json:"name_en,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
// EventType is the legacy JSON key; EventKind is the Slice B.5
|
||||
// canonical name. Decoder accepts either — coalescePatchKeys()
|
||||
// resolves the canonical to the legacy field if only EventKind
|
||||
// was sent. Same uuid wire shape; emit-side wraps via
|
||||
// adminRuleResponse to expose both keys for one slice.
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
DurationValue *int `json:"duration_value,omitempty"`
|
||||
DurationUnit *string `json:"duration_unit,omitempty"`
|
||||
Timing *string `json:"timing,omitempty"`
|
||||
@@ -100,6 +108,24 @@ type RulePatch struct {
|
||||
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
||||
}
|
||||
|
||||
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
|
||||
// JSON aliases into the legacy field positions so the rest of the
|
||||
// service can keep using the existing field names. Canonical wins
|
||||
// when both are sent.
|
||||
//
|
||||
// json:"event_kind" → EventType (legacy)
|
||||
//
|
||||
// Called by the handler immediately after json.Decode. New code can
|
||||
// adopt the canonical naming; legacy callers continue to work.
|
||||
func (p *RulePatch) CoalesceCanonicalKeys() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if p.EventKind != nil {
|
||||
p.EventType = p.EventKind
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRuleInput is the create payload — a full rule row in draft
|
||||
// state. Required fields enforce schema NOT-NULL on insert (name,
|
||||
// name_en, duration_value, duration_unit).
|
||||
@@ -110,9 +136,16 @@ type CreateRuleInput struct {
|
||||
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
||||
// SubmissionCode is the legacy JSON key; Code is the Slice B.5
|
||||
// canonical name. Decoder accepts either — CoalesceCanonicalKeys()
|
||||
// folds Code → SubmissionCode if only the canonical was sent.
|
||||
SubmissionCode *string `json:"submission_code,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
// EventType is the legacy JSON key; EventKind is the Slice B.5
|
||||
// canonical name. Same dual-accept pattern as SubmissionCode/Code.
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
DurationValue int `json:"duration_value"`
|
||||
DurationUnit string `json:"duration_unit"`
|
||||
Timing *string `json:"timing,omitempty"`
|
||||
@@ -134,6 +167,24 @@ type CreateRuleInput struct {
|
||||
SequenceOrder int `json:"sequence_order"`
|
||||
}
|
||||
|
||||
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
|
||||
// JSON aliases into the legacy field positions. Canonical wins when
|
||||
// both are sent. Called by the handler immediately after json.Decode.
|
||||
//
|
||||
// json:"code" → SubmissionCode (legacy)
|
||||
// json:"event_kind" → EventType (legacy)
|
||||
func (in *CreateRuleInput) CoalesceCanonicalKeys() {
|
||||
if in == nil {
|
||||
return
|
||||
}
|
||||
if in.Code != nil {
|
||||
in.SubmissionCode = in.Code
|
||||
}
|
||||
if in.EventKind != nil {
|
||||
in.EventType = in.EventKind
|
||||
}
|
||||
}
|
||||
|
||||
// Create inserts a new rule as lifecycle_state='draft' with
|
||||
// published_at=NULL. The caller's reason is set on the session BEFORE
|
||||
// the INSERT so the mig 079 trigger writes an audit row with the
|
||||
@@ -148,6 +199,16 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
if strings.TrimSpace(input.Priority) == "" {
|
||||
input.Priority = "mandatory"
|
||||
}
|
||||
// Slice B3 (m/paliad#124 §18.3, mig 135): canonical four-value
|
||||
// primary_party vocab. Pre-validate so the user gets a
|
||||
// user-friendly error before the DB CHECK fires with the raw
|
||||
// constraint-violation message.
|
||||
if input.PrimaryParty != nil && !lp.IsValidPrimaryParty(*input.PrimaryParty) {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: primary_party=%q is not one of %v",
|
||||
ErrInvalidInput, *input.PrimaryParty, lp.PrimaryParties,
|
||||
)
|
||||
}
|
||||
if err := s.validateSpawnNoCycle(ctx, nil, input.SpawnProceedingTypeID, input.ProceedingTypeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,7 +228,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
// here writes the live shape only — priority + condition_expr
|
||||
// + is_court_set are the new gates.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadline_rules
|
||||
`INSERT INTO paliad.deadline_rules_unified
|
||||
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
|
||||
name, name_en, description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
@@ -198,6 +259,12 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
return nil, fmt.Errorf("insert rule: %w", err)
|
||||
}
|
||||
|
||||
// Slice B.4 (mig 140, t-paliad-305): write routes through the
|
||||
// INSTEAD OF triggers on paliad.deadline_rules_unified, which fan
|
||||
// out into legal_sources + procedural_events + sequencing_rules.
|
||||
// No Go-side mirror call needed — the INSERT above already landed
|
||||
// the parallel rows.
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create: %w", err)
|
||||
}
|
||||
@@ -220,6 +287,19 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
|
||||
ErrInvalidLifecycleState, id, current.LifecycleState)
|
||||
}
|
||||
|
||||
// Slice B3 (m/paliad#124 §18.3, mig 135): pre-validate the
|
||||
// patch's primary_party so the user gets a user-friendly error
|
||||
// before the DB CHECK fires with the raw constraint-violation
|
||||
// message. Patch field is *string — nil means "don't change",
|
||||
// dereferenced empty string means "set to NULL" (handled below
|
||||
// in buildPatchSets).
|
||||
if patch.PrimaryParty != nil && !lp.IsValidPrimaryParty(*patch.PrimaryParty) {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: primary_party=%q is not one of %v",
|
||||
ErrInvalidInput, *patch.PrimaryParty, lp.PrimaryParties,
|
||||
)
|
||||
}
|
||||
|
||||
// Spawn cycle guard: if the patch sets spawn_proceeding_type_id,
|
||||
// validate against the global graph BEFORE the UPDATE so we can
|
||||
// surface the cycle clearly instead of relying on a runtime
|
||||
@@ -247,11 +327,13 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
|
||||
args = append(args, time.Now().UTC())
|
||||
args = append(args, id)
|
||||
q := fmt.Sprintf(
|
||||
`UPDATE paliad.deadline_rules SET %s WHERE id = $%d AND lifecycle_state = 'draft'`,
|
||||
`UPDATE paliad.deadline_rules_unified SET %s WHERE id = $%d AND lifecycle_state = 'draft'`,
|
||||
strings.Join(sets, ", "), len(args))
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("update rule draft: %w", err)
|
||||
}
|
||||
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF trigger handles the
|
||||
// new-table writes — the UPDATE above is already fan-out.
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update: %w", err)
|
||||
}
|
||||
@@ -285,7 +367,7 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
|
||||
|
||||
newID := uuid.New()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadline_rules
|
||||
`INSERT INTO paliad.deadline_rules_unified
|
||||
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
|
||||
name, name_en, description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
@@ -306,12 +388,16 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
|
||||
is_active,
|
||||
'draft', $2, NULL,
|
||||
now(), now()
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE id = $2`,
|
||||
newID, id,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("clone rule as draft: %w", err)
|
||||
}
|
||||
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF INSERT trigger
|
||||
// mints the synthetic 'null.<8hex>' code when submission_code is
|
||||
// NULL (matching mig 136 + the legacy dual-write helper's
|
||||
// expression).
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit clone: %w", err)
|
||||
}
|
||||
@@ -345,7 +431,7 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
|
||||
|
||||
now := time.Now().UTC()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules
|
||||
`UPDATE paliad.deadline_rules_unified
|
||||
SET lifecycle_state = 'published',
|
||||
published_at = $1,
|
||||
updated_at = $1
|
||||
@@ -358,7 +444,7 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
|
||||
// Archive the peer this draft was cloned from, if any.
|
||||
if current.DraftOf != nil {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules
|
||||
`UPDATE paliad.deadline_rules_unified
|
||||
SET lifecycle_state = 'archived',
|
||||
updated_at = $1
|
||||
WHERE id = $2 AND lifecycle_state = 'published'`,
|
||||
@@ -368,6 +454,10 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
|
||||
}
|
||||
}
|
||||
|
||||
// Slice B.4 (mig 140, t-paliad-305): both UPDATEs above route via
|
||||
// the INSTEAD OF UPDATE trigger, which mirrors the lifecycle flip
|
||||
// onto procedural_events + sequencing_rules in the same TX.
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit publish: %w", err)
|
||||
}
|
||||
@@ -415,7 +505,7 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
|
||||
// timestamp helps audit reads ("when was this rule first live?").
|
||||
if target == "published" {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules
|
||||
`UPDATE paliad.deadline_rules_unified
|
||||
SET lifecycle_state = $1,
|
||||
published_at = COALESCE(published_at, $2),
|
||||
updated_at = $2
|
||||
@@ -426,7 +516,7 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules
|
||||
`UPDATE paliad.deadline_rules_unified
|
||||
SET lifecycle_state = $1, updated_at = $2
|
||||
WHERE id = $3`,
|
||||
target, now, id,
|
||||
@@ -435,6 +525,9 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
|
||||
}
|
||||
}
|
||||
|
||||
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF UPDATE trigger
|
||||
// mirrors the lifecycle flip onto sr + pe.
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit flip: %w", err)
|
||||
}
|
||||
@@ -574,7 +667,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)
|
||||
@@ -585,6 +678,42 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LoadProceedingTypeCodes returns an id → code map for every distinct
|
||||
// non-NULL proceeding_type_id present in rows. Single SELECT against
|
||||
// paliad.proceeding_types (firm-wide reference data, no RLS). Used by
|
||||
// /admin/api/procedural-events to enrich the LIST response with a
|
||||
// proceeding_type_code field so the admin UI can disambiguate
|
||||
// same-named rules at a glance (t-paliad-321).
|
||||
func (s *RuleEditorService) LoadProceedingTypeCodes(ctx context.Context, rows []models.DeadlineRule) (map[int]string, error) {
|
||||
seen := map[int]bool{}
|
||||
var ids []int
|
||||
for _, r := range rows {
|
||||
if r.ProceedingTypeID != nil && !seen[*r.ProceedingTypeID] {
|
||||
seen[*r.ProceedingTypeID] = true
|
||||
ids = append(ids, *r.ProceedingTypeID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
type pair struct {
|
||||
ID int `db:"id"`
|
||||
Code string `db:"code"`
|
||||
}
|
||||
var pairs []pair
|
||||
if err := s.db.SelectContext(ctx, &pairs,
|
||||
`SELECT id, code FROM paliad.proceeding_types WHERE id = ANY($1)`,
|
||||
pq.Array(ids),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("load proceeding_type codes: %w", err)
|
||||
}
|
||||
out := make(map[int]string, len(pairs))
|
||||
for _, p := range pairs {
|
||||
out[p.ID] = p.Code
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetByID returns a single rule. Exported so the handler can call it
|
||||
// directly without round-tripping through ListRules.
|
||||
func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
|
||||
@@ -594,7 +723,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
|
||||
}
|
||||
@@ -604,92 +733,6 @@ func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ExportMigrationsSince returns a SQL blob containing one UPDATE / INSERT
|
||||
// per audited rule change after the given audit row id. Used by the
|
||||
// admin "export changes to a migration file" flow (Q-H-5: pure SQL
|
||||
// format). Returns SQL + count + the latest audit id seen so the
|
||||
// caller can pass it as ?since= on the next call.
|
||||
//
|
||||
// v1 generates one UPDATE per audit row using the after_json snapshot.
|
||||
// Slice 11b will polish the output (re-order so foreign-key edges
|
||||
// resolve, collapse consecutive UPDATEs on the same row, format the
|
||||
// header comment with author + reason). v1 emits one statement per
|
||||
// audit row in chronological order — sufficient for hand-review.
|
||||
type ExportResult struct {
|
||||
MigrationSQL string `json:"migration_sql"`
|
||||
Count int `json:"count"`
|
||||
LatestAuditID string `json:"latest_audit_id"`
|
||||
}
|
||||
|
||||
func (s *RuleEditorService) ExportMigrationsSince(ctx context.Context, sinceAuditID string) (*ExportResult, error) {
|
||||
type auditRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
RuleID uuid.UUID `db:"rule_id"`
|
||||
ChangedAt time.Time `db:"changed_at"`
|
||||
Action string `db:"action"`
|
||||
AfterJSON json.RawMessage `db:"after_json"`
|
||||
Reason string `db:"reason"`
|
||||
}
|
||||
var rows []auditRow
|
||||
q := `SELECT id, rule_id, changed_at, action, after_json, reason
|
||||
FROM paliad.deadline_rule_audit
|
||||
WHERE migration_exported = false`
|
||||
args := []any{}
|
||||
if sinceAuditID != "" {
|
||||
sid, err := uuid.Parse(sinceAuditID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid since= uuid", ErrInvalidInput)
|
||||
}
|
||||
q += ` AND changed_at >= (SELECT changed_at FROM paliad.deadline_rule_audit WHERE id = $1)`
|
||||
args = append(args, sid)
|
||||
}
|
||||
q += ` ORDER BY changed_at ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list audit since: %w", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("-- Auto-generated rule-editor migration export.\n")
|
||||
sb.WriteString("-- Generated at: " + time.Now().UTC().Format(time.RFC3339) + "\n")
|
||||
sb.WriteString("-- Rows: " + fmt.Sprintf("%d", len(rows)) + "\n\n")
|
||||
sb.WriteString("SELECT set_config('paliad.audit_reason',\n")
|
||||
sb.WriteString(" 'rule-editor export: replay of " + fmt.Sprintf("%d", len(rows)) + " edits', true);\n\n")
|
||||
|
||||
latest := ""
|
||||
for _, r := range rows {
|
||||
sb.WriteString("-- audit " + r.ID.String() + " (" + r.Action + " " + r.ChangedAt.Format(time.RFC3339) + "): " + sqlEscape(r.Reason) + "\n")
|
||||
switch r.Action {
|
||||
case "create", "update":
|
||||
if len(r.AfterJSON) == 0 {
|
||||
sb.WriteString("-- (no after_json — skipped)\n\n")
|
||||
continue
|
||||
}
|
||||
sb.WriteString("INSERT INTO paliad.deadline_rules\n")
|
||||
sb.WriteString(" SELECT (jsonb_populate_record(NULL::paliad.deadline_rules, '")
|
||||
sb.WriteString(sqlEscape(string(r.AfterJSON)))
|
||||
sb.WriteString("'::jsonb)).*\n")
|
||||
sb.WriteString("ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, name_en = EXCLUDED.name_en,\n")
|
||||
sb.WriteString(" duration_value = EXCLUDED.duration_value, duration_unit = EXCLUDED.duration_unit,\n")
|
||||
sb.WriteString(" timing = EXCLUDED.timing, priority = EXCLUDED.priority,\n")
|
||||
sb.WriteString(" is_court_set = EXCLUDED.is_court_set,\n")
|
||||
sb.WriteString(" condition_expr = EXCLUDED.condition_expr,\n")
|
||||
sb.WriteString(" lifecycle_state = EXCLUDED.lifecycle_state,\n")
|
||||
sb.WriteString(" updated_at = now();\n\n")
|
||||
case "delete", "archive":
|
||||
sb.WriteString("UPDATE paliad.deadline_rules SET lifecycle_state='archived', updated_at=now() WHERE id='")
|
||||
sb.WriteString(r.RuleID.String())
|
||||
sb.WriteString("';\n\n")
|
||||
}
|
||||
latest = r.ID.String()
|
||||
}
|
||||
|
||||
return &ExportResult{
|
||||
MigrationSQL: sb.String(),
|
||||
Count: len(rows),
|
||||
LatestAuditID: latest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internal helpers
|
||||
// =============================================================================
|
||||
@@ -739,7 +782,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
|
||||
@@ -814,6 +857,3 @@ func nullableJSON(b json.RawMessage) any {
|
||||
return []byte(b)
|
||||
}
|
||||
|
||||
func sqlEscape(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
|
||||
347
internal/services/scenario_service.go
Normal file
347
internal/services/scenario_service.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// ScenarioService reads + writes paliad.scenarios — named compositions
|
||||
// of existing proceedings + flags + per-card choices + anchor dates,
|
||||
// switchable per project or saved as abstract templates on
|
||||
// /tools/verfahrensablauf. Slice D, m/paliad#124 §5, mig 145.
|
||||
//
|
||||
// Visibility:
|
||||
// - Project-scoped scenarios (project_id NOT NULL): require
|
||||
// can_see_project on the bound project (mirrors
|
||||
// EventChoiceService.requireProjectVisible).
|
||||
// - Abstract scenarios (project_id IS NULL): owner-only. Only
|
||||
// created_by can read / mutate.
|
||||
//
|
||||
// The service applies these checks in application code; paliad.scenarios
|
||||
// also has RLS policies (mig 145) as defense-in-depth for callers that
|
||||
// connect through Supabase Auth's auth.uid() session.
|
||||
type ScenarioService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
rules *DeadlineRuleService
|
||||
}
|
||||
|
||||
// NewScenarioService wires the service to its dependencies.
|
||||
func NewScenarioService(db *sqlx.DB, projects *ProjectService, rules *DeadlineRuleService) *ScenarioService {
|
||||
return &ScenarioService{db: db, projects: projects, rules: rules}
|
||||
}
|
||||
|
||||
// Sentinel errors. Mirrors EventChoiceService + the lp package errors
|
||||
// so handlers can map cleanly to HTTP statuses.
|
||||
var (
|
||||
ErrScenarioNotVisible = errors.New("scenario not visible to caller")
|
||||
)
|
||||
|
||||
// CreateScenarioInput is the payload for POST /api/scenarios. project_id
|
||||
// nil = abstract scenario (saved Verfahrensablauf template).
|
||||
type CreateScenarioInput struct {
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
}
|
||||
|
||||
// Create inserts a new scenario after validating the spec.
|
||||
func (s *ScenarioService) Create(ctx context.Context, userID uuid.UUID, input CreateScenarioInput) (*lp.Scenario, error) {
|
||||
if input.Name == "" {
|
||||
return nil, fmt.Errorf("%w: name required", ErrInvalidInput)
|
||||
}
|
||||
if err := s.validateSpec(ctx, input.Spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.ProjectID != nil {
|
||||
if err := s.requireProjectVisible(ctx, userID, *input.ProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var out lp.Scenario
|
||||
err := s.db.GetContext(ctx, &out,
|
||||
`INSERT INTO paliad.scenarios (project_id, name, description, spec, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at`,
|
||||
input.ProjectID, input.Name, input.Description,
|
||||
[]byte(input.Spec), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create scenario: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// Get returns one scenario by id after a visibility check.
|
||||
func (s *ScenarioService) Get(ctx context.Context, userID, scenarioID uuid.UUID) (*lp.Scenario, error) {
|
||||
var sc lp.Scenario
|
||||
err := s.db.GetContext(ctx, &sc,
|
||||
`SELECT id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE id = $1`, scenarioID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, lp.ErrUnknownScenario
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get scenario: %w", err)
|
||||
}
|
||||
if err := s.requireVisible(ctx, userID, &sc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sc, nil
|
||||
}
|
||||
|
||||
// ListForProject returns scenarios attached to one project, ordered by
|
||||
// created_at desc.
|
||||
func (s *ScenarioService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]lp.Scenario, error) {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []lp.Scenario{}
|
||||
err := s.db.SelectContext(ctx, &out,
|
||||
`SELECT id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list scenarios for project: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListAbstractForUser returns the calling user's abstract scenarios.
|
||||
func (s *ScenarioService) ListAbstractForUser(ctx context.Context, userID uuid.UUID) ([]lp.Scenario, error) {
|
||||
out := []lp.Scenario{}
|
||||
err := s.db.SelectContext(ctx, &out,
|
||||
`SELECT id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE project_id IS NULL AND created_by = $1
|
||||
ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list abstract scenarios: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PatchScenarioInput is the payload for PATCH /api/scenarios/{id}. Any
|
||||
// field nil means "don't change". Spec replacement re-runs validation.
|
||||
type PatchScenarioInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Spec json.RawMessage `json:"spec,omitempty"`
|
||||
}
|
||||
|
||||
// Patch updates one or more scenario fields. Visibility check fires
|
||||
// first (the caller must already see the scenario to mutate it).
|
||||
func (s *ScenarioService) Patch(ctx context.Context, userID, scenarioID uuid.UUID, input PatchScenarioInput) (*lp.Scenario, error) {
|
||||
current, err := s.Get(ctx, userID, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(input.Spec) > 0 {
|
||||
if err := s.validateSpec(ctx, input.Spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
sets = append(sets, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if input.Name != nil {
|
||||
add("name = $%d", *input.Name)
|
||||
}
|
||||
if input.Description != nil {
|
||||
add("description = $%d", *input.Description)
|
||||
}
|
||||
if len(input.Spec) > 0 {
|
||||
add("spec = $%d", []byte(input.Spec))
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return current, nil
|
||||
}
|
||||
args = append(args, scenarioID)
|
||||
query := fmt.Sprintf(`UPDATE paliad.scenarios SET %s
|
||||
WHERE id = $%d
|
||||
RETURNING id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at`, joinSets(sets), len(args))
|
||||
var out lp.Scenario
|
||||
if err := s.db.GetContext(ctx, &out, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("patch scenario: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SetActive points a project at one of its scenarios. Pass nil to
|
||||
// clear (revert to ad-hoc per-card choice state).
|
||||
func (s *ScenarioService) SetActive(ctx context.Context, userID, projectID uuid.UUID, scenarioID *uuid.UUID) error {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if scenarioID != nil {
|
||||
// Ensure scenario exists + belongs to this project. A scenario
|
||||
// from a different project (or an abstract one) can't be the
|
||||
// active scenario on this project.
|
||||
sc, err := s.Get(ctx, userID, *scenarioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sc.ProjectID == nil || *sc.ProjectID != projectID {
|
||||
return fmt.Errorf("%w: scenario %s is not attached to project %s",
|
||||
ErrInvalidInput, *scenarioID, projectID)
|
||||
}
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET active_scenario_id = $1 WHERE id = $2`,
|
||||
scenarioID, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set active scenario: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a scenario. Project's active_scenario_id is cleared
|
||||
// automatically via the FK's ON DELETE SET NULL.
|
||||
func (s *ScenarioService) Delete(ctx context.Context, userID, scenarioID uuid.UUID) error {
|
||||
// Visibility check via Get — also resolves the existence question.
|
||||
if _, err := s.Get(ctx, userID, scenarioID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.scenarios WHERE id = $1`, scenarioID); err != nil {
|
||||
return fmt.Errorf("delete scenario: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireVisible enforces the per-row visibility rule:
|
||||
// - project_id NOT NULL → caller must see the project
|
||||
// - project_id IS NULL → caller must be the row's created_by
|
||||
func (s *ScenarioService) requireVisible(ctx context.Context, userID uuid.UUID, sc *lp.Scenario) error {
|
||||
if sc.ProjectID != nil {
|
||||
return s.requireProjectVisible(ctx, userID, *sc.ProjectID)
|
||||
}
|
||||
if sc.CreatedBy == nil || *sc.CreatedBy != userID {
|
||||
return ErrScenarioNotVisible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireProjectVisible mirrors EventChoiceService.requireProjectVisible
|
||||
// (visibility via can_see_project). Cheap re-implementation — keeps the
|
||||
// call-graph small + avoids a cross-service dep.
|
||||
func (s *ScenarioService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
|
||||
var visible bool
|
||||
err := s.db.GetContext(ctx, &visible,
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = $1 AND u.global_role = 'global_admin'
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM paliad.projects p
|
||||
JOIN paliad.project_teams pt ON pt.project_id = ANY(
|
||||
string_to_array(p.path, '.')::uuid[]
|
||||
)
|
||||
WHERE p.id = $2 AND pt.user_id = $1
|
||||
)`, userID, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check project visibility: %w", err)
|
||||
}
|
||||
if !visible {
|
||||
return ErrScenarioNotVisible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSpec checks the jsonb spec is well-formed, has the right
|
||||
// version, and that every referenced proceeding code + submission code
|
||||
// resolves to an active row in the live catalog. Surfaces friendly
|
||||
// errors wrapping ErrInvalidInput so the handler can map to a 400.
|
||||
func (s *ScenarioService) validateSpec(ctx context.Context, raw json.RawMessage) error {
|
||||
if len(raw) == 0 {
|
||||
return fmt.Errorf("%w: spec is required", ErrInvalidInput)
|
||||
}
|
||||
parsed, err := lp.ParseSpec(lp.NullableJSON(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if _, err := parsed.PrimaryProceeding(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if parsed.BaseTriggerDate != "" {
|
||||
if _, err := time.Parse("2006-01-02", parsed.BaseTriggerDate); err != nil {
|
||||
return fmt.Errorf("%w: base_trigger_date %q is not YYYY-MM-DD", ErrInvalidInput, parsed.BaseTriggerDate)
|
||||
}
|
||||
}
|
||||
for i, p := range parsed.Proceedings {
|
||||
if p.Code == "" {
|
||||
return fmt.Errorf("%w: proceedings[%d].code is empty", ErrInvalidInput, i)
|
||||
}
|
||||
if p.Role != lp.ScenarioRolePrimary && p.Role != lp.ScenarioRolePeer {
|
||||
return fmt.Errorf("%w: proceedings[%d].role=%q must be 'primary' or 'peer'",
|
||||
ErrInvalidInput, i, p.Role)
|
||||
}
|
||||
if p.AppealTarget != "" && !lp.IsValidAppealTarget(p.AppealTarget) {
|
||||
return fmt.Errorf("%w: proceedings[%d].appeal_target=%q not in %v",
|
||||
ErrInvalidInput, i, p.AppealTarget, lp.AppealTargets)
|
||||
}
|
||||
if p.TriggerDateOverride != "" {
|
||||
if _, err := time.Parse("2006-01-02", p.TriggerDateOverride); err != nil {
|
||||
return fmt.Errorf("%w: proceedings[%d].trigger_date_override %q is not YYYY-MM-DD",
|
||||
ErrInvalidInput, i, p.TriggerDateOverride)
|
||||
}
|
||||
}
|
||||
for code, dateStr := range p.AnchorOverrides {
|
||||
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
|
||||
return fmt.Errorf("%w: proceedings[%d].anchor_overrides[%q]=%q is not YYYY-MM-DD",
|
||||
ErrInvalidInput, i, code, dateStr)
|
||||
}
|
||||
}
|
||||
// Resolve code against active proceedings.
|
||||
var exists bool
|
||||
if err := s.db.GetContext(ctx, &exists,
|
||||
`SELECT EXISTS(SELECT 1 FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true)`,
|
||||
p.Code); err != nil {
|
||||
return fmt.Errorf("validate spec proceedings[%d]: %w", i, err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%w: proceedings[%d].code=%q is not an active proceeding_type",
|
||||
ErrInvalidInput, i, p.Code)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// joinSets joins SET clauses with ", ". Tiny utility, kept here to
|
||||
// avoid cross-package strings.Join indirection.
|
||||
func joinSets(sets []string) string {
|
||||
out := ""
|
||||
for i, s := range sets {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Suppress unused-import diagnostic when models isn't referenced
|
||||
// (kept for future shape-evolution; canonical scenario row lives in lp).
|
||||
var _ = models.NullableJSON(nil)
|
||||
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user