Compare commits
164 Commits
mai/icarus
...
mai/darwin
| Author | SHA1 | Date | |
|---|---|---|---|
| 733d21c930 | |||
| 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 | |||
| 49ddaa4eb8 | |||
| 1bd2ebb4ae | |||
| f6c8eb5bcf | |||
| 5ba4df9d55 | |||
| 7ca6b2d643 | |||
| ed8af0dca9 | |||
| 293e612582 | |||
| 9d3325bd88 | |||
| 18d2e743ba | |||
| 07d2eb472c | |||
| 7cdccd55ae | |||
| d4ed989b8f | |||
| 54fb676db5 | |||
| c3eaa9b1d4 | |||
| 80883eaac5 | |||
| 5e17de6e07 | |||
| 0e1f62e375 | |||
| cca5e72c57 | |||
| 4d923562f5 | |||
| c70914c2a0 | |||
| 016ac2532a | |||
| c901293c9c | |||
| 0b1653c2bf | |||
| a6cf6ff4c9 | |||
| 191d8e7268 | |||
| cb44b3b8cc | |||
| c4e9875ff4 | |||
| e4c694e01c | |||
| 5efb9f5098 | |||
| c6267e4e6d | |||
| 8e696487e0 | |||
| 001542a3ce | |||
| 4fc3005db8 | |||
| a6d0acbcb4 | |||
| 96eab90044 | |||
| 5348cb548f | |||
| b1340e2be4 | |||
| 1292aa575d | |||
| 87c200a47e | |||
| 4f910e31ea | |||
| bf60fc1400 | |||
| dc47ea7f43 | |||
| 930771a898 | |||
| f2fbf93adf | |||
| 7368e7012b | |||
| d4df81e374 | |||
| 169ace5d26 | |||
| ac7bc27fb7 | |||
| f4dee97493 | |||
| 7aed8e4ec5 | |||
| b429dabf9e | |||
| d3c28009de | |||
| 8be7af7cd6 | |||
| d52995a4d6 | |||
| f0c343c638 | |||
| f11390d18b | |||
| aa2f4aacc6 | |||
| 3d985ef0c2 | |||
| f72e8a7b85 | |||
| 013facb9db | |||
| ff503ffc43 | |||
| 05f7ea2af5 | |||
| df2a1275cb | |||
| 3700d68c68 | |||
| e0c8401482 | |||
| 247e9005db | |||
| e68b800d52 | |||
| 31d78526cf | |||
| a8e2bd8350 | |||
| 8c94dccf83 | |||
| 90f5dd4b1b | |||
| 34e3d7188e | |||
| 24f3baf61f | |||
| 0f2f3e3ea1 |
242
.gitea/workflows/test.yaml
Normal file
242
.gitea/workflows/test.yaml
Normal file
@@ -0,0 +1,242 @@
|
||||
# Paliad CI gate (t-paliad-282 / m/paliad#114).
|
||||
#
|
||||
# Single workflow, two purposes:
|
||||
#
|
||||
# - On every push: gate tier — build + unit + migration smoke. Red gate
|
||||
# means no further work and (on main) no deploy.
|
||||
# - On push to main with gate green: deploy step — calls the Dokploy
|
||||
# compose-deploy API for paliad's compose Zx147ycurfYagKRl_Zzyo, then
|
||||
# polls /health/ready until the new container reports 200.
|
||||
#
|
||||
# The deploy step REPLACES the previous Gitea-push → Dokploy webhook path
|
||||
# (per m's Q11.4 pick: soft-launch with both alive for ~1 week, then
|
||||
# disable the Dokploy auto-deploy toggle). Soft-launch leaves Dokploy's
|
||||
# autoDeploy=true intact today — the workflow's deploy step is additive
|
||||
# and idempotent (Dokploy's deploy is itself idempotent).
|
||||
#
|
||||
# Catches the three failure classes from 2026-05-25:
|
||||
#
|
||||
# - brunel slot collision (~13:20) — TestMigrations_NoDuplicateSlot,
|
||||
# pure unit, no DB needed.
|
||||
# - hermes dropped-col refs (~16:05) — TestBootSmoke, applies all NEW
|
||||
# migrations (those not in the snapshot) end-to-end against a
|
||||
# scratch DB restored from internal/db/testdata/prod-snapshot.sql.
|
||||
# - mig 129 42501 ownership (~14:56→) — TestMigrations_EndToEndAsAppRole,
|
||||
# applies new migrations as the prod-shaped `postgres` role (which
|
||||
# is NOT a superuser on supabase/postgres — same shape as
|
||||
# youpc-supabase prod, see internal/db/testdata/README.md).
|
||||
#
|
||||
# Snapshot approach: dump paliad schema + applied_migrations rows from
|
||||
# prod, commit them. CI restores → ApplyMigrations sees existing migs as
|
||||
# applied, only runs NEW migs (the ones this PR adds). This sidesteps the
|
||||
# fresh-DB idempotence requirement on historical migrations (some of
|
||||
# which use raw COMMIT or pre-installed extensions and can't be replayed
|
||||
# from scratch). To refresh: `make refresh-snapshot`.
|
||||
#
|
||||
# Design: docs/design-cicd-pre-deploy-gate-2026-05-25.md (cronus inventor
|
||||
# shift, t-paliad-282).
|
||||
|
||||
name: Paliad CI gate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'mai/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.24'
|
||||
BUN_VERSION: '1.2'
|
||||
|
||||
jobs:
|
||||
# Gate job 1 — pure build. Catches go/bun build breakage that local
|
||||
# `go build` would catch but which a worker might have skipped before
|
||||
# pushing. Fast (~60 s) so a red here surfaces immediately.
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: go build
|
||||
run: go build ./...
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: bun install + build
|
||||
working-directory: frontend
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
|
||||
# Gate job 2 — Go test suite + migration smoke against snapshot-restored
|
||||
# scratch DB.
|
||||
#
|
||||
# The Postgres service container uses the same supabase/postgres image
|
||||
# as youpc-supabase prod. The CI scratch DB starts empty; a setup step
|
||||
# installs pg_trgm + restores the snapshot. After restore, paliad
|
||||
# schema is at HEAD-of-snapshot and applied_migrations covers every
|
||||
# migration up to (and including) the snapshot's max version.
|
||||
#
|
||||
# ApplyMigrations called in TestBootSmoke / TestMigrations_EndToEndAsAppRole
|
||||
# sees the snapshot's applied set, finds whatever NEW migrations this
|
||||
# PR added on top, and applies only those. The role-split smoke runs as
|
||||
# `postgres` (which is NOT a superuser on supabase/postgres, matching
|
||||
# the prod role topology) — any new migration that needs supabase_admin
|
||||
# privilege fails here as it would in prod.
|
||||
test-go:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
# supabase/postgres baked-in auth schema + supabase role topology
|
||||
# matches youpc-supabase prod. `postgres` here is NOT a superuser
|
||||
# (verified live: \du postgres shows "Create role, Create DB,
|
||||
# Replication, Bypass RLS" — no Superuser). This is the prod-shaped
|
||||
# role the deploy uses.
|
||||
postgres:
|
||||
image: supabase/postgres:15.8.1.060
|
||||
env:
|
||||
POSTGRES_PASSWORD: ci
|
||||
POSTGRES_DB: paliad_scratch
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install postgresql-client
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq postgresql-client
|
||||
|
||||
# Snapshot restore. Two prep steps as supabase_admin (the actual
|
||||
# superuser): GRANT CREATE so the `postgres` role can later create
|
||||
# schemas if a new mig needs it; install pg_trgm so the snapshot's
|
||||
# trigram indexes restore. Snapshot itself loads as `postgres`.
|
||||
- name: Provision + restore snapshot
|
||||
env:
|
||||
PGPASSWORD: ci
|
||||
run: |
|
||||
set -euo pipefail
|
||||
psql -h localhost -U supabase_admin -d paliad_scratch -v ON_ERROR_STOP=1 \
|
||||
-c "GRANT CREATE ON DATABASE paliad_scratch TO postgres;" \
|
||||
-c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
|
||||
psql -h localhost -U postgres -d paliad_scratch -v ON_ERROR_STOP=1 \
|
||||
-f internal/db/testdata/prod-snapshot.sql
|
||||
|
||||
# Pre-flight: catches brunel slot collision in seconds, no DB
|
||||
# contact (still useful even though the test-go job has Postgres
|
||||
# running, because the failure mode is independent).
|
||||
- name: Migration coordination check
|
||||
run: go test -count=1 -run TestMigrations_NoDuplicateSlot ./internal/db/
|
||||
|
||||
# Role-split end-to-end apply. Connects as `postgres` (NOT a
|
||||
# superuser on supabase/postgres) and runs ApplyMigrations against
|
||||
# the snapshot-restored DB. Existing migs are skipped (already in
|
||||
# applied_migrations); NEW migs in this PR apply here. If a new
|
||||
# migration assumes supabase_admin privilege, fails with the same
|
||||
# 42501 error class that took paliad.de offline on 2026-05-25.
|
||||
- name: Migration end-to-end (deploy role)
|
||||
env:
|
||||
TEST_APP_DATABASE_URL: postgres://postgres:ci@localhost:5432/paliad_scratch?sslmode=disable
|
||||
run: go test -count=1 -run TestMigrations_EndToEndAsAppRole ./internal/db/
|
||||
|
||||
# Boot smoke. Confirms ApplyMigrations succeeds + applied set
|
||||
# matches on-disk set + /healthz returns 200 + /health/ready
|
||||
# returns 200 (the live-pool variant via TestHealthReady_Live).
|
||||
- name: Boot smoke + readiness
|
||||
env:
|
||||
TEST_DATABASE_URL: postgres://postgres:ci@localhost:5432/paliad_scratch?sslmode=disable
|
||||
run: go test -count=1 -run 'TestBootSmoke|TestHealthReady_Live' ./cmd/server/
|
||||
|
||||
# Full Go test suite WITHOUT TEST_DATABASE_URL so live-DB service
|
||||
# tests skip (same shape as a developer laptop without a scratch
|
||||
# DB). Live-DB tests in internal/services/* will be activated by a
|
||||
# follow-up shift once the snapshot is verified stable across
|
||||
# multiple PRs — they need investigation against supabase/postgres
|
||||
# 15.8 (parameter type inference differs subtly from youpc-supabase).
|
||||
- name: go test ./... (pure + skip-on-no-DB)
|
||||
run: go test -count=1 ./internal/... ./cmd/...
|
||||
|
||||
# Deploy step. Only runs on push to main and only after both gate jobs
|
||||
# are green. Calls Dokploy's compose.deploy with the paliad compose ID
|
||||
# (Zx147ycurfYagKRl_Zzyo) and polls /health/ready until it returns 200
|
||||
# or times out.
|
||||
#
|
||||
# Skipped on PR / feature branch pushes — those run the gate tier as
|
||||
# a status check but don't trigger a prod deploy. Dokploy's existing
|
||||
# autoDeploy=true webhook continues to fire during the soft-launch
|
||||
# window (per Q11.4); it can be disabled in the Dokploy UI once this
|
||||
# workflow has gated ≥5 successful green deploys.
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, test-go]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Trigger Dokploy compose deploy
|
||||
env:
|
||||
DOKPLOY_KEY: ${{ secrets.DOKPLOY_TOKEN }}
|
||||
DOKPLOY_API: http://100.99.98.201:3000/api/trpc
|
||||
COMPOSE_ID: Zx147ycurfYagKRl_Zzyo
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DOKPLOY_KEY:-}" ]; then
|
||||
echo "ERROR: DOKPLOY_TOKEN secret is not configured."
|
||||
echo " Set the secret in Gitea repo settings before this step can deploy."
|
||||
exit 2
|
||||
fi
|
||||
echo "==> POST compose.deploy"
|
||||
curl -sS --connect-timeout 5 --max-time 30 \
|
||||
-X POST \
|
||||
-H "x-api-key: $DOKPLOY_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"json\":{\"composeId\":\"$COMPOSE_ID\"}}" \
|
||||
"$DOKPLOY_API/compose.deploy"
|
||||
echo
|
||||
|
||||
- name: Wait for /health/ready
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "==> polling https://paliad.de/health/ready"
|
||||
# Up to 5 minutes (60 × 5 s) — paliad's cold-start is normally
|
||||
# ≤30 s; the longer budget covers slow image pulls + migration
|
||||
# apply.
|
||||
for i in $(seq 1 60); do
|
||||
status=$(curl -sS --connect-timeout 3 --max-time 5 \
|
||||
-o /dev/null -w '%{http_code}' \
|
||||
https://paliad.de/health/ready || echo "000")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "ready after ${i} poll(s)"
|
||||
exit 0
|
||||
fi
|
||||
echo " [$i/60] status=$status — sleeping 5s"
|
||||
sleep 5
|
||||
done
|
||||
echo "ERROR: /health/ready did not return 200 within 5 minutes."
|
||||
echo " The deploy fired but the new container is not serving."
|
||||
echo " Investigate: ssh mlake 'docker logs --tail 50 compose-transmit-multi-byte-driver-v7jth9-web-1'"
|
||||
exit 1
|
||||
93
Makefile
93
Makefile
@@ -21,18 +21,26 @@
|
||||
# the test runner's working dirs. None of them touch internal/db/migrations/
|
||||
# files.
|
||||
|
||||
.PHONY: help verify-migrations verify-mig test test-go
|
||||
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot snapshot-upc
|
||||
|
||||
help:
|
||||
@echo "Paliad — developer targets"
|
||||
@echo ""
|
||||
@echo " verify-migrations Dry-run pending migrations + boot smoke (needs TEST_DATABASE_URL)"
|
||||
@echo " verify-mig Alias for verify-migrations"
|
||||
@echo " verify-mig-app End-to-end migration smoke as non-superuser role"
|
||||
@echo " (needs TEST_APP_DATABASE_URL — t-paliad-282 / m/paliad#114)"
|
||||
@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"
|
||||
@echo ""
|
||||
@echo "Set TEST_APP_DATABASE_URL to enable the role-split smoke. Example:"
|
||||
@echo " export TEST_APP_DATABASE_URL=postgres://paliad_app:...@localhost:5432/paliad_scratch"
|
||||
|
||||
# Gate target — the test that would have caught mig 098 / mig 099 before
|
||||
# deploy. Combines:
|
||||
@@ -71,3 +79,86 @@ test:
|
||||
# (full suite, not per-PR).
|
||||
test-go:
|
||||
go test -race ./...
|
||||
|
||||
# Frontend bun:test suite. Runs the 4 existing pure-TS tests today; will
|
||||
# grow as mendel's Slice 3 (frontend test infill) lands.
|
||||
test-frontend:
|
||||
cd frontend && bun test
|
||||
|
||||
# Role-split end-to-end migration smoke — the catch for the mig 129 42501
|
||||
# ownership class (m/paliad#114). Runs ApplyMigrations as a non-superuser
|
||||
# role against TEST_APP_DATABASE_URL. Fails the build if any migration
|
||||
# assumes more privilege than the deploy role has.
|
||||
#
|
||||
# Developer setup (local):
|
||||
# psql -c "CREATE ROLE paliad_app LOGIN PASSWORD 'ci' NOSUPERUSER;"
|
||||
# psql -c "CREATE DATABASE paliad_scratch OWNER paliad_app;"
|
||||
# export TEST_APP_DATABASE_URL=postgres://paliad_app:ci@localhost:5432/paliad_scratch
|
||||
verify-mig-app:
|
||||
@if [ -z "$$TEST_APP_DATABASE_URL" ]; then \
|
||||
echo "ERROR: TEST_APP_DATABASE_URL is not set."; \
|
||||
echo " The role-split migration smoke cannot run without a non-superuser scratch DB."; \
|
||||
echo " See Makefile comments above this target for setup."; \
|
||||
exit 2; \
|
||||
fi
|
||||
go test -count=1 -run TestMigrations_EndToEndAsAppRole ./internal/db/
|
||||
|
||||
# Refresh the prod schema snapshot used by CI's migration smoke
|
||||
# (t-paliad-282 / m/paliad#114). Connects to youpc-supabase prod, dumps
|
||||
# the paliad schema + applied_migrations rows, strips rows beyond the
|
||||
# current branch's max on-disk version, and writes
|
||||
# internal/db/testdata/prod-snapshot.sql.
|
||||
#
|
||||
# When to refresh:
|
||||
# - After merging a PR that added a new migration to main.
|
||||
# - When CI's migration smoke starts spuriously failing because the
|
||||
# snapshot's applied set diverges from on-disk by more than this
|
||||
# branch's worth of new migs.
|
||||
#
|
||||
# Requires PALIAD_PROD_DATABASE_URL env var (a Postgres URL with
|
||||
# pg_dump rights on youpc-supabase). Example:
|
||||
# export PALIAD_PROD_DATABASE_URL='postgres://postgres:PW@100.99.98.201:11833/postgres'
|
||||
refresh-snapshot:
|
||||
@if [ -z "$$PALIAD_PROD_DATABASE_URL" ]; then \
|
||||
echo "ERROR: PALIAD_PROD_DATABASE_URL is not set."; \
|
||||
echo " Refresh requires read access to youpc-supabase prod."; \
|
||||
exit 2; \
|
||||
fi
|
||||
@echo "==> dumping paliad schema (no owner, no privs)..."
|
||||
@pg_dump --schema-only --schema=paliad --no-owner --no-privileges \
|
||||
--no-publications --no-subscriptions \
|
||||
"$$PALIAD_PROD_DATABASE_URL" > internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@echo "==> appending applied_migrations rows..."
|
||||
@pg_dump --data-only --table=paliad.applied_migrations \
|
||||
--no-owner --no-privileges \
|
||||
"$$PALIAD_PROD_DATABASE_URL" >> internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@echo "==> stripping pg16 \\restrict / \\unrestrict commands for pg15 compat..."
|
||||
@sed -i.bak '/^\\restrict /d; /^\\unrestrict /d' internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@rm -f internal/db/testdata/prod-snapshot.sql.tmp.bak
|
||||
@echo "==> stripping applied_migrations rows beyond branch's max on-disk version..."
|
||||
@MAX_VER=$$(ls internal/db/migrations/*.up.sql | xargs -I{} basename {} | sed 's/_.*//' | sort -n | tail -1); \
|
||||
awk -v max=$$MAX_VER ' \
|
||||
/^[0-9]+\t/ { split($$0, a, "\t"); if (a[1]+0 > max) next; } \
|
||||
{ print } \
|
||||
' 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,17 +159,37 @@ 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)
|
||||
sysAuditSvc := services.NewSystemAuditLogService(pool)
|
||||
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
|
||||
svcBundle = &handlers.Services{
|
||||
Pool: pool,
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
SubmissionBase: submissionBaseSvc,
|
||||
SubmissionSection: submissionSectionSvc,
|
||||
SubmissionComposer: submissionComposerSvc,
|
||||
SubmissionBuildingBlock: submissionBuildingBlockSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -218,6 +238,10 @@ func main() {
|
||||
// is captured into __meta of every export and printed in the
|
||||
// embedded README.
|
||||
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
|
||||
@@ -334,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")
|
||||
|
||||
@@ -98,6 +98,51 @@ func TestBootSmoke(t *testing.T) {
|
||||
if body := strings.TrimSpace(rec.Body.String()); body != "ok" {
|
||||
t.Errorf("GET /healthz: body=%q; want \"ok\"", body)
|
||||
}
|
||||
|
||||
// (4) Readiness probe. With a nil Services bundle the endpoint MUST
|
||||
// report 503 — that's the contract documented in handlers/handlers.go.
|
||||
// A separate svc-with-Pool case is exercised in TestHealthReady (live).
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/health/ready", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("GET /health/ready (nil svc): status=%d; want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthReady_Live asserts the readiness probe answers 200 when the
|
||||
// pool is reachable, 503 when it isn't. Requires TEST_DATABASE_URL.
|
||||
//
|
||||
// Why a separate test: TestBootSmoke runs Register with svc=nil to keep
|
||||
// its setup minimal; the pool-reachable path needs the pool wired in
|
||||
// through svc.Pool. Two tests, two assertions, no entanglement.
|
||||
func TestHealthReady_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live readiness probe")
|
||||
}
|
||||
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("db.ApplyMigrations: %v", err)
|
||||
}
|
||||
pool, err := db.OpenPool(url)
|
||||
if err != nil {
|
||||
t.Fatalf("open pool: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret"))
|
||||
handlers.Register(mux, authClient, "", &handlers.Services{Pool: pool})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/health/ready", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET /health/ready (live pool): status=%d, body=%q; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := strings.TrimSpace(rec.Body.String()); body != "ready" {
|
||||
t.Errorf("GET /health/ready (live pool): body=%q; want \"ready\"", body)
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedMigrationVersions returns every N where N_*.up.sql exists in
|
||||
|
||||
@@ -42,5 +42,14 @@ services:
|
||||
- AICHAT_URL=${AICHAT_URL:-}
|
||||
- AICHAT_TOKEN=${AICHAT_TOKEN:-}
|
||||
- AICHAT_PERSONA=${AICHAT_PERSONA:-paliadin}
|
||||
# Backup Mode (m/paliad#77 Slice A). Local-disk export target; the
|
||||
# paliad_exports named volume below persists it across container
|
||||
# restarts. Unset → /admin/backups returns 503 (BackupService gate).
|
||||
- PALIAD_EXPORT_DIR=${PALIAD_EXPORT_DIR:-/var/lib/paliad/exports}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
volumes:
|
||||
- paliad_exports:/var/lib/paliad/exports
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
paliad_exports:
|
||||
|
||||
181
docs/cicd-runner-setup-2026-05-25.md
Normal file
181
docs/cicd-runner-setup-2026-05-25.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# CI/CD runner setup — paliad
|
||||
|
||||
**Companion to:** `docs/design-cicd-pre-deploy-gate-2026-05-25.md` (Slice A, t-paliad-282 / m/paliad#114)
|
||||
**Date:** 2026-05-25
|
||||
**Audience:** mlake / mriver admin (m or head)
|
||||
|
||||
Slice A's `.gitea/workflows/test.yaml` requires (a) at least one online Gitea Actions runner and (b) a Dokploy API token wired as a repo secret. Both are one-time setup actions that paliad's source tree cannot perform itself — they live on infra-side. This doc lists them so the workflow can go green on its first run.
|
||||
|
||||
---
|
||||
|
||||
## 0. Pre-flight: what already exists
|
||||
|
||||
Verified live (2026-05-25 cronus inventor shift):
|
||||
|
||||
- Gitea 1.24.4 on `mgit.msbls.de`, `has_actions: true` on `m/paliad`.
|
||||
- `/api/v1/admin/actions/runners` reports **2 runners** registered. They are likely the shared runners used by `m/mGreen` and `m/mGeo` (both have `.gitea/workflows/deploy.yml` with `runs-on: self-hosted`).
|
||||
- `m/paliad/actions/tasks` reports `total_count=0` — paliad has never run a workflow yet.
|
||||
|
||||
The existing runners may already be capable of running paliad's workflow without further setup. The verification step (§3) below tells you whether they are.
|
||||
|
||||
---
|
||||
|
||||
## 1. Runner placement decision (m's Q11.1)
|
||||
|
||||
m's pick: **mriver**.
|
||||
|
||||
Rationale: mriver hosts the mai worker fleet but workers spend most of their time waiting on Anthropic. mlake's Dokploy + Swarm workload is more contended. A new runner on mriver adds the least pressure to either box.
|
||||
|
||||
If mriver is offline or saturated when CI first fires, fall back to the existing mlake-side runners (they're already registered; no provisioning needed).
|
||||
|
||||
---
|
||||
|
||||
## 2. One-time setup (admin steps)
|
||||
|
||||
### 2.1 Register a new Gitea Actions runner on mriver
|
||||
|
||||
```bash
|
||||
# On mriver, as m:
|
||||
# 1. Download the act_runner binary (matching Gitea 1.24.x)
|
||||
curl -L -o /usr/local/bin/act_runner \
|
||||
https://gitea.com/gitea/act_runner/releases/download/v0.2.13/act_runner-0.2.13-linux-amd64
|
||||
chmod +x /usr/local/bin/act_runner
|
||||
|
||||
# 2. Get a runner registration token. In the Gitea UI:
|
||||
# /admin → Actions → Runners → "Create new Runner"
|
||||
# (or org-scope: /m/paliad/settings/actions/runners)
|
||||
# Copy the token.
|
||||
|
||||
# 3. Register
|
||||
mkdir -p ~/act_runner && cd ~/act_runner
|
||||
act_runner register --no-interactive \
|
||||
--instance https://mgit.msbls.de \
|
||||
--token <REGISTRATION_TOKEN> \
|
||||
--name mriver-paliad-1 \
|
||||
--labels ubuntu-latest:docker://node:20-bookworm
|
||||
|
||||
# 4. Run as a systemd unit (preferred) or as a session daemon
|
||||
# Systemd unit example: /etc/systemd/system/act_runner.service
|
||||
# [Unit]
|
||||
# Description=Gitea Actions runner
|
||||
# After=network.target
|
||||
# [Service]
|
||||
# User=m
|
||||
# WorkingDirectory=/home/m/act_runner
|
||||
# ExecStart=/usr/local/bin/act_runner daemon
|
||||
# Restart=on-failure
|
||||
# [Install]
|
||||
# WantedBy=multi-user.target
|
||||
sudo systemctl enable --now act_runner
|
||||
sudo systemctl status act_runner
|
||||
```
|
||||
|
||||
**Why `ubuntu-latest:docker://node:20-bookworm` for the label?** Gitea Actions' `runs-on: ubuntu-latest` resolves via the runner's label map. Mapping it to a Docker image gives the workflow a sandbox with Docker available — required for our Postgres service container in `test.yaml`. mriver should have Docker (for `paliadin-shim`); if not, install it.
|
||||
|
||||
### 2.2 Register the Dokploy API token as a repo secret
|
||||
|
||||
The workflow's `deploy` job needs `secrets.DOKPLOY_TOKEN`. Use the existing project-wide Dokploy API key (the one stored in `~/.claude/skills/mai-dokploy/SKILL.md`).
|
||||
|
||||
In the Gitea UI:
|
||||
- Navigate to `https://mgit.msbls.de/m/paliad/settings/actions/secrets`
|
||||
- Click "Add secret"
|
||||
- Name: `DOKPLOY_TOKEN`
|
||||
- Value: `mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz`
|
||||
|
||||
Or via API (mAi identity):
|
||||
```bash
|
||||
curl --netrc-file ~/.netrc-mai -sS -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
https://mgit.msbls.de/api/v1/repos/m/paliad/actions/secrets/DOKPLOY_TOKEN \
|
||||
-d '{"data":"mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz"}'
|
||||
```
|
||||
|
||||
(Requires repo-owner permission. If mAi lacks it, m runs it.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Verify the runner sees the workflow
|
||||
|
||||
After (2.1) + (2.2):
|
||||
|
||||
```bash
|
||||
# Push the Slice A branch (the one this doc lives on)
|
||||
git push origin mai/cronus/coder-cicd-slice-a
|
||||
|
||||
# Confirm the runner picked up the job
|
||||
curl --netrc-file ~/.netrc-mai -sS \
|
||||
"https://mgit.msbls.de/api/v1/repos/m/paliad/actions/tasks?limit=5" | jq '.'
|
||||
```
|
||||
|
||||
A new task per job should appear (build, test-go). If `total_count` stays 0, the runner labels don't match the workflow's `runs-on`. Re-register with `--labels ubuntu-latest` (no docker:// suffix) and the existing runners on mlake will pick it up via shell mode.
|
||||
|
||||
---
|
||||
|
||||
## 4. Soft-launch (m's Q11.4)
|
||||
|
||||
m's pick: **keep both Dokploy auto-deploy and the workflow's deploy step alive for ~1 week. After ≥5 successful green deploys via the workflow, disable Dokploy's autoDeploy in the Dokploy UI for the paliad compose.**
|
||||
|
||||
While both are live, every push to main fires:
|
||||
1. Dokploy webhook (existing path) → deploys immediately, no gate.
|
||||
2. Gitea workflow → on green, ALSO calls `compose.deploy`.
|
||||
|
||||
The second call is idempotent — if Dokploy already deployed the same commit, this is a no-op. The workflow's value during soft-launch is the **gate signal**: a red workflow on a green main = the bad migration shipped via the unguarded webhook and broke prod, and the workflow is shouting about it.
|
||||
|
||||
After confidence builds:
|
||||
1. In the Dokploy UI, navigate to the paliad compose → Settings.
|
||||
2. Toggle "Auto Deploy" off.
|
||||
3. Save.
|
||||
|
||||
From this point, the only path to deploy is the workflow's deploy job. Red workflow = no deploy.
|
||||
|
||||
---
|
||||
|
||||
## 5. What Slice A catches today — and what it doesn't
|
||||
|
||||
After this branch (`mai/cronus/coder-cicd-slice-a`) merges to main:
|
||||
|
||||
### Catches (active in CI)
|
||||
|
||||
- **Build breakage** — `go build`, `go vet`, `bun run build`. Red gate, no deploy.
|
||||
- **Slot collisions** — `TestMigrations_NoDuplicateSlot` runs without a DB. A PR adding migration N when version N already exists fails at gate time. This is the brunel-class catch (m/paliad#114 ~13:20 outage).
|
||||
- **New-migration shape errors (hermes class)** — `TestBootSmoke` runs `ApplyMigrations` against the snapshot-restored DB. New migs from this PR get applied for real; any column/relation/syntax error fails the gate before merge.
|
||||
- **New-migration ownership errors (mig 129 42501 class)** — `TestMigrations_EndToEndAsAppRole` runs `ApplyMigrations` connected as `postgres` (NON-superuser on `supabase/postgres:15.8.1.060`, same role topology as youpc-supabase prod). Any migration that assumes supabase_admin privilege fails with the same `42501 must be owner` error class that took paliad.de offline on 2026-05-25.
|
||||
- **Readiness probe regressions** — `TestHealthReady_Live` confirms `/health/ready` returns 200 against a live pool, 503 against a nil pool.
|
||||
- **Pure-Go test regressions** — `go test ./internal/... ./cmd/...` runs without `TEST_DATABASE_URL` (live-DB service tests skip the same way they do on a developer laptop without a scratch DB).
|
||||
|
||||
### Mechanism — the snapshot approach
|
||||
|
||||
CI's scratch DB starts from a `pg_dump` of youpc-supabase paliad schema +
|
||||
`paliad.applied_migrations` rows, committed to `internal/db/testdata/prod-snapshot.sql`. After restore, the scratch DB is at "paliad HEAD of snapshot" and `ApplyMigrations` sees only this PR's new migrations as pending.
|
||||
|
||||
This sidesteps the fresh-DB idempotence problem: several historical migrations (notably mig 037's missing `CREATE EXTENSION pg_trgm`, mig 051's inner `COMMIT;`) can't be replayed from scratch against `supabase/postgres:15.8.1.060`. The snapshot pins everything that's already applied in prod and lets CI focus on what's new — which is what we actually care about for outage prevention.
|
||||
|
||||
Snapshot refresh: `make refresh-snapshot` with `PALIAD_PROD_DATABASE_URL` set (see `internal/db/testdata/README.md`).
|
||||
|
||||
### Known gap — live-DB service tests don't run in CI
|
||||
|
||||
`internal/services/*_test.go` tests with `TEST_DATABASE_URL` set fail against `supabase/postgres:15.8.1.060` with `42P08 inconsistent types deduced for parameter` errors on some INSERT bind paths. The same tests pass against youpc-supabase prod. Cause is unconfirmed — likely subtle differences in type inference between the dockerized image and the prod cluster's configuration. CI today runs `go test ./...` without `TEST_DATABASE_URL` so these tests skip. Not blocking outage prevention; tracked as a follow-up for the post-Slice-A coder.
|
||||
|
||||
### Migration cleanup also bundled in this PR
|
||||
|
||||
Two surgical migration improvements that surfaced during snapshot debugging — kept here because they're small and harmless:
|
||||
|
||||
- **mig 024 + 027** — `ALTER INDEX` / `ALTER POLICY` exception handlers now catch `undefined_object` OR `undefined_table` OR `duplicate_object`. Old handler caught only `undefined_object`; Postgres raises `undefined_table` when the source object never existed and `duplicate_object` when the destination already exists. The expanded handler makes the migrations truly idempotent across the three plausible states: source-still-German (rename succeeds), already-renamed (catches duplicate_object), and fresh-DB-never-had-German (catches undefined_table).
|
||||
|
||||
Other migration history bugs (mig 037 missing pg_trgm, mig 051 inner COMMIT) are tracked as a separate cleanup task — not blocking, because the snapshot bypasses them.
|
||||
|
||||
### Verification checklist (after Slice A merges)
|
||||
|
||||
1. **Workflow green on its first PR run?** Check `/m/paliad/actions`. If not, fix before merging.
|
||||
2. **Dokploy `compose.deploy` call succeeds?** The workflow's `deploy` job logs the POST response. A successful response is a Dokploy job ID; a 4xx is an auth or compose-id problem.
|
||||
3. **`/health/ready` returns 200 within 5 minutes after a green deploy?** The workflow polls this. If it times out, the migration may have failed silently inside the new container — check `docker logs --tail 50 compose-transmit-multi-byte-driver-v7jth9-web-1` on mlake.
|
||||
4. **Reproduce the slot-collision catch locally:** rename `131_…up.sql` to `129_…` (duplicate slot) → workflow MUST fail at `Migration coordination check`. Revert before pushing.
|
||||
5. **Reproduce the role-split catch locally:** add a no-op migration `132_test_supersedes.up.sql` containing `REINDEX SYSTEM paliad_scratch;` (requires superuser). Workflow MUST fail at `Migration end-to-end (deploy role)`. Revert before pushing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Future polish (Slice D, m's Q4 R-pick)
|
||||
|
||||
`mai-test` post-merge shift: once Slice A is stable, wire a Gitea webhook on push-to-main that fires `/mai-test` as a follow-up shift. It runs the broader smoke + integration suite and posts results as a Gitea commit status. Not blocking; the gate doesn't depend on it.
|
||||
|
||||
Implementation belongs in `m/mAi` (the mai webhook handler), not in paliad. Out of scope for Slice A.
|
||||
856
docs/design-date-range-picker-2026-05-25.md
Normal file
856
docs/design-date-range-picker-2026-05-25.md
Normal file
@@ -0,0 +1,856 @@
|
||||
# Symmetric date-range picker — design
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Task:** t-paliad-248 (Gitea m/paliad#79)
|
||||
**Inventor:** atlas
|
||||
**Branch:** `mai/atlas/inventor-symmetric-date`
|
||||
**Status:** READ-ONLY design. Awaiting head's go/no-go before coder shift.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Today paliad has **three independent date-range schemes** scattered across surfaces:
|
||||
|
||||
1. **`/agenda`** — future-only chip row [7|14|30|90 Tage], state `rangeDays`.
|
||||
2. **`/admin/audit-log`** — past-only `<select>` [24h|7d|30d|custom|all] + manual `<input type="date">` pair.
|
||||
3. **`/projects/:id/chart`** — symmetric `RangePreset` [1y|2y|all|custom] + manual date pair.
|
||||
|
||||
…plus a **fourth, unified `TimeHorizon` contract** (`internal/services/filter_spec.go`, mirrored in `frontend/src/client/views/types.ts`) that's used by the filter-bar, Verlauf, Custom Views, and InboxFilterBar — but its "Anpassen" custom-range chip is still stubbed (`filter-bar/axes.ts:105-112`, marked Phase 2, disabled, "coming soon" tooltip).
|
||||
|
||||
The fix is **not** "build a fourth scheme." The fix is to **finish the TimeHorizon contract** (add `past_14d`, `next_14d`, `past_all`, `next_all`), build **one reusable `<DateRangePicker>`** that emits a `TimeSpec`, then migrate the three legacy affordances to it.
|
||||
|
||||
**Layout (m's brief, locked):**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ [Zeitraum: Nächste 30 Tage ▾] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
↓ click to open
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Vergangenheit (ALLE) Zukunft │
|
||||
│ [Ganze Vergangenheit] [⌖ ALLE] [Ganze Zukunft] │
|
||||
│ [90 T] [30 T] [14 T] [7 T] [7 T] [14 T] [30 T] [90 T] │
|
||||
│ │
|
||||
│ ── oder benutzerdefiniert ── │
|
||||
│ Von [____.____.____] Bis [____.____.____] [Anwenden] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Slice plan:**
|
||||
|
||||
- **Slice A** — `<DateRangePicker>` component + 4 new horizon constants (`past_14d`, `next_14d`, `past_all`, `next_all`). Wired onto filter-bar `time` axis first (lights up Verlauf + InboxFilterBar + views simultaneously by replacing the stubbed Phase-2 chip).
|
||||
- **Slice B** — `/agenda` migrates (highest-traffic standalone consumer).
|
||||
- **Slice C** — `/admin/audit-log` + `/projects/:id/chart` migrate. Each surface picks the preset subset it cares about.
|
||||
- **Slice D** *(optional, later)* — upckommentar-style two-handle slicer replaces the inline date-pair for the "custom" mode.
|
||||
|
||||
**Hard rules honoured:**
|
||||
|
||||
- No new top-level table or migration in Slice A — purely additive enum values + Go switch arms.
|
||||
- No new dependency in Slice A — slicer is deferred (it's a non-trivial port from Svelte to paliad's plain TSX renderer).
|
||||
- Backward-compatible URL shape — each surface keeps its current short-alias parser (e.g. `?range=30` → `horizon=next_30d`) and additionally accepts the canonical `?horizon=…&from=…&to=…`.
|
||||
|
||||
---
|
||||
|
||||
## §1 Current state — every date-range affordance
|
||||
|
||||
Cataloguing **every** place a paliad user picks a past/future window, with file:line refs.
|
||||
|
||||
### 1.1 `/agenda` — future-only chip row
|
||||
|
||||
`frontend/src/agenda.tsx:64-67`:
|
||||
|
||||
```tsx
|
||||
<button className="agenda-chip" data-range="7" >7 Tage</button>
|
||||
<button className="agenda-chip" data-range="14" >14 Tage</button>
|
||||
<button className="agenda-chip" data-range="30" >30 Tage</button>
|
||||
<button className="agenda-chip" data-range="90" >90 Tage</button>
|
||||
```
|
||||
|
||||
State machine `frontend/src/client/agenda.ts:80-104`:
|
||||
|
||||
- `state.rangeDays ∈ {7,14,30,90}` (set `VALID_RANGES`). Default `30`.
|
||||
- URL: `?range=30&types=…&event_type=…`.
|
||||
- Fetch: `GET /api/agenda?from=<today>&to=<today+rangeDays-1>&types=…`.
|
||||
- **Future-only by construction** — m's complaint applies precisely here. No "past 7 days" affordance, no "all" affordance.
|
||||
|
||||
### 1.2 `/admin/audit-log` — past-only `<select>` + manual date pair
|
||||
|
||||
`frontend/src/admin-audit-log.tsx:50-65`:
|
||||
|
||||
```tsx
|
||||
<select id="audit-range">
|
||||
<option value="24h">Letzte 24h</option>
|
||||
<option value="7d" selected>Letzte 7 Tage</option>
|
||||
<option value="30d">Letzte 30 Tage</option>
|
||||
<option value="custom">Benutzerdefiniert</option>
|
||||
<option value="all">Alles</option>
|
||||
</select>
|
||||
<!-- custom toggles a date-pair: -->
|
||||
<input type="date" id="audit-from" />
|
||||
<input type="date" id="audit-to" />
|
||||
```
|
||||
|
||||
State machine `frontend/src/client/admin-audit-log.ts:135-174`:
|
||||
|
||||
- `rangePresetToFrom(preset)` converts `"24h" | "7d" | "30d"` → `Date`. `"custom"` reads `from`/`to` inputs. `"all"` clears both bounds.
|
||||
- URL: `?source=…&range=7d&q=…&from=…&to=…&limit=…&before_ts=…&before_id=…` (cursor-paged).
|
||||
- **Past-only by construction.** No future-projection — this is an audit log, looking forward makes no sense.
|
||||
|
||||
### 1.3 `/projects/:id/chart` — symmetric `RangePreset`
|
||||
|
||||
`frontend/src/client/views/types.ts:77-79`:
|
||||
|
||||
```ts
|
||||
range_preset?: "1y" | "2y" | "all" | "custom";
|
||||
range_from?: string;
|
||||
range_to?: string;
|
||||
```
|
||||
|
||||
UI `frontend/src/projects-chart.tsx:78-82`:
|
||||
|
||||
```tsx
|
||||
<input type="date" id="projects-chart-range-from" />
|
||||
<input type="date" id="projects-chart-range-to" />
|
||||
```
|
||||
|
||||
State machine `frontend/src/client/projects-chart.ts:73-118`:
|
||||
|
||||
- `rangeFromURL()` → `{preset, from?, to?}` with default `"1y"`.
|
||||
- "1y" = `today-1y..today+1y`, "2y" = `today-2y..today+2y`, "all" derived from loaded events, "custom" = read inputs.
|
||||
- URL: `?range=1y&from=YYYY-MM-DD&to=YYYY-MM-DD`.
|
||||
- **Symmetric around today** by construction — this is a chart, not a filter; the user is panning a viewport, not picking a fan.
|
||||
|
||||
### 1.4 `views-editor.tsx` (Custom Views config form)
|
||||
|
||||
`frontend/src/views-editor.tsx:102-109`:
|
||||
|
||||
```tsx
|
||||
<select id="editor-time-horizon">
|
||||
<option value="next_7d">Nächste 7 Tage</option>
|
||||
<option value="next_30d">Nächste 30 Tage</option>
|
||||
<option value="next_90d">Nächste 90 Tage</option>
|
||||
<option value="past_30d">Letzte 30 Tage</option>
|
||||
<option value="past_90d">Letzte 90 Tage</option>
|
||||
<option value="any">Beliebig</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
- Mixes past + future, but only 5 horizons exposed (no 14d, no past_7d, no all).
|
||||
- Persists into `paliad.user_views.filter_spec` (JSON column) as a `TimeSpec`.
|
||||
- **This is the closest existing affordance to m's symmetric fan**, but rendered as a plain `<select>` and incomplete.
|
||||
|
||||
### 1.5 Filter-bar `time` axis (riemann's t-paliad-163 Phase 1)
|
||||
|
||||
`frontend/src/client/filter-bar/axes.ts:65-115`:
|
||||
|
||||
- Renders a chip cluster: `[next_7d, next_30d, next_90d, past_30d, any]` (default presets, line 77-79).
|
||||
- **"Anpassen" chip is disabled** with `coming_soon` tooltip (line 108-112). This is the documented Phase 2 substrate.
|
||||
- Surfaces declaring axis `time` thread their own preset list via `RenderAxisOpts.timePresets` — e.g. Verlauf overrides to `["past_7d","past_30d","past_90d","any"]` (`frontend/src/client/projects-detail.ts:2310`).
|
||||
|
||||
Consumers:
|
||||
- `/projects/:id` Verlauf (`projects-detail.ts:2296` initial state, 2310 preset override).
|
||||
- `/views` and `/views/:id` (Custom Views runtime).
|
||||
- `/inbox` (`InboxFilterBar` flow — t-paliad-138/139 derived inbox).
|
||||
|
||||
### 1.6 `horizonBounds()` — the materializer
|
||||
|
||||
`frontend/src/client/projects-detail.ts:393-406` mirrors the Go-side `computeViewSpecBounds()` (`internal/services/view_service.go:156-187`):
|
||||
|
||||
```ts
|
||||
case "past_7d": return { from: offset(-7), to: offset(1) };
|
||||
case "past_30d": return { from: offset(-30), to: offset(1) };
|
||||
case "past_90d": return { from: offset(-90), to: offset(1) };
|
||||
case "next_7d": return { from: day, to: offset(7) };
|
||||
case "next_30d": return { from: day, to: offset(30) };
|
||||
case "next_90d": return { from: day, to: offset(90) };
|
||||
default: return {};
|
||||
```
|
||||
|
||||
(Backend equivalent: `internal/services/view_service.go:160-186`.)
|
||||
|
||||
### 1.7 Single-date inputs (NOT date-range — listed for completeness)
|
||||
|
||||
These are out of scope but mentioned so the audit is exhaustive:
|
||||
|
||||
- `verfahrensablauf.tsx:174` — `#trigger-date` (calculator anchor).
|
||||
- `fristenrechner.tsx:496,504,616` — `#trigger-date`, `#priority-date`, `#event-date` (calculator).
|
||||
- `admin-rules-edit.tsx:265` — `#preview-trigger-date`.
|
||||
- `deadlines-detail.tsx:82` — `#deadline-due-edit` (inline-edit).
|
||||
- `deadlines-new.tsx:116` — `#deadline-due` (form).
|
||||
- `appointments-new.tsx`, `appointments-detail.tsx` — `start_at`/`end_at`.
|
||||
- `projects-detail.tsx:181` — `#smart-timeline-milestone-date` (add-milestone modal).
|
||||
- `components/ProjectFormFields.tsx:134,138` — `#project-filing-date`, `#project-grant-date`.
|
||||
|
||||
### 1.8 Summary matrix
|
||||
|
||||
| Surface | Direction | Presets | Custom | URL contract | Default |
|
||||
|---|---|---|---|---|---|
|
||||
| `/agenda` | Future | 7\|14\|30\|90 | — | `?range=N` | 30d |
|
||||
| `/admin/audit-log` | Past | 24h\|7d\|30d\|all + custom | date pair | `?range=…&from=…&to=…` | 7d |
|
||||
| `/projects/:id/chart` | Symmetric ±N | 1y\|2y\|all + custom | date pair | `?range=…&from=…&to=…` | 1y |
|
||||
| `/views/:id` editor | Past+Future mix | next_7d\|next_30d\|next_90d\|past_30d\|past_90d\|any | — | persisted JSON | next_30d |
|
||||
| Filter-bar `time` axis | Past+Future mix | next_7d\|next_30d\|next_90d\|past_30d\|any | **stubbed** | persisted + `?…__time_from=` | per surface |
|
||||
| Verlauf | Past + any | past_7d\|past_30d\|past_90d\|any | **stubbed** | URL | past_30d |
|
||||
| InboxFilterBar | Mix | filter-bar default | **stubbed** | URL | per surface |
|
||||
|
||||
Three of seven surfaces have **incomplete** custom-range affordances. None of the seven exposes the full symmetric fan m wants.
|
||||
|
||||
---
|
||||
|
||||
## §2 upckommentar slicer pattern
|
||||
|
||||
Verified by reading source at `/home/m/dev/web/upc-kommentar/src/lib/`:
|
||||
|
||||
- **`DateRangeSlider.svelte`** (component, 448 lines).
|
||||
- **`date-range-slider-pure.ts`** (pure-math helpers, 487 lines, fully unit-tested).
|
||||
- **`InboxFilterBar.svelte`** (host).
|
||||
|
||||
### 2.1 What it is
|
||||
|
||||
A **two-handle range slider** that wraps `svelte-range-slider-pips` (npm: `svelte-range-slider-pips@4`). The slider's rail is the upckommentar floor (`2023-01-01`) to today, and the two handles define `dateFrom` and `dateTo`. Step is **1 day** regardless of zoom.
|
||||
|
||||
Public contract (DateRangeSlider.svelte:57-82):
|
||||
|
||||
```ts
|
||||
interface Props {
|
||||
minISO: string; // axis lower bound, default 2023-01-01
|
||||
maxISO: string; // axis upper bound, today
|
||||
fromISO: string | null; // current From (null = parked at min)
|
||||
toISO: string | null; // current To (null = parked at max)
|
||||
onChange: (from, to) => void; // emits on every slider change
|
||||
testid?: string;
|
||||
axisWidthPx?: number; // test override for jsdom
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Anchor rail + granularity
|
||||
|
||||
Below the slider rail is a **custom-rendered anchor rail** (the lib's own pips are hidden via `pips={false}` because they're evenly-spaced approximations — issue #42 in upckommentar). Anchor day-numbers come from `pipAnchorsFor(granularity, minDay, maxDay)`:
|
||||
|
||||
- **year:** every Jan 1 in range.
|
||||
- **month:** every 1st-of-month.
|
||||
- **day:** every Monday.
|
||||
|
||||
Edges (`minDay`, `maxDay`) are always anchors so the user can park at the slider's extremes.
|
||||
|
||||
Granularity has **+/- zoom buttons** in the top-right of the slider (`year → month → day`), with each level showing more anchors.
|
||||
|
||||
### 2.3 Click-to-snap (left half / right half)
|
||||
|
||||
`DateRangeSlider.svelte:219-240` + pure helper `endOfPeriodDay()`:
|
||||
|
||||
- **Left half of an anchor label** → snap closest handle to **start** of period (the anchor day itself, e.g. Jan 1).
|
||||
- **Right half of the same label** → snap to **end** of period (Dec 31 for year, last-of-month for month, Sunday for day).
|
||||
- Keyboard activation falls back to left-half (start-of-period) deterministically.
|
||||
|
||||
### 2.4 Label thinning + two-row alternation
|
||||
|
||||
`pipLabelStrideFor()` + `pipLabelRow()` (pure helpers):
|
||||
|
||||
- Measures rail width via `ResizeObserver`.
|
||||
- Computes a stride — only every Nth label is rendered.
|
||||
- Adjacent rendered labels alternate row 0 / row 1 (~1.1em offset down) so they can sit closer horizontally without colliding.
|
||||
|
||||
### 2.5 Handle behaviour
|
||||
|
||||
- `range=true` draws a colored bar between handles.
|
||||
- `draggy=true` lets the user drag the **bar itself** to shift the window without changing its width.
|
||||
- `pushy=true` — handles push each other when crossed.
|
||||
- `float=true` — tooltip floats above the dragged handle showing `DD.MM.YYYY`.
|
||||
|
||||
### 2.6 URL contract on host
|
||||
|
||||
`InboxFilterBar.svelte` debounces `onChange` at 250ms, then writes:
|
||||
|
||||
```
|
||||
?date_from=2024-03-15&date_to=2024-09-30
|
||||
```
|
||||
|
||||
When a handle is parked at min/max, that bound is **omitted** from the URL (`valuesToFromTo()` in the pure module). So `?date_from=2024-03-15` alone means "from March 15 onwards, no upper bound."
|
||||
|
||||
### 2.7 What's worth borrowing for paliad
|
||||
|
||||
| Element | Borrow? | Why |
|
||||
|---|---|---|
|
||||
| Two-handle drag | **Yes — but defer to Slice D** | Excellent fine-tune UX. Non-trivial to port without `svelte-range-slider-pips` (or a Svelte ↔ TSX adapter). |
|
||||
| Anchor rail with click-to-snap | Yes (in Slice D) | Year/month/Monday anchors are the right granularities. |
|
||||
| Label thinning + two-row alternation | Yes (in Slice D) | Makes the rail readable at any width. |
|
||||
| Granularity + zoom +/- | Yes (in Slice D) | Single most useful interaction; users don't drag pixel-precise. |
|
||||
| Epoch-day pure math | Yes — verbatim | The `date-range-slider-pure.ts` module is well-tested and dependency-free. Port to TS in paliad's pure-helper layer. |
|
||||
| `null` = parked at edge | Yes — already aligned | TimeHorizon's `past_all` / `next_all` map cleanly to "one bound parked at infinity." |
|
||||
| The library `svelte-range-slider-pips` itself | **No** | Adds a Svelte dependency to a non-Svelte project. Slice D would build a tiny equivalent on top of `<input type="range">` × 2 + CSS — or vendor the lib's pure parts. |
|
||||
|
||||
### 2.8 What does NOT apply to paliad
|
||||
|
||||
- **Floor at 2023-01-01.** upckommentar starts at the UPC's first day. paliad has decade-old patents and future-projecting deadlines; the axis must extend in both directions. We use `today ± 5 years` as the default visible range with `past_all` / `next_all` chips to escape it.
|
||||
- **Single granularity locked per session.** upckommentar's UI shows one of year/month/day at a time. paliad's typical use ("next 30 days for the deadline list") doesn't benefit from a zoom; the chips ARE the granularity. Slicer in Slice D only opens when the user picks "Anpassen" — at which point the zoom UI makes sense.
|
||||
|
||||
---
|
||||
|
||||
## §3 Component design — `<DateRangePicker>`
|
||||
|
||||
### 3.1 Public API
|
||||
|
||||
```ts
|
||||
type TimeHorizonExt =
|
||||
| "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "custom";
|
||||
|
||||
interface DateRangePickerProps {
|
||||
// Current state. The component is fully controlled.
|
||||
value: TimeSpec;
|
||||
onChange: (next: TimeSpec) => void;
|
||||
|
||||
// Per-surface preset filter — omit a chip by leaving it out of the array.
|
||||
// Default: all symmetric chips + "any" + "custom".
|
||||
presets?: TimeHorizonExt[];
|
||||
|
||||
// Closed-state button label override. Defaults to the i18n key for value.horizon
|
||||
// (e.g. "Letzte 30 Tage"). Override for surfaces that want a heading prefix
|
||||
// like "Zeitraum: Letzte 30 Tage".
|
||||
labelPrefix?: string;
|
||||
|
||||
// i18n strings consumed via the i18n.ts dictionary. No props for individual labels.
|
||||
// Localisation flows through existing data-i18n attributes.
|
||||
|
||||
// Surface tag — used to derive a stable testid and URL-param namespace if
|
||||
// the host wires URL serialization through helpers we provide (see §4).
|
||||
surface: string; // e.g. "agenda" | "audit-log" | "filter-bar"
|
||||
|
||||
// Mode — popover (default) or modal (rare).
|
||||
mode?: "popover" | "modal";
|
||||
|
||||
// Anchor / placement for popover mode. Defaults to "below".
|
||||
placement?: "below" | "above" | "right";
|
||||
}
|
||||
```
|
||||
|
||||
`TimeSpec` mirrors the existing shape (`internal/services/filter_spec.go:107-112`), extended with the 4 new horizon values:
|
||||
|
||||
```ts
|
||||
interface TimeSpec {
|
||||
horizon: TimeHorizonExt;
|
||||
field?: "auto" | "created_at";
|
||||
from?: string; // ISO YYYY-MM-DD; set only when horizon === "custom"
|
||||
to?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 States
|
||||
|
||||
The component is a small state machine:
|
||||
|
||||
```
|
||||
closed ────[click button]────► open
|
||||
▲ │
|
||||
└──[click outside / Esc]───────┘
|
||||
│
|
||||
open ───[click chip]──── closed (commit immediately)
|
||||
│
|
||||
open ───[click "Anpassen"]► custom-editor
|
||||
│
|
||||
custom-editor ─[Anwenden]► closed (commit)
|
||||
custom-editor ─[Esc]─────► open
|
||||
```
|
||||
|
||||
- **closed** — single button with current selection label and a chevron `▾`. No outline/highlight unless the value is not the default for this surface.
|
||||
- **open** — popover anchored below the button (or below-then-flip-up on viewport-bottom). Contains the symmetric chip row + ALL center + "Anpassen" sub-section.
|
||||
- **custom-editor** — replaces the "Anpassen" link with two `<input type="date">` + "Anwenden" / "Abbrechen" buttons. (In Slice D this becomes the slicer.)
|
||||
|
||||
### 3.3 Symmetric chip layout
|
||||
|
||||
The popover body — full ASCII sketch:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ╭ Vergangenheit ────────╮ ╭ ALLES ╮ ╭ Zukunft ───────────╮ │
|
||||
│ │ [Ganze Vergangenheit] │ │ [⌖] │ │ [Ganze Zukunft] │ │
|
||||
│ │ [Letzte 90 Tage] │ │ │ │ [Nächste 7 Tage] │ │
|
||||
│ │ [Letzte 30 Tage] │ │ │ │ [Nächste 14 Tage] │ │
|
||||
│ │ [Letzte 14 Tage] │ │ │ │ [Nächste 30 Tage] │ │
|
||||
│ │ [Letzte 7 Tage] │ │ │ │ [Nächste 90 Tage] │ │
|
||||
│ ╰───────────────────────╯ ╰───────╯ ╰────────────────────╯ │
|
||||
│ │
|
||||
│ ── Anpassen ────────────────────────────────────────── │
|
||||
│ Von [____.____.____] Bis [____.____.____] [Anwenden] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Visual cues:
|
||||
|
||||
- The currently-selected chip gets the **lime accent** (`--color-bg-lime-tint` background, `--color-text` text, `--color-accent` border) — matches existing `.agenda-chip-active` so we don't introduce a new active state.
|
||||
- The "ALLES" center button is **larger** than the fan chips (44px tall vs. 32px), drawn with a target-style glyph `⌖` (or `∞` — see Q3.B). Inventor pick: `⌖` plus the word "ALLES" beneath. Larger so it reads as "the no-filter affordance," not as one chip among many.
|
||||
- The two fans are visually **mirrored** — past on the left, future on the right. Both have a "Ganze …" terminal chip at the outer edge (left-most for past_all, right-most for next_all) and decreasing-magnitude chips fanning toward the center. The ordering matches the human intuition: "left = back in time, right = forward in time."
|
||||
- On viewports < 480px the popover stacks vertically (past fan above, ALL middle, future fan below). On viewports < 360px the popover becomes a modal-feeling slide-up sheet (existing inbox modal CSS pattern reusable).
|
||||
|
||||
### 3.4 Sketch of the closed button states
|
||||
|
||||
```
|
||||
default: ┌─Zeitraum: Nächste 30 Tage ▾─┐
|
||||
custom: ┌─Zeitraum: 15.03.2026 – 30.04.2026 ▾─┐
|
||||
any: ┌─Zeitraum: Alles ▾─┐
|
||||
past_all: ┌─Zeitraum: Ganze Vergangenheit ▾─┐
|
||||
hover/open: same + outline + bg-accent-tint
|
||||
```
|
||||
|
||||
When the value is **not** the surface default, an additional small `●` dot appears between "Zeitraum:" and the value — the existing universal "filter is non-default" indicator used by the filter-bar.
|
||||
|
||||
### 3.5 Keyboard
|
||||
|
||||
- `Tab` lands on the button. `Enter`/`Space` opens the popover.
|
||||
- `Esc` from open state closes it. `Esc` from custom-editor returns to chip view (one level back).
|
||||
- Chips are focusable buttons in the natural left-to-right reading order: past_all → past_90 → past_30 → past_14 → past_7 → any (center) → next_7 → next_14 → next_30 → next_90 → next_all.
|
||||
- The custom date inputs are `<input type="date" lang="de">` — gets the OS-native picker on macOS / iOS / Android / Windows. No new custom calendar widget.
|
||||
|
||||
### 3.6 Accessibility
|
||||
|
||||
- The button has `aria-haspopup="dialog"` and `aria-expanded` toggled on open/close.
|
||||
- The popover has `role="dialog"` with `aria-label` = `t("date_range.dialog.label")` ("Zeitraum wählen" / "Choose date range").
|
||||
- Chips are `<button>` with `aria-pressed="true"` on the active one.
|
||||
- The two fan groups have `role="group"` + `aria-label="Vergangenheit"` / `aria-label="Zukunft"`.
|
||||
|
||||
### 3.7 Module layout
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── components/
|
||||
│ └── DateRangePicker.tsx ← TSX shell (markup only)
|
||||
├── client/
|
||||
│ ├── date-range-picker.ts ← mount() + state machine + DOM event wiring
|
||||
│ └── date-range-picker-pure.ts ← horizon-bounds math, label resolver, parse/serialize
|
||||
└── styles/
|
||||
└── global.css ← .date-range-* classes
|
||||
```
|
||||
|
||||
`-pure.ts` is the headless module — fully testable under `bun test`. The boot client in `-picker.ts` consumes it, mirroring the pattern used by `shape-timeline-chart.ts` + `shape-timeline-chart.test.ts` (see memory: t-paliad-173 / gauss).
|
||||
|
||||
Pure module exports (preliminary):
|
||||
|
||||
```ts
|
||||
export function horizonBounds(h: TimeHorizonExt, now: Date): { from?: Date; to?: Date }
|
||||
export function labelForHorizon(h: TimeHorizonExt, lang: "de"|"en"): string
|
||||
export function labelForCustom(from: string, to: string, lang: "de"|"en"): string
|
||||
export function parseURL(params: URLSearchParams): TimeSpec
|
||||
export function serializeURL(spec: TimeSpec, defaults: Partial<TimeSpec>): URLSearchParams
|
||||
export function isDefault(spec: TimeSpec, default_: TimeSpec): boolean
|
||||
```
|
||||
|
||||
### 3.8 Go-side additions
|
||||
|
||||
`internal/services/filter_spec.go`:
|
||||
|
||||
```go
|
||||
// Add four new constants alongside the existing TimeHorizon block.
|
||||
HorizonNext14d TimeHorizon = "next_14d"
|
||||
HorizonPast14d TimeHorizon = "past_14d"
|
||||
HorizonNextAll TimeHorizon = "next_all"
|
||||
HorizonPastAll TimeHorizon = "past_all"
|
||||
```
|
||||
|
||||
`internal/services/view_service.go:computeViewSpecBounds()`:
|
||||
|
||||
```go
|
||||
case HorizonNext14d:
|
||||
bounds.from = &startOfDay; t := startOfDay.AddDate(0, 0, 14); bounds.to = &t
|
||||
case HorizonPast14d:
|
||||
f := startOfDay.AddDate(0, 0, -14); bounds.from = &f; bounds.to = &startOfTomorrow
|
||||
case HorizonNextAll:
|
||||
bounds.from = &startOfDay
|
||||
// bounds.to left nil → "no upper bound"
|
||||
case HorizonPastAll:
|
||||
bounds.to = &startOfTomorrow
|
||||
// bounds.from left nil
|
||||
```
|
||||
|
||||
`HorizonNextAll` and `HorizonPastAll` are **one-sided unbounded** — distinct from existing `HorizonAll` (bidirectional unbounded) and `HorizonAny` (no filter at all, same effect as `HorizonAll` for view-spec runtime but different in intent).
|
||||
|
||||
`filter_spec.go:validate()` (line 280-292) gains the two new past/next constants in the switch.
|
||||
|
||||
### 3.9 i18n keys
|
||||
|
||||
Two-language matrix (DE primary, EN secondary):
|
||||
|
||||
```
|
||||
date_range.button.label "Zeitraum" / "Time range"
|
||||
date_range.button.label.custom "Von … bis …" / "From … to …"
|
||||
date_range.horizon.next_7d "Nächste 7 Tage" / "Next 7 days"
|
||||
date_range.horizon.next_14d "Nächste 14 Tage" / "Next 14 days"
|
||||
date_range.horizon.next_30d "Nächste 30 Tage" / "Next 30 days"
|
||||
date_range.horizon.next_90d "Nächste 90 Tage" / "Next 90 days"
|
||||
date_range.horizon.next_all "Ganze Zukunft" / "All future"
|
||||
date_range.horizon.past_7d "Letzte 7 Tage" / "Last 7 days"
|
||||
date_range.horizon.past_14d "Letzte 14 Tage" / "Last 14 days"
|
||||
date_range.horizon.past_30d "Letzte 30 Tage" / "Last 30 days"
|
||||
date_range.horizon.past_90d "Letzte 90 Tage" / "Last 90 days"
|
||||
date_range.horizon.past_all "Ganze Vergangenheit" / "All past"
|
||||
date_range.horizon.any "Alles" / "All"
|
||||
date_range.horizon.custom "Benutzerdefiniert" / "Custom"
|
||||
date_range.dialog.label "Zeitraum wählen" / "Choose date range"
|
||||
date_range.fan.past.label "Vergangenheit" / "Past"
|
||||
date_range.fan.future.label "Zukunft" / "Future"
|
||||
date_range.center.label "Alles" / "All"
|
||||
date_range.custom.from "Von" / "From"
|
||||
date_range.custom.to "Bis" / "To"
|
||||
date_range.custom.apply "Anwenden" / "Apply"
|
||||
date_range.custom.cancel "Abbrechen" / "Cancel"
|
||||
date_range.custom.invalid "Bis-Datum muss nach Von-Datum liegen." / "End date must be after start date."
|
||||
```
|
||||
|
||||
Total: 21 keys × 2 langs = 42 new entries in `i18n.ts`. Existing per-surface keys (`agenda.range.7`, `admin.audit.range.24h`, `views.bar.time.next_30d` etc.) stay until each surface migrates, then get retired.
|
||||
|
||||
---
|
||||
|
||||
## §4 URL / form serialization contract
|
||||
|
||||
### 4.1 Canonical URL shape
|
||||
|
||||
The picker writes (and reads) **canonical** params on the host's URL:
|
||||
|
||||
```
|
||||
?horizon=next_30d
|
||||
?horizon=past_all
|
||||
?horizon=any ← omitted if it matches the surface default
|
||||
?horizon=custom&from=2026-03-15&to=2026-04-30
|
||||
```
|
||||
|
||||
The host page's URL-init code (`bootDateRangePicker(surface, opts)`) calls `parseURL(searchParams)` to derive the initial `TimeSpec`, then calls `serializeURL(spec, defaults)` on every change. Params equal to the surface default are **omitted** so the canonical URL stays short and dedupable — matches the existing `writeParamToURL` pattern in `projects-chart.ts:144-154`.
|
||||
|
||||
### 4.2 Backwards-compat aliases
|
||||
|
||||
Each migrating surface keeps its existing alias parser for the transition window:
|
||||
|
||||
| Surface | Legacy URL | Canonical URL | Adapter |
|
||||
|---|---|---|---|
|
||||
| `/agenda` | `?range=30` | `?horizon=next_30d` | `range=N → horizon=next_${N}d` if `N ∈ {7,14,30,90}`, else `next_all` for `N>90`. Read both, write canonical. |
|
||||
| `/admin/audit-log` | `?range=7d` | `?horizon=past_7d` | `range=24h → horizon=past_1d` (new, see Q5) or kept as `past_7d` fallback. `range=all → horizon=any`. |
|
||||
| `/projects/:id/chart` | `?range=1y` | `?range=1y` (kept) | **NOT migrated to TimeHorizon** — projects-chart is symmetric-around-today. It uses DateRangePicker only for its **custom**-mode UI (the date-pair → slicer in Slice D). The 1y/2y/all presets stay surface-specific. |
|
||||
|
||||
The Go side is unaffected by aliasing — handlers receive whatever shape they always have, and the URL alias adapter lives entirely client-side per surface. **No backend route signature changes** in Slice A.
|
||||
|
||||
### 4.3 Custom Views (persisted JSON)
|
||||
|
||||
`paliad.user_views.filter_spec` is a JSON column. The TimeSpec extension is additive (new enum values, no shape change). Existing rows continue to validate. Migration not needed.
|
||||
|
||||
### 4.4 Form fields (Custom Views editor)
|
||||
|
||||
`views-editor.tsx:102-109` migrates from `<select>` to the picker. The form submits the same FormData shape (just one extra key for custom from/to — already plumbed via TimeSpec.from / TimeSpec.to). The Go-side `parseViewForm()` (TBD by coder) gains 4 new acceptable horizon values; existing test cases continue to pass.
|
||||
|
||||
---
|
||||
|
||||
## §5 Migration plan
|
||||
|
||||
### Slice A — substrate + filter-bar `time` axis
|
||||
|
||||
**Backend** (single migration not needed — additive constants only):
|
||||
|
||||
- `internal/services/filter_spec.go` — 4 new `TimeHorizon` constants + validate switch arms.
|
||||
- `internal/services/view_service.go` — `computeViewSpecBounds()` 4 new switch cases.
|
||||
- Pure unit tests for each new horizon (zero DB).
|
||||
|
||||
**Frontend**:
|
||||
|
||||
- New `frontend/src/components/DateRangePicker.tsx` + boot client + pure module.
|
||||
- New i18n keys (42 entries).
|
||||
- `frontend/src/client/filter-bar/axes.ts:renderTimeAxis()` — replace the disabled "Anpassen" stub with the picker. The chip cluster either becomes the picker's open-state (preferred) OR the chips stay flat and the picker only opens on "Anpassen" click (fallback if popover-in-bar is visually noisy). **Inventor pick (R): chips stay flat in the bar; "Anpassen" chip becomes the picker trigger. Picker emits TimeSpec back into the bar's state, same patch path.**
|
||||
|
||||
**Surfaces lit up automatically**: Verlauf (`/projects/:id`), Custom Views (`/views`, `/views/:id`), InboxFilterBar (`/inbox`).
|
||||
|
||||
**LoC estimate**: ~600 LoC (pure: 180 / boot: 180 / TSX: 100 / CSS: 80 / Go: 30 / tests: 240). Tests-first per `docs/design-paliad-test-strategy-2026-05-19.md`.
|
||||
|
||||
### Slice B — `/agenda`
|
||||
|
||||
- `agenda.tsx:51-69` — replace chip rows with `<DateRangePicker surface="agenda" presets={["next_7d","next_14d","next_30d","next_90d","next_all","custom"]} />`.
|
||||
- `client/agenda.ts:85-104` — replace `wireControls()` chip wiring with picker subscription.
|
||||
- URL alias adapter — accept `?range=N` for back-compat, emit `?horizon=…`.
|
||||
|
||||
**LoC**: ~80 LoC delta, mostly deletion.
|
||||
|
||||
### Slice C — `/admin/audit-log` + `/projects/:id/chart`
|
||||
|
||||
- `admin-audit-log.tsx:50-65` — replace `<select>` + date-pair with `<DateRangePicker surface="audit-log" presets={["past_7d","past_14d","past_30d","past_90d","past_all","custom"]} />`.
|
||||
- `projects-chart.tsx:75-83` — **wrap** the existing 1y/2y/all presets in a custom-prop variant (a sibling component `<SymmetricRangePicker>` that shares the picker's popover scaffolding but emits the surface-specific `range_preset`). Or — if the head/m prefers — fold 1y/2y/all into TimeHorizon as `sym_1y` / `sym_2y` / `sym_all`. **Inventor pick (R): sibling component**, because symmetric-around-today is conceptually different from past/future fan. See §8 Q1.
|
||||
|
||||
**LoC**: ~120 LoC for audit-log, ~80 LoC for projects-chart wrap.
|
||||
|
||||
### Slice D *(optional, separate task)* — slicer
|
||||
|
||||
- Add `<DateRangeSlicer>` for the custom-editor sub-pane. Built on `<input type="range">` × 2 with a custom anchor rail above, ported from `date-range-slider-pure.ts`.
|
||||
- Replaces inline date-pair when `horizon === "custom"` and `surface ∈ {agenda, audit-log, filter-bar}`. Projects-chart keeps inline date-pair OR also uses slicer — its choice.
|
||||
- No new dependency.
|
||||
- ~400 LoC including pure helpers + DOM scaffolding + tests.
|
||||
|
||||
### Per-slice rollout
|
||||
|
||||
| Slice | Risk | Surfaces affected | Coder profile |
|
||||
|---|---|---|---|
|
||||
| A | Low — additive only | 4 (filter-bar + 3 consumers) | Pattern-fluent Sonnet |
|
||||
| B | Low | 1 | Same coder |
|
||||
| C | Medium (projects-chart sibling) | 2 | Same coder |
|
||||
| D | Medium (new slicer) | 0 (additive on top of A) | Separate task |
|
||||
|
||||
---
|
||||
|
||||
## §6 Visual decisions
|
||||
|
||||
### 6.1 Chip labels
|
||||
|
||||
Final labels — bilingual (DE first):
|
||||
|
||||
| Chip | DE | EN |
|
||||
|---|---|---|
|
||||
| past_all | Ganze Vergangenheit | All past |
|
||||
| past_90d | Letzte 90 Tage | Last 90 days |
|
||||
| past_30d | Letzte 30 Tage | Last 30 days |
|
||||
| past_14d | Letzte 14 Tage | Last 14 days |
|
||||
| past_7d | Letzte 7 Tage | Last 7 days |
|
||||
| any (center) | Alles | All |
|
||||
| next_7d | Nächste 7 Tage | Next 7 days |
|
||||
| next_14d | Nächste 14 Tage | Next 14 days |
|
||||
| next_30d | Nächste 30 Tage | Next 30 days |
|
||||
| next_90d | Nächste 90 Tage | Next 90 days |
|
||||
| next_all | Ganze Zukunft | All future |
|
||||
| custom | Anpassen | Customize |
|
||||
|
||||
Rationale on "Anpassen" vs "Benutzerdefiniert":
|
||||
- "Anpassen" matches existing `views.bar.time.custom` key value in `i18n.ts`.
|
||||
- "Benutzerdefiniert" is used in admin-audit-log's dropdown — verbose, but more accurate.
|
||||
- (R): **Anpassen** (consistent with filter-bar; six chars vs. eighteen).
|
||||
|
||||
### 6.2 Accent / active state
|
||||
|
||||
Reuse the existing **lime accent** chip-active state (`--color-bg-lime-tint` background, `--color-accent` border, `--color-text` text). This is the established affordance for the `agenda-chip-active` class — same visual reused, no new accent token.
|
||||
|
||||
### 6.3 The "ALLES" center button
|
||||
|
||||
A larger, target-glyph button — visually distinct from the fan chips so the user reads it as the "no time filter" exit, not as one chip among many:
|
||||
|
||||
```
|
||||
╭──────╮
|
||||
│ ⌖ │
|
||||
│ ALLES│
|
||||
╰──────╯
|
||||
```
|
||||
|
||||
(R) glyph: `⌖` (Unicode U+2316 POSITION INDICATOR). Alternatives considered: `∞` (too math-y), `⊕` (too connect-y), `▣` (too checkbox-y), no glyph (chip then looks like every other chip). See §8 Q3.B.
|
||||
|
||||
### 6.4 Custom-range entry
|
||||
|
||||
In Slice A: **inline date-pair below the chip rows**, with an "Anwenden" button that commits + closes the picker. Plain `<input type="date" lang="de">` — gets the OS-native picker.
|
||||
|
||||
In Slice D (later): same slot becomes the slicer. The chip rows remain; the slicer collapses under them so the user can switch back to a chip with one click.
|
||||
|
||||
### 6.5 Hover / focus
|
||||
|
||||
- Chip hover: existing `.agenda-chip:hover` (lighter background tint).
|
||||
- Chip focus-visible: 2px outline using `--color-accent`.
|
||||
- Button focus-visible: same.
|
||||
- Popover entry: 120ms fade-in via `transform: translateY(-4px) → 0` + opacity. Reduced-motion users (prefers-reduced-motion: reduce) get instant show.
|
||||
|
||||
### 6.6 Indication that the filter is non-default
|
||||
|
||||
The closed button shows a small `●` dot to the left of the label when the value is **not** the surface default. This matches the existing filter-bar non-default-indicator pattern (`frontend/src/client/filter-bar/index.ts` has a similar dot but on the whole bar; we adopt it per-control).
|
||||
|
||||
---
|
||||
|
||||
## §7 Edge cases
|
||||
|
||||
### 7.1 Timezones
|
||||
|
||||
All horizon math runs against **UTC `startOfDay`** of `new Date()` — same convention as `horizonBounds()` in `projects-detail.ts:393-406`. The user's browser may be in CEST in summer or CET in winter; the picker still treats "today" as a UTC date for filter purposes. The date-input localizes display (German locale → DD.MM.YYYY) but the underlying ISO is `YYYY-MM-DD` parsed as UTC midnight.
|
||||
|
||||
Practical impact: a user in CEST clicking "Letzte 7 Tage" at 01:30 local on 2026-06-15 sees `from=2026-06-07T00:00Z, to=2026-06-15T00:00Z` even though their local clock shows the 15th. This matches every other date-filter in paliad and avoids "the same row vanishes at 01:00 vs. 23:00" surprises. Document the convention in the pure module's header comment.
|
||||
|
||||
### 7.2 Far past truncation
|
||||
|
||||
`past_all` materialises to `from: nil`. The Go side (view_service.go) treats nil as "no lower bound" — the SQL `WHERE due_date >= ?` clause is omitted. No truncation needed.
|
||||
|
||||
For projects-chart's symmetric "all" mode, "all" still means **bounds derived from loaded events** (status quo) — the picker for projects-chart's surface uses the sibling `<SymmetricRangePicker>` which doesn't have `past_all`/`next_all` chips, only `1y/2y/all`.
|
||||
|
||||
### 7.3 Overlapping selections — past_7 + next_7 simultaneously?
|
||||
|
||||
The picker is **single-select** — one chip active at a time, OR custom mode. m's brief doesn't mention multi-select and the existing TimeSpec is single-valued. Multi-select would require a fundamental contract change. Don't.
|
||||
|
||||
If a user genuinely wants "last 7 days OR next 7 days," they use the custom-range with `from=today-7d`, `to=today+7d` — which is what `±1w` would mean. The fact that this is two chip-clicks vs. one isn't a real ergonomic loss.
|
||||
|
||||
### 7.4 Custom dates with from > to
|
||||
|
||||
Validate client-side: when both inputs are filled and `from > to`, the "Anwenden" button is disabled and a hint appears: "Bis-Datum muss nach Von-Datum liegen" (i18n key `date_range.custom.invalid`). The picker does **not** auto-swap.
|
||||
|
||||
### 7.5 Empty inputs in custom mode
|
||||
|
||||
If the user clicks "Anpassen" then clicks elsewhere before filling inputs, the picker reverts to whatever horizon was active before (state cached on entry to custom-editor). No "half-custom" state persists.
|
||||
|
||||
### 7.6 Surface-specific preset overrides
|
||||
|
||||
Each surface declares its own presets via the `presets` prop. The picker hides chips not in the array. The default surface preset (read from `defaults` prop, or hardcoded if absent) is what `serializeURL()` omits from the URL.
|
||||
|
||||
Important invariant: `defaults` must be a member of `presets`, OR be a special value like `any` that's always rendered. The component asserts this at boot and falls back to `any` if violated.
|
||||
|
||||
### 7.7 Bilingual labels mid-session
|
||||
|
||||
`labelForHorizon()` consults the live `i18n.ts` dictionary on every render, so a language toggle updates the picker immediately — including the closed-button label.
|
||||
|
||||
### 7.8 Embedded picker inside a filter bar
|
||||
|
||||
When the picker is mounted inside `filter-bar`, it should NOT use a full popover overlay — the filter bar already wraps controls. Instead the open-state's chip rows render **inline below the time chip cluster**, expanding the bar's height. This is `mode="inline"` (a third mode beyond popover/modal). Slice A picks this for filter-bar consumers; standalone surfaces (`/agenda`, `/admin/audit-log`) use popover mode.
|
||||
|
||||
### 7.9 What happens if a saved Custom View references `past_14d` before Slice A ships?
|
||||
|
||||
The JSON validator rejects it (`filter_spec.go:validate()` enum check). Saved views are migration-safe in one direction only — adding new enum values is fine; removing is not. Slice A adds, doesn't remove. No issue.
|
||||
|
||||
### 7.10 Race: URL change while picker is open
|
||||
|
||||
If the user has the picker open and a URL change happens via another control (e.g. they Cmd-Click a sidebar link), the picker is unmounted naturally with the page navigation. No state to preserve across navigations.
|
||||
|
||||
---
|
||||
|
||||
## §8 Open questions for m
|
||||
|
||||
Per task brief: **no AskUserQuestion**. Material picks escalated via `mai instruct head`; everything else defaults to (R) below. The head decides whether to forward to m or rule on the spot.
|
||||
|
||||
### Q1 [MATERIAL — escalate]: How to handle `/projects/:id/chart`?
|
||||
|
||||
The chart's range presets are **symmetric around today** (1y / 2y / all = ±1y / ±2y / all-data-bounds), conceptually different from past/future fans. Options:
|
||||
|
||||
- **(R) A — sibling component.** Keep a separate `<SymmetricRangePicker>` for the chart surface. Same popover scaffolding, different chip set. Chart's URL stays `?range=1y`. Doesn't add to TimeHorizon.
|
||||
- **B — fold into TimeHorizon.** Add `sym_1y`, `sym_2y`, `sym_all` constants. Picker prop selects which fan vs. symmetric. Saved views could then express "±1y" too.
|
||||
- **C — leave the chart as-is.** Don't migrate. Accept the visual inconsistency.
|
||||
|
||||
(R) **A.** Symmetric vs fan is a real semantic difference; one component trying to be both is muddier than two components sharing scaffolding. The chart isn't a "filter" — it's a viewport, and viewports legitimately want symmetric panning.
|
||||
|
||||
### Q2 [MATERIAL — escalate]: Modal vs popover for the standalone case?
|
||||
|
||||
m's brief says "mini modal." Options:
|
||||
|
||||
- **(R) A — popover always.** Anchored to the trigger button, click-outside dismiss. In-context, lightweight.
|
||||
- **B — modal for explicit "open date filter" intent.** Use a centered modal with scrim when the picker is the page's primary filter (e.g. `/admin/audit-log` where date is the most prominent control). Popover for embedded uses.
|
||||
- **C — modal everywhere.** Strong visual hierarchy, but interrupts the user.
|
||||
|
||||
(R) **A.** Modal feels heavy for what is conceptually a chip cluster. The "mini" qualifier in m's wording suggests popover, not full modal. If a surface specifically needs the modal weight, the `mode="modal"` prop is available — but no default surface picks it.
|
||||
|
||||
### Q3 [MATERIAL — escalate]: Slice priority — what migrates first?
|
||||
|
||||
- **(R) A — filter-bar `time` axis first** (Slice A). Lights up 4 surfaces simultaneously (Verlauf, InboxFilterBar, views runtime, Custom Views editor) by replacing the existing Phase-2 disabled stub.
|
||||
- **B — `/agenda` first** (per task brief default). Highest-traffic standalone surface, simplest migration.
|
||||
- **C — both A and B in parallel** (head splits between two coders).
|
||||
|
||||
(R) **A.** Filter-bar is the substrate everything else either uses or should use. Lighting it up first turns three downstream surfaces from "almost working" (the stubbed custom-range chip with "coming_soon" tooltip) to "fully working." Agenda then migrates as Slice B, on top of a proven component.
|
||||
|
||||
### Q3.B [DEFAULT — no escalation needed]: ALL center button glyph?
|
||||
|
||||
- **(R) `⌖`** (POSITION INDICATOR, U+2316). Implies "center / pin to here."
|
||||
- B `∞` (infinity). Mathy.
|
||||
- C `⊕` (circled plus). Looks like a button.
|
||||
- D No glyph, just "ALLES" in bold.
|
||||
|
||||
(R) `⌖`. If the head/m doesn't like the unicode lookup, D is the safe fallback.
|
||||
|
||||
### Q4 [DEFAULT — no escalation]: Custom-range entry in Slice A?
|
||||
|
||||
- **(R)** Inline `<input type="date">` pair, OS-native picker. Slice D adds the slicer.
|
||||
|
||||
### Q5 [DEFAULT — no escalation]: Past `24h` in audit-log?
|
||||
|
||||
audit-log currently has a `24h` preset; the picker would express this as `past_1d`. Options:
|
||||
|
||||
- **(R)** Map legacy `?range=24h` → `?horizon=past_1d`. Add a new `past_1d` constant.
|
||||
- B Drop `24h` — audit log defaults to `past_7d` like other surfaces. Users wanting "last 24h" use custom mode.
|
||||
|
||||
(R) Add `past_1d`. It's a one-line addition and audit-log users genuinely use "last 24h" for incident triage.
|
||||
|
||||
(Note: this means the picker actually has 5 past chips + 5 future chips + center + custom = 12 chips total, which fits comfortably in the popover.)
|
||||
|
||||
### Q6 [DEFAULT — no escalation]: Slice D (slicer) — separate task or fold in?
|
||||
|
||||
- **(R) Separate task.** Slice A-C are independently shippable. Slice D is meaningful design + ~400 LoC and shouldn't gate the main migration.
|
||||
|
||||
### Q7 [DEFAULT — no escalation]: Per-surface defaults?
|
||||
|
||||
Each migrating surface keeps its current default exactly:
|
||||
|
||||
- `/agenda` → `next_30d` (was 30).
|
||||
- `/admin/audit-log` → `past_7d` (was 7d).
|
||||
- `/projects/:id` Verlauf → `past_30d` (was past_30d in `projects-detail.ts:2310`).
|
||||
- `/views/:id` runtime → whatever the saved view has (no change).
|
||||
- `/inbox` (InboxFilterBar) → whatever filter-bar's surface defines.
|
||||
|
||||
### Q8 [DEFAULT — no escalation]: Should `past_14d` and `next_14d` retroactively appear in `views-editor.tsx`'s `<select>`?
|
||||
|
||||
(R) **Yes** — once Slice A ships, the `<select>` in `views-editor.tsx` is replaced by the picker (part of Slice A, as filter-bar consumers all flip in one commit). All 12 preset values become available for new Custom Views.
|
||||
|
||||
---
|
||||
|
||||
## §9 Implementer notes (for the coder shift, if approved)
|
||||
|
||||
### Lessons embedded
|
||||
|
||||
- **TimeSpec extension is additive only** — Go enum + TS union + i18n keys + horizonBounds switch. No DB migration, no contract break.
|
||||
- **Pure module is testable under `bun test`** — no DOM needed for horizon math, label resolution, URL serialization. Aim for 95%+ coverage of the pure module before touching the boot client.
|
||||
- **Reuse `.agenda-chip` styling** — adds no new tokens, no new dark-mode contrast risk (cf. memory t-paliad-150 / fritz — fritz lost 90 minutes to a `var(--token, #hex)` fallback bug because the token wasn't defined in dark mode).
|
||||
- **`mode="inline"` for filter-bar consumers** — the bar already wraps its own popover-like layout; nesting popovers gets visually noisy.
|
||||
- **Surface defaults must be members of `presets`** — assert at boot, fail loud in dev, fall back to `any` in prod.
|
||||
|
||||
### Recommended coder profile
|
||||
|
||||
Pattern-fluent Sonnet. Substrate is well-trodden (TimeSpec/TimeHorizon already lives, chip-cluster CSS exists, URL-codec pattern documented in `projects-chart.ts`). The novel piece is the popover scaffolding — paliad doesn't have a generic Popover primitive today; the picker builds its own DOM-anchored overlay. ~80 LoC of plain JS, no dependency.
|
||||
|
||||
### Build hygiene checklist
|
||||
|
||||
- `go build ./...` clean
|
||||
- `go vet ./...` clean
|
||||
- `go test ./...` clean (existing tests must continue passing — additive constants change zero behaviour)
|
||||
- `bun run build` clean (i18n scan: 21 new keys added, all `data-i18n` attributes present)
|
||||
- bun:test covers the pure module (horizon math, label resolver, URL parser/serializer)
|
||||
- Playwright smoke (manual, not gated): on `/inbox` the time axis "Anpassen" chip is now functional; custom-from/to date pair commits a usable filter.
|
||||
|
||||
### Out of scope for the coder
|
||||
|
||||
- Slicer (Slice D) — separate task.
|
||||
- Per-language adjustments beyond DE/EN (per task brief, out of scope).
|
||||
- Time-of-day picking — separate concern.
|
||||
- Recurring-event windows — events feed handles separately.
|
||||
- A generic Popover primitive — extract only if a second consumer appears in the same slice.
|
||||
|
||||
### Acceptance criteria for Slice A
|
||||
|
||||
1. New `<DateRangePicker>` mounts on filter-bar's `time` axis, replacing the disabled "Anpassen" chip.
|
||||
2. The 4 new horizon values (`past_14d`, `next_14d`, `past_all`, `next_all`) are accepted by Go's `TimeSpec.validate()` and produce correct `(from, to)` bounds in `computeViewSpecBounds()`.
|
||||
3. The 4 new horizons round-trip through saved Custom Views (`paliad.user_views.filter_spec` JSON).
|
||||
4. URL serialization is canonical (`?horizon=…&from=…&to=…`) and surface-default values are omitted.
|
||||
5. Verlauf (`/projects/:id`), `/views`, `/views/:id`, and `/inbox` continue to function with their existing presets unchanged — they pick up the new picker but don't switch their preset list yet.
|
||||
6. Pure-module unit tests cover: 12 horizons × bound calculation; URL parse / serialize round-trip; default-omission rule; custom-mode date validation.
|
||||
7. `bun run build` reports the new i18n keys (no missing-key warnings).
|
||||
8. No regression in `go test ./internal/services/...` (existing TimeSpec tests stay green).
|
||||
|
||||
---
|
||||
|
||||
## §10 Material picks summary — escalation message
|
||||
|
||||
To be sent via `mai instruct head` after this doc is pushed:
|
||||
|
||||
> Three material picks for m on date-range-picker design:
|
||||
>
|
||||
> 1. **`/projects/:id/chart` migration** — keep symmetric (1y/2y/all) presets as a sibling component, NOT fold into TimeHorizon. Chart is a viewport, not a filter.
|
||||
> 2. **Popover vs modal** — popover by default. Modal is a `mode` prop available per surface but no surface picks it in Slice A.
|
||||
> 3. **Slice A first migrates filter-bar time axis** (lights up Verlauf + InboxFilterBar + Views + Custom-Views-editor simultaneously by un-stubbing the existing "Anpassen" chip), not `/agenda` as the task brief defaulted. `/agenda` is Slice B.
|
||||
>
|
||||
> Everything else (chip labels, accent, glyph, custom-mode entry, surface defaults, past_1d for audit, slicer-as-Slice-D, 42 i18n keys) defaults per (R) in §8. Doc at `docs/design-date-range-picker-2026-05-25.md`.
|
||||
|
||||
---
|
||||
|
||||
*Verified premises (live, before designing):*
|
||||
|
||||
- `internal/services/filter_spec.go:107-126` — TimeHorizon enum at 9 values today.
|
||||
- `internal/services/view_service.go:156-187` — `computeViewSpecBounds()` switches on the same enum.
|
||||
- `frontend/src/client/views/types.ts:21-33` — TimeHorizon TS mirror; same 9 values.
|
||||
- `frontend/src/client/filter-bar/axes.ts:65-115` — chip cluster renderer; "Anpassen" stub at line 105-112 marked Phase 2, disabled, "coming_soon" tooltip.
|
||||
- `frontend/src/agenda.tsx:64-67` — chip row exact values `7|14|30|90`.
|
||||
- `frontend/src/admin-audit-log.tsx:50-65` — select exact values `24h|7d|30d|custom|all`.
|
||||
- `frontend/src/projects-chart.tsx:78-82` + `frontend/src/client/projects-chart.ts:73-118` — RangePreset `1y|2y|all|custom`, symmetric around today.
|
||||
- `frontend/src/views-editor.tsx:102-109` — select exact values `next_7d|next_30d|next_90d|past_30d|past_90d|any`.
|
||||
- `/home/m/dev/web/upc-kommentar/src/lib/components/DateRangeSlider.svelte` — 448 lines, wraps `svelte-range-slider-pips@4`, custom anchor rail above the lib's hidden pips, click-to-snap left/right halves, granularity year/month/day zoom.
|
||||
- `/home/m/dev/web/upc-kommentar/src/lib/modules/date-range-slider/date-range-slider-pure.ts` — 487 lines, fully testable pure helpers, dependency-free, portable to paliad's TS.
|
||||
|
||||
*Not verified live:* upckommentar.de in a browser (requires author auth; the source code IS the source of truth and was read end-to-end).
|
||||
492
docs/design-event-card-choices-2026-05-25.md
Normal file
492
docs/design-event-card-choices-2026-05-25.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# Design — Per-event-card optional choices on the Verfahrensablauf timeline
|
||||
|
||||
**Author:** atlas (inventor)
|
||||
**Date:** 2026-05-25
|
||||
**Task:** t-paliad-265 (m/paliad#96)
|
||||
**Branch:** `mai/atlas/inventor-per-event-card`
|
||||
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
> **m's decisions landed 2026-05-25** — see §11. Persisted table, caret+popover, per-card-overrides-page-level, and m chose to bundle Slice A + Slice B into one coder shift (over the inventor (R) of "Slice A first"). All other picks matched inventor recommendations.
|
||||
|
||||
The Verfahrensablauf timeline today carries **two** projection knobs at the page level — `side` (who-we-are) and `appellant` (who-initiated). Both are **global** for the whole timeline. m wants three more knobs, but **per event card**, not page-level:
|
||||
|
||||
1. **Appellant per decision card** — if a decision is appealable, the user picks which side appealed (Claimant / Defendant / Both / None). Different decisions in the same timeline can have different appellants.
|
||||
2. **Include Nichtigkeitswiderklage on Klageerwiderung** — toggling this on a single Klageerwiderung card flips on the existing `with_ccr` flag for everything downstream of that card.
|
||||
3. **Skip an optional event** — for any rule marked `priority='optional'`, a per-card "don't consider for this case" toggle hides downstream consequences.
|
||||
|
||||
The flow these choices drive is **already there** — `condition_expr` jsonb gates (`with_ccr`, `with_amend`, `with_cci`) plus the page-level appellant selector. What's missing is (a) **per-card** scope and (b) **per-project persistence**.
|
||||
|
||||
Recommendation: persist choices in a new `paliad.project_event_choices` table; expose them through a popover-on-caret affordance on the relevant cards only; map them into the existing `CalcOptions.Flags` + a new per-rule `Appellants` map at projection time. Two slices: **Slice A** (appellant-per-decision + skip-optional, narrow + bounded), **Slice B** (include-CCR-on-Klageerwiderung, requires per-card flag-scoping in the projection engine — bigger).
|
||||
|
||||
---
|
||||
|
||||
## 1. Premises verified live (before designing)
|
||||
|
||||
CLAUDE.md / memory / issue text can drift; the live system can't. Each load-bearing premise below was probed against the live DB or live source on 2026-05-25.
|
||||
|
||||
### Schema
|
||||
|
||||
- **Migration tracker at 127** (`paliad.paliad_schema_migrations`). Next migration: 128. No new table for `project_event_choices` exists today.
|
||||
- **`paliad.deadline_rules` carries `condition_expr jsonb`** already. The flag-evaluation engine (`internal/services/fristenrechner.go:208 Calculate`, `evalConditionExpr` at line ~947) walks the jsonb tree and skips rules whose gate is unsatisfied. Today's gates are `{"flag":"with_ccr"}`, `{"flag":"with_amend"}`, `{"flag":"with_cci"}`, and `{"op":"and","args":[…]}` combinations.
|
||||
- **`with_ccr` is the existing Nichtigkeitswiderklage gate.** Verified live: 7 upc.inf.cfi rules gate on it (`upc.inf.cfi.reply`, `…rejoin`, `…ccr`, `…def_to_ccr`, `…reply_def_ccr`, `…rejoin_reply_ccr`, plus `upc.inf.cfi.app_to_amend` which additionally requires `with_amend`).
|
||||
- **`priority` column** has 4 values: `mandatory`, `recommended`, `optional`, `informational`. Live counts (deadline_rules table-wide): 230 mandatory / 18 recommended / 6 optional / (informational not in count, must be 0 or absent). The "skip optional" affordance keys off `priority='optional'`.
|
||||
- **`event_type` discriminator** exists with values `filing`, `decision`, `hearing`. The "appellant-per-decision" affordance keys off `event_type='decision'`. Live: every decision rule has `primary_party='court'`.
|
||||
- **`paliad.projects.our_side`** exists (column added before mig 112; values today include `claimant|defendant|applicant|appellant|respondent|third_party|other`). It is the broad project-level side axis t-paliad-257 / #88 hooked into.
|
||||
- **NO `appellant` column on `paliad.projects`** — the appellant axis lives only in the URL query (`?appellant=claimant|defendant`) in `client/verfahrensablauf.ts:73-89`.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `frontend/src/client/views/verfahrensablauf-core.ts` is the **shared rendering core** for both `/tools/verfahrensablauf` and `/tools/fristenrechner`. Per-card UI affordances added here surface on both pages automatically.
|
||||
- `bucketDeadlinesIntoColumns(deadlines, {side, appellant})` (line 496) is the **pure routing primitive**; column placement is computed without DOM. Unit-tested in `verfahrensablauf-core.test.ts`.
|
||||
- `deadlineCardHtml(dl, {showParty, editable, showNotes})` (line 254) is the **per-card renderer**. There is no per-card props channel for "choices" yet — that's the surface this design extends.
|
||||
- `client/verfahrensablauf.ts` and `client/fristenrechner.ts` both manage `currentSide` + `currentAppellant` in-memory and round-trip them through the URL (`writeSideToURL` / `writeAppellantToURL`). The pattern is mature; this design mirrors it for the new state when state stays URL-bound, and lifts it into a server-persisted store when state stays per-project.
|
||||
- `APPELLANT_AXIS_PROCEEDINGS` set (verfahrensablauf.ts:52-62) gates the page-level appellant selector to appeal-flavoured proceedings only. The per-card appellant affordance MUST NOT depend on this set — any first-instance decision is a potential appeal trigger (e.g. LG-Urteil → Berufung, BPatG-Entscheidung → BGH-Rechtsbeschwerde).
|
||||
|
||||
### Surfaces in scope
|
||||
|
||||
- **`/tools/verfahrensablauf`** — abstract browse, no project context. Per-card choices here are ephemeral (URL-bound) — there's no project to persist into.
|
||||
- **`/tools/fristenrechner`** — concrete projection, optionally project-bound via `?project=<id>` (`currentStep1Context.kind === "project"`). When project-bound, per-card choices persist to `paliad.project_event_choices`. When unbound, URL only.
|
||||
- **`/projects/{id}` Verlauf tab (SmartTimeline)** — separate widget (per `docs/design-smart-timeline-2026-05-08.md`); does **NOT** use `renderColumnsBody`. Per-card choices are NOT in scope for the SmartTimeline in v1 — the Verfahrensablauf core is.
|
||||
|
||||
### What is NOT premised
|
||||
|
||||
- The deadline_rules → procedural_events rename (#93) is **not assumed shipped**. This design uses `deadline_rules`/`rule_code` vocabulary throughout and flags the rename touch-points in §6.
|
||||
- The per-card UI does NOT require new server-side priority/event_type semantics. Both `priority='optional'` and `event_type='decision'` exist on every row.
|
||||
|
||||
---
|
||||
|
||||
## 2. Vision + scope
|
||||
|
||||
m's vision (verbatim 2026-05-25 15:12):
|
||||
|
||||
> We still have no choice to say that a specific party appealed. We may need selections within the event cards on the timeline to change it? For example for a decision we could check Appeal by... or in Klageerwiderung we can chose to include a Nichtigkeitswiderklage. Or with any optional event we can select not to consider it (because someone decided not to file it).
|
||||
|
||||
### What changes
|
||||
|
||||
- A **caret affordance** (▾) appears on the right edge of cards that have at least one applicable choice-kind. Click → small popover with the choices. Cards without an applicable choice render unchanged.
|
||||
- A **`choices_offered` jsonb column** on `paliad.deadline_rules` declares which choice-kinds each rule offers. Three kinds in v1:
|
||||
- `appellant` — applicable to rules with `event_type='decision'` (no static list; engine decides).
|
||||
- `include_ccr` — applicable to the single Klageerwiderung rule per proceeding (today: `upc.inf.cfi.def`, `de.inf.lg.erwidg`).
|
||||
- `skip` — applicable to any rule with `priority='optional'`.
|
||||
- A **new persistence table** `paliad.project_event_choices(project_id, rule_code, choice_kind, choice_value)` holds the user's choices. Per-project, audit-logged via `paliad.system_audit_log`.
|
||||
- A **projection-time merge** turns the persisted choices into `CalcOptions.Flags` and a new `PerCardAppellants map[ruleCode]string` field, then re-runs the existing projection engine. No new flag types; `with_ccr` is the same `with_ccr`.
|
||||
|
||||
### What stays
|
||||
|
||||
- `bucketDeadlinesIntoColumns` and `renderColumnsBody` are extended (new opts), not replaced.
|
||||
- `condition_expr` jsonb gating semantics are unchanged. Per-card `include_ccr` choice simply means "set `with_ccr` in the flag set for this projection" — same engine.
|
||||
- Page-level `side` / `appellant` selectors stay. The per-card appellant choice is an **override layer** on top of the page-level appellant (Q4 below).
|
||||
- URL-state plumbing (`?side=…`, `?appellant=…`) stays. The page-level URL params remain the only state for unbound `/tools/verfahrensablauf`.
|
||||
|
||||
### Out of scope (v1)
|
||||
|
||||
- Per-card choices on the SmartTimeline (project Verlauf tab). Deferred to a follow-up when SmartTimeline matures.
|
||||
- Versioning of choices over time ("the appellant changed mid-case", "the CCR was withdrawn"). Choices are last-write-wins.
|
||||
- Cross-project propagation of choices.
|
||||
- Implementing the choice flow (coder task per slice; this is design-only).
|
||||
- A "what-if scenarios" mode (saved named scenarios).
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
|
||||
### 3.1 The new table
|
||||
|
||||
```sql
|
||||
-- migration 128_project_event_choices.up.sql
|
||||
CREATE TABLE paliad.project_event_choices (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
rule_code text NOT NULL, -- e.g. "RoP.029.a" or "de.inf.lg.urteil"
|
||||
choice_kind text NOT NULL, -- 'appellant' | 'include_ccr' | 'skip'
|
||||
choice_value text NOT NULL, -- value namespace per kind (see §3.3)
|
||||
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- One choice per (project, rule_code, kind). Re-pick is an UPDATE.
|
||||
UNIQUE (project_id, rule_code, choice_kind)
|
||||
);
|
||||
|
||||
CREATE INDEX project_event_choices_project_idx
|
||||
ON paliad.project_event_choices (project_id);
|
||||
|
||||
-- RLS: same `paliad.can_see_project(project_id)` predicate as paliad.deadlines.
|
||||
ALTER TABLE paliad.project_event_choices ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY project_event_choices_select ON paliad.project_event_choices
|
||||
FOR SELECT USING (paliad.can_see_project(project_id));
|
||||
CREATE POLICY project_event_choices_mutate ON paliad.project_event_choices
|
||||
FOR ALL USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
```
|
||||
|
||||
**Why this shape:**
|
||||
- Tall not wide — adding a 4th choice-kind in slice C means one more allowed `choice_kind` value, no DDL.
|
||||
- `rule_code` is the join key against `paliad.deadline_rules` (which already uses `rule_code` widely — `Calculate`, `AnchorOverrides`, the projection). Stable across rule renames provided the rename keeps the same `rule_code`.
|
||||
- UNIQUE per `(project, rule_code, kind)` makes the choice idempotent — re-picking the appellant overwrites, doesn't accumulate.
|
||||
- ON DELETE CASCADE follows the project — when a project is hard-deleted (rare; usually soft-status), the choices go with it.
|
||||
|
||||
### 3.2 The opt-in column on `paliad.deadline_rules`
|
||||
|
||||
```sql
|
||||
-- migration 128_project_event_choices.up.sql (same migration)
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN choices_offered jsonb;
|
||||
|
||||
-- Example seeded values (in the same migration's data-fix block):
|
||||
--
|
||||
-- upc.inf.cfi.def → '{"include_ccr": [true, false]}'
|
||||
-- de.inf.lg.erwidg → '{"include_ccr": [true, false]}'
|
||||
-- upc.inf.cfi.decision → '{"appellant": ["claimant", "defendant", "both", "none"]}'
|
||||
-- de.inf.lg.urteil → '{"appellant": ["claimant", "defendant", "both", "none"]}'
|
||||
-- (every event_type='decision' rule)
|
||||
-- upc.inf.cfi.ccr (priority='optional') → '{"skip": [true, false]}'
|
||||
-- (every priority='optional' rule)
|
||||
```
|
||||
|
||||
**Alternative considered + rejected:** infer offering at projection-time from `(event_type, priority, submission_code)` heuristics. Rejected because:
|
||||
- The Klageerwiderung rule is identified only by its `submission_code` slug. Tying the engine to a hardcoded slug list inside the projection service is brittle (mig 124 + future Wave-1 fixes rename slugs); declaring `choices_offered` in data lets the audit ship them without a code change.
|
||||
- A `skip` toggle that's automatically derived from `priority='optional'` is consistent today but may diverge tomorrow (an optional rule we DON'T want skippable, or a non-optional rule we DO want skippable). The opt-in jsonb keeps the choice axis decoupled from `priority`.
|
||||
|
||||
### 3.3 Value namespaces per kind
|
||||
|
||||
| `choice_kind` | `choice_value` valid set | Default when no row exists |
|
||||
|---|---|---|
|
||||
| `appellant` | `"claimant"` / `"defendant"` / `"both"` / `"none"` | inherits page-level appellant (URL `?appellant=`), else `null` (treated as "not yet picked" — render appeal-deadlines greyed) |
|
||||
| `include_ccr` | `"true"` / `"false"` | `"false"` (no CCR until user opts in — matches current default flag set) |
|
||||
| `skip` | `"true"` / `"false"` | `"false"` (rule renders normally) |
|
||||
|
||||
Values are stored as `text` not `boolean` so the same column scales to multi-valued kinds (appellant has 4 values; future kinds may have N). Coercion lives in the service layer.
|
||||
|
||||
### 3.4 Audit trail
|
||||
|
||||
Every INSERT / UPDATE / DELETE on `project_event_choices` writes a row to `paliad.system_audit_log` (the standard sink mig 102 introduced) with `event_type='project_event_choice.set'` and the changed `(rule_code, kind, value)` in `metadata jsonb`. Pattern mirrors `paliad.deadlines.status_changed` audit rows.
|
||||
|
||||
---
|
||||
|
||||
## 4. Projection flow
|
||||
|
||||
The existing projection engine is a single Go function: `FristenrechnerService.Calculate(ctx, proceedingCode, triggerDateStr, opts CalcOptions)`. Two changes:
|
||||
|
||||
### 4.1 Extending `CalcOptions`
|
||||
|
||||
```go
|
||||
type CalcOptions struct {
|
||||
// ...existing fields...
|
||||
Flags []string // <-- already exists
|
||||
AnchorOverrides map[string]string // <-- already exists
|
||||
|
||||
// NEW — per-card overrides surfaced by the per-event-card choices.
|
||||
// Keyed by deadline_rules.rule_code.
|
||||
//
|
||||
// PerCardAppellant: when a decision rule's rule_code is in this map,
|
||||
// the appellant for downstream rules whose parent is THAT decision
|
||||
// is set to the value here. Overrides any global Appellant.
|
||||
//
|
||||
// SkipRules: when a rule's rule_code is in this set, the rule is
|
||||
// suppressed AND its descendants are suppressed. Same suppression
|
||||
// path as a failed condition_expr gate.
|
||||
//
|
||||
// IncludeCCRFor: when a rule's rule_code is in this set, the with_ccr
|
||||
// flag is treated as set in the flag context FROM that rule
|
||||
// onward (i.e. for that rule's descendants). On v1 with a single
|
||||
// Klageerwiderung-per-proceeding, this is equivalent to a project-
|
||||
// wide with_ccr — but the per-card scope leaves room for future
|
||||
// proceedings with multiple CCR entry points.
|
||||
PerCardAppellant map[string]string // rule_code → "claimant"|"defendant"|"both"|"none"
|
||||
SkipRules map[string]struct{} // set of rule_code
|
||||
IncludeCCRFor map[string]struct{} // set of rule_code
|
||||
}
|
||||
```
|
||||
|
||||
The handler reads `project_event_choices` for the project (if project-bound) and folds them into these fields before calling `Calculate`. When called unbound (URL-only, `/tools/verfahrensablauf` without project), the maps come from URL params instead (see §5.2).
|
||||
|
||||
### 4.2 Three engine changes
|
||||
|
||||
1. **SkipRules suppression**: in the post-condition_expr filter pass (`Calculate` around line 333 where the gate is evaluated), additionally drop any rule whose `rule_code ∈ opts.SkipRules`. Also drop its descendants (existing `parent_id` walk already handles cascading; just add the new predicate to the keep/drop decision).
|
||||
|
||||
2. **IncludeCCRFor scope**: rather than threading a per-rule flag context (expensive change to engine), implement v1 as: **if any rule_code in IncludeCCRFor exists at all, append `"with_ccr"` to `opts.Flags`** before the gate-evaluation pass. This is correct for the v1 surface (Klageerwiderung is the only CCR-entry-point per proceeding) but loses the per-card scoping for multi-CCR cases. The full per-rule scope is **Slice B** (§7).
|
||||
|
||||
3. **PerCardAppellant routing**: when `bucketDeadlinesIntoColumns` collapses `party=both` rows in the appellant's column, today it consults the global `opts.appellant`. Extend to consult `PerCardAppellant[ruleCode]` first — if present, that drives the collapse for descendants of that decision. Out-of-band: this changes the projection contract subtly. We surface this as **server-computed metadata** on the response (`CalculatedDeadline.AppellantContext`) so the frontend bucketer doesn't need to know about parent-chain walks — the server already does the walk.
|
||||
|
||||
### 4.3 Wire shape
|
||||
|
||||
The `CalculatedDeadline` Go struct + TS mirror grow one optional field:
|
||||
|
||||
```go
|
||||
type CalculatedDeadline struct {
|
||||
// ...existing fields...
|
||||
AppellantContext string `json:"appellantContext,omitempty"`
|
||||
// "claimant" | "defendant" | "both" | "none" | "" (default).
|
||||
// Filled by the projection from the user's per-decision choice.
|
||||
// Frontend bucketer prefers this over the page-level appellant.
|
||||
}
|
||||
```
|
||||
|
||||
This keeps the bucketer logic local — no second pass needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI / i18n
|
||||
|
||||
### 5.1 Caret + popover affordance
|
||||
|
||||
Each rendered card gets, when `choices_offered IS NOT NULL`, a `▾` caret on the right edge of the title line. Click → popover anchored to the caret. Popover renders one block per choice-kind the rule offers (typically one, occasionally two if a rule has both `appellant` and `skip` — none today; design holds for the future).
|
||||
|
||||
DOM-wise: `frontend/src/client/views/verfahrensablauf-core.ts` `deadlineCardHtml` grows a `choicesCaret` segment, and a sibling module `client/views/event-card-choices.ts` (new) owns the popover open/close + commit handler. The popover commits via `POST /api/projects/{id}/event-choices` with body `{rule_code, kind, value}`; the response is the updated choice row.
|
||||
|
||||
**Why a popover and not inline checkboxes:**
|
||||
- Inline would put a checkbox on every decision card + every optional card. ~6 decision cards + ~6 optional cards on a typical UPC.INF.CFI projection is ~12 always-on widgets per timeline. Visual noise + scan cost.
|
||||
- Popover defaults to hidden; the caret is a low-noise affordance. The selected choice surfaces as a small chip on the card title line ("Berufung: Beklagter") so the choice is glanceable without re-opening.
|
||||
- Mobile + touch: the caret is a 24×24 tap target; the popover is keyboard-dismissable.
|
||||
|
||||
**Why not card-hover-reveal:** discoverability + touch failure (no hover on iOS).
|
||||
|
||||
### 5.2 URL fallback (no project context)
|
||||
|
||||
When `/tools/verfahrensablauf` is opened without a project (the abstract-browse case), per-card choices have no persistence layer. The popover still works, but commits update an **in-memory + URL** state instead:
|
||||
|
||||
```
|
||||
?event_choices=RoP.029.a:appellant=defendant,upc.inf.cfi.ccr:skip=true
|
||||
```
|
||||
|
||||
Compact CSV in one URL param. Read at page load, applied to `CalcOptions` via the same `PerCardAppellant` / `SkipRules` / `IncludeCCRFor` route. Shareable, ephemeral. Matches the existing `?side=` + `?appellant=` URL idiom.
|
||||
|
||||
### 5.3 Chip indicators
|
||||
|
||||
A card with a non-default choice gets a small chip next to the title:
|
||||
- Appellant chosen: `Berufung: Beklagter` / `Appeal: Defendant`
|
||||
- Include CCR: `mit Nichtigkeitswiderklage` / `with CCR`
|
||||
- Skipped: card itself fades to 50% opacity, body adds class `timeline-item--skipped`, chip reads `übersprungen` / `skipped` with an undo arrow.
|
||||
|
||||
### 5.4 i18n keys (new)
|
||||
|
||||
```
|
||||
choices.caret.title "Optionen für dieses Ereignis" "Options for this event"
|
||||
choices.appellant.title "Berufung durch ..." "Appealed by ..."
|
||||
choices.appellant.claimant "Klägerseite" "Claimant side"
|
||||
choices.appellant.defendant "Beklagtenseite" "Defendant side"
|
||||
choices.appellant.both "beide Parteien" "both parties"
|
||||
choices.appellant.none "keine Berufung" "no appeal"
|
||||
choices.include_ccr.title "Nichtigkeitswiderklage einbeziehen" "Include nullity counterclaim"
|
||||
choices.skip.title "Für diese Akte überspringen" "Skip for this case"
|
||||
choices.skipped.chip "übersprungen" "skipped"
|
||||
choices.reset "Auswahl zurücksetzen" "Reset choice"
|
||||
```
|
||||
|
||||
### 5.5 What's removed
|
||||
|
||||
The page-level appellant selector (URL `?appellant=`) stays for **non-decision proceedings** (the Appeal-CoA case where the appellant axis is the whole-timeline framing, not a per-decision choice). But for first-instance proceedings (UPC.INF, DE.INF.LG, etc.), the appellant axis migrates from page-level to per-decision card. The page-level selector hides when the proceeding has decision rules with `choices_offered.appellant` declared — which is the cleaner UX (one knob, in the right place).
|
||||
|
||||
---
|
||||
|
||||
## 6. Services + handlers (new surface)
|
||||
|
||||
### 6.1 Go service
|
||||
|
||||
```go
|
||||
// internal/services/event_choice_service.go (new)
|
||||
type EventChoiceService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func (s *EventChoiceService) ListForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectEventChoice, error)
|
||||
func (s *EventChoiceService) Upsert(ctx context.Context, c ProjectEventChoice) error
|
||||
func (s *EventChoiceService) Delete(ctx context.Context, projectID uuid.UUID, ruleCode, kind string) error
|
||||
|
||||
// Used by ProjectionService to fold choices into CalcOptions.
|
||||
func (s *EventChoiceService) ToCalcOptions(choices []ProjectEventChoice) CalcOptionsAddendum
|
||||
```
|
||||
|
||||
The `CalcOptionsAddendum` type wraps the three new map/set fields so the merge into the parent `CalcOptions` is one call from the projection handler.
|
||||
|
||||
### 6.2 HTTP routes
|
||||
|
||||
```
|
||||
GET /api/projects/{id}/event-choices → []ProjectEventChoice
|
||||
PUT /api/projects/{id}/event-choices → upsert one (body: {rule_code, kind, value})
|
||||
DELETE /api/projects/{id}/event-choices/{rule_code}/{kind} → remove
|
||||
```
|
||||
|
||||
All gated by `gateOnboarded` + visibilityPredicate (project-team membership).
|
||||
|
||||
### 6.3 Projection handler
|
||||
|
||||
The existing `POST /api/tools/fristenrechner` handler accepts `flags`, `anchorOverrides`, `priorityDate`, `courtId`. Extend the request shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"proceedingType": "upc.inf.cfi",
|
||||
"triggerDate": "2026-01-15",
|
||||
"flags": ["with_ccr"],
|
||||
"perCardChoices": [
|
||||
{"rule_code": "RoP.029.a", "kind": "appellant", "value": "defendant"},
|
||||
{"rule_code": "upc.inf.cfi.ccr", "kind": "skip", "value": "true"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Or, when project-bound:
|
||||
|
||||
```json
|
||||
{
|
||||
"proceedingType": "upc.inf.cfi",
|
||||
"triggerDate": "2026-01-15",
|
||||
"projectId": "abc-123"
|
||||
// server pulls perCardChoices from paliad.project_event_choices
|
||||
}
|
||||
```
|
||||
|
||||
The handler merges either source into `CalcOptions` and runs `Calculate`.
|
||||
|
||||
### 6.4 Touch points — files coder will edit
|
||||
|
||||
- **DB**: new migration `128_project_event_choices.up.sql` + `.down.sql`. Add `choices_offered` column + seed data.
|
||||
- **Go**: `internal/services/event_choice_service.go` (new), `internal/services/fristenrechner.go` (extend `CalcOptions`, projection logic), `internal/handlers/event_choices.go` (new HTTP routes), `internal/handlers/fristenrechner.go` (request shape extension).
|
||||
- **Models**: `internal/models/models.go` — `ProjectEventChoice` struct, `CalculatedDeadline.AppellantContext` field.
|
||||
- **Frontend**: `frontend/src/client/views/verfahrensablauf-core.ts` (caret + chip in deadlineCardHtml), `frontend/src/client/views/event-card-choices.ts` (new popover module), `frontend/src/client/verfahrensablauf.ts` + `frontend/src/client/fristenrechner.ts` (URL-state plumbing for the unbound case; load project choices for the bound case).
|
||||
- **i18n**: `frontend/src/client/i18n.ts` + `frontend/src/i18n-keys.ts` — new keys per §5.4.
|
||||
- **Tests**: `internal/services/event_choice_service_test.go` (new), `internal/services/fristenrechner_test.go` (extend with PerCardAppellant + SkipRules cases), `frontend/src/client/views/verfahrensablauf-core.test.ts` (extend bucketing with `perCardAppellant` opt).
|
||||
|
||||
### 6.5 Coordination with #93 procedural-events rename
|
||||
|
||||
When #93 lands (and the rename ships), this design's `rule_code` references become `procedural_event.code` — same string namespace, cleaner name. Join points:
|
||||
- `project_event_choices.rule_code` → `project_event_choices.procedural_event_code` (or stays as a generic string column if #93 keeps `rule_code` as the join key).
|
||||
- `deadline_rules.choices_offered` → `procedural_events.choices_offered`.
|
||||
|
||||
If #93 ships first, this design's migration applies to `procedural_events` instead. The data shape (jsonb + new join table) is unaffected. If THIS ships first, #93 absorbs the column in its rename.
|
||||
|
||||
---
|
||||
|
||||
## 7. Slice plan
|
||||
|
||||
### Slice A — Appellant per decision + Skip optional event
|
||||
|
||||
Two choice-kinds, narrow + bounded, do not change the gate-evaluation engine.
|
||||
|
||||
- **DB**: migration 128 adds `project_event_choices` + `choices_offered`. Seed `choices_offered` on all `event_type='decision'` rules and all `priority='optional'` rules.
|
||||
- **Service**: `EventChoiceService` CRUD; `CalcOptions.PerCardAppellant` + `CalcOptions.SkipRules`; `Calculate` extension to honour SkipRules suppression + AppellantContext metadata.
|
||||
- **HTTP**: 3 new routes (GET / PUT / DELETE on project_event_choices); fristenrechner request extension.
|
||||
- **Frontend**: caret + popover on decision cards + optional cards; chip indicators; URL-state for the unbound case; load-on-mount for the bound case.
|
||||
- **Tests**: bucketing with PerCardAppellant; service CRUD; gate-suppression with SkipRules.
|
||||
|
||||
Ship this slice first. It validates the popover affordance + the persistence layer end-to-end without touching the flag-evaluation engine.
|
||||
|
||||
### Slice B — Include Nichtigkeitswiderklage on Klageerwiderung
|
||||
|
||||
Wires `IncludeCCRFor` through the flag-evaluation engine. v1 simplification (§4.2 #2) makes this **almost** a no-op for the engine — but the per-card scope semantics need a separate inventor pass to nail down whether the simplification holds for de.inf.lg's CCR analogue (Widerklage auf Nichtigkeit) and for any future proceedings with multiple CCR entry points.
|
||||
|
||||
- **DB**: add `include_ccr` to allowed `choice_kind` values + seed `choices_offered = '{"include_ccr": [true, false]}'` on the Klageerwiderung rows (`upc.inf.cfi.def`, `de.inf.lg.erwidg`).
|
||||
- **Service**: `CalcOptions.IncludeCCRFor`; the "if non-empty, append with_ccr to Flags" simplification.
|
||||
- **Frontend**: the include_ccr popover block (already designed; just enabling the row).
|
||||
- **Cross-flow audit**: confirm that the existing 7 upc.inf.cfi cross-flow rules + de.inf.lg analogues fire correctly when with_ccr is set via the per-card path vs. the existing page-level flag checkbox. Existing checkbox stays in v1; deprecation is a Slice C decision.
|
||||
|
||||
### Bundling note (per m's Q4 decision 2026-05-25)
|
||||
|
||||
A + B ship together. The slice headings above remain as a logical breakdown for the coder to follow when sequencing commits inside the single shift; they are not separate PRs. See §11 Q4 for rationale.
|
||||
|
||||
### Slice C — Future choice-kinds
|
||||
|
||||
Open-ended; not designed here. Examples surfaced by the t-paliad-067 audit:
|
||||
- "Bilateral hearing requested" toggle on hearing rules.
|
||||
- "Cost orders requested" toggle on cost-related rules.
|
||||
- "Stay applied" toggle on procedural events.
|
||||
|
||||
Each new kind = one new allowed `choice_kind` value + one seed row + one popover block. Schema-stable.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risk assessment
|
||||
|
||||
- **Migration risk**: new table + new column, both additive. Down-migration drops table + column + reverts seed. No data loss path. Low risk.
|
||||
- **Projection correctness**: PerCardAppellant changes the bucket routing for "both" rows in chains downstream of a decision card. The unit-tested `bucketDeadlinesIntoColumns` carries the existing appellant semantics; extending it without breaking the existing test suite means new tests, not changes to existing ones. Coder MUST add the new tests before changing the bucketer.
|
||||
- **Flag-context vs per-rule-flag aliasing**: §4.2 #2 (Slice B) trades per-card precision for engine simplicity. Acceptable in v1 (Klageerwiderung is the only entry point per proceeding) but a known limitation. Document it in `internal/services/fristenrechner.go` doc comment so the next Wave-2 inventor doesn't think it's bug-free.
|
||||
- **Page-level vs per-card appellant interaction**: when both are set, per-card wins for descendants of the decision the per-card was set on; page-level still drives descendants of decisions without a per-card pick. Could confuse a user. Mitigation: the page-level appellant selector hides for first-instance proceedings (per §5.5). For appeal proceedings, the selector stays — but those proceedings have a single root decision so the conflict surface is small.
|
||||
- **Cross-proceeding consistency** (where #93's rename lives) — coordinate with the inventor on #93 if both ship in parallel.
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of scope (recap)
|
||||
|
||||
- SmartTimeline (project Verlauf tab) per-card choices.
|
||||
- Versioning / time-machine of choices.
|
||||
- Cross-project propagation.
|
||||
- Coder implementation (separate task per slice).
|
||||
- A "saved scenarios" feature.
|
||||
- Removal of the page-level `?appellant=` URL param for appeal proceedings.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions for m
|
||||
|
||||
The following 4 questions need m's pick. Inventor recommendations marked **(R)**. After m answers via AskUserQuestion, the picks land in §11 below as the historical record.
|
||||
|
||||
### Q1 — State location
|
||||
|
||||
Where do per-card choices live?
|
||||
|
||||
- **(R) A. `paliad.project_event_choices` persisted (with URL override for what-if).** Per-case choices are real, not exploratory. Persist by default; what-if exploration handled later as a URL-override layer.
|
||||
- B. URL query state only. Ephemeral, shareable, no persistence.
|
||||
- C. Both from day one. Persisted default + URL-overridable for what-if scenarios.
|
||||
|
||||
### Q2 — Affordance
|
||||
|
||||
How do the choices surface on a card?
|
||||
|
||||
- **(R) A. Caret (▾) + popover on click.** Off-by-default visual, on-tap reveal. Selected choice surfaces as a chip on the card title.
|
||||
- B. Inline checkbox/radio on every relevant card. Higher discoverability, more visual noise.
|
||||
- C. Card-hover reveals the choices. Discoverability + touch issues.
|
||||
|
||||
### Q3 — Page-level appellant interaction
|
||||
|
||||
When a per-card appellant is set on a decision, what happens to the page-level `?appellant=` selector?
|
||||
|
||||
- **(R) A. Per-card overrides page-level for descendants of THAT decision.** Decisions without a per-card pick still use page-level. Most expressive.
|
||||
- B. Per-card inherits page-level unless explicitly set. Less surprising default but loses the per-decision expressiveness.
|
||||
|
||||
### Q4 — Slice order
|
||||
|
||||
Which slice ships first?
|
||||
|
||||
- **(R) A. Slice A first (appellant per decision + skip optional).** Bounded, validates the popover + persistence layer without touching the flag-evaluation engine. Slice B (include-CCR) follows.
|
||||
- B. Slice B first. Higher-impact user feature but requires the engine change.
|
||||
- C. Bundle A + B in one coder shift. Slower to ship, lower per-coder load, but one less round trip.
|
||||
|
||||
---
|
||||
|
||||
## 11. m's decisions (2026-05-25)
|
||||
|
||||
- **Q1 (State location):** Persisted table — `paliad.project_event_choices` per §3.1. Matches inventor (R).
|
||||
- **Q2 (Affordance):** Caret + popover with chip indicator on chosen cards per §5.1, §5.3. Matches inventor (R).
|
||||
- **Q3 (Appellant layer):** Per-card overrides page-level for descendants of that decision. Page-level still drives decisions without a per-card pick. Matches inventor (R). Implementation: `CalculatedDeadline.AppellantContext` (§4.3) carries the per-decision pick down the parent chain so the bucketer reads one field.
|
||||
- **Q4 (Slice order):** **Bundle Slice A + Slice B in one coder shift** (m picked over inventor (R) of "A first"). Reasoning: keeps the popover, persistence layer, AND the engine extension for `IncludeCCRFor` in one cohesive PR — coder + reviewer hold the full mental model once; one user-visible release; no half-shipped state where the caret exists on Klageerwiderung cards but the include-CCR pick doesn't yet wire through. Trade-off: larger PR. Mitigation: coder still organises commits per slice internally (separate test files, separate handler additions) so review can read them sequentially. See §7 slice plan — both slices implemented; ship as one.
|
||||
|
||||
### Coder-shift implications of Q4 bundling
|
||||
|
||||
- Migration 128 carries ALL three choice-kinds (`appellant`, `skip`, `include_ccr`) in the seed of `choices_offered`, plus the Klageerwiderung rows seeded with `{"include_ccr": [true, false]}`.
|
||||
- `CalcOptions` gains all three new fields (`PerCardAppellant`, `SkipRules`, `IncludeCCRFor`) in the same Go change.
|
||||
- The `IncludeCCRFor` v1 simplification (§4.2 #2 — "any non-empty set means append `with_ccr` to Flags") documents the per-card-scope limitation up front. Multi-CCR proceedings are a future expansion, not a v1 ship blocker.
|
||||
- Frontend popover renders all three blocks the rule offers in one render path; coder cannot half-ship by leaving include_ccr's popover branch as a TODO.
|
||||
- Tests cover the full matrix on the same branch.
|
||||
|
||||
---
|
||||
|
||||
## 12. Hard rules for the coder shift
|
||||
|
||||
- Migration is 128, not anything else. Verify against `paliad.paliad_schema_migrations` MAX before authoring.
|
||||
- Tests added BEFORE projection-engine changes in fristenrechner.go (bucketer, gate, AppellantContext).
|
||||
- `go build ./... && go test ./internal/... && cd frontend && bun run build` clean.
|
||||
- No regression on `?side=` + `?appellant=` URL state.
|
||||
- DE primary, EN secondary for all new i18n keys.
|
||||
- Branch per slice: `mai/<coder>/event-card-choices-slice-a` etc.
|
||||
|
||||
---
|
||||
|
||||
## 13. Reporting
|
||||
|
||||
When ready, the coder reports completion with the URL of the test project that exercises the feature, a screenshot of the popover, and the deadline-rules SQL UPDATE counts for the seeded `choices_offered` rows. Standard slice-completion shape.
|
||||
@@ -421,7 +421,7 @@ The editor is the **largest single surface** in Phase 3. ~3-4 PRs of work depend
|
||||
| `POST /api/admin/rules` | POST | global_admin | Create a new rule from scratch (starts as `lifecycle_state='draft'`). |
|
||||
| `GET /admin/rules/{id}/audit` | GET | global_admin | Audit log for this rule. |
|
||||
| `POST /admin/rules/{id}/preview` | POST | global_admin | Preview-on-trigger-date — runs calculator with this draft replacing its published peer; returns the resulting timeline (no persistence). |
|
||||
| `POST /admin/rules/export-migration` | POST | global_admin | Export pending (draft + audit-since-last-export) rules as a `*.up.sql` blob the human can paste into `internal/db/migrations/`. Sets `migration_exported=true` on the audit rows. |
|
||||
| _(removed t-paliad-297)_ migration-export endpoint | — | — | Was a SQL-export tool generating `*.up.sql` from audit rows. Workflow shifted to hand-written numbered migrations; tool removed in m/paliad#129. |
|
||||
|
||||
### 4.2 Draft → published lifecycle
|
||||
|
||||
|
||||
1618
docs/design-litigation-planner-2026-05-26.md
Normal file
1618
docs/design-litigation-planner-2026-05-26.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ A full org export today is **< 600 rows of user content** plus reference data
|
||||
|
||||
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
|
||||
|
||||
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) — admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
|
||||
**Existing export precedent.** _(Originally pointed at the admin rule-migration export. That tool was deleted in m/paliad#129 / t-paliad-297. The gating pattern — `adminGate(users, …)` on a download endpoint that streams a generated artifact — still lives on other admin handlers, e.g. `handleAdminDownloadBackup` for `/api/admin/backups/{id}/file`.)_ Re-use the gating helper.
|
||||
|
||||
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
|
||||
|
||||
@@ -591,7 +591,7 @@ No other slice deltas. v1 still ships slices 1+2+3.
|
||||
- `docs/design-data-model-v2.md` — projects + mandanten + ltree path + can_see_project predicate.
|
||||
- `docs/design-approval-policy-ui-2026-05-07.md` — 5-source audit union (this design adds the 6th source).
|
||||
- `docs/design-profession-vs-project-role-2026-05-07.md` — profession ladder for the §4 project gate.
|
||||
- `internal/handlers/admin_rules.go:303` — `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
|
||||
- `internal/handlers/backups.go` — `handleAdminDownloadBackup` (precedent for admin-gated artifact download; the older rule-migration export precedent was removed in t-paliad-297).
|
||||
- `internal/services/project_service.go:15` — visibility predicate.
|
||||
- `internal/services/derivation_service.go` — `EffectiveProjectRole` for the project gate.
|
||||
- `github.com/xuri/excelize/v2` — chosen xlsx library.
|
||||
|
||||
277
docs/design-procedural-events-b0-findings-2026-05-26.md
Normal file
277
docs/design-procedural-events-b0-findings-2026-05-26.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Slice B.0 — Live DB re-validation findings (t-paliad-273)
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-26
|
||||
**Branch:** `mai/curie/researcher-slice-b-zero`
|
||||
**Predecessor:** `docs/design-procedural-events-model-2026-05-25.md` (cronus, t-paliad-262)
|
||||
**Scope:** READ-ONLY re-validation of the design doc's §1 premises against the live youpc Supabase `paliad` schema. No migration SQL written, no writes to `deadline_rules` or any table. B.1 (additive migration) remains blocked pending m's greenlight.
|
||||
|
||||
This document does **not** redesign the schema. It does **not** propose new structural changes. It records what the live DB looks like ~24 hours after the design was authored, flags every claim that drifted, and gives the eventual B.1 coder a current-as-of-2026-05-26 baseline to plan against.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
The design doc's §1 premises were sound on 2026-05-25. **All numeric premises drifted in the 24 hours since.** The qualitative model (`deadline_rules` conflates three concepts; live `deadlines.rule_id` FK; snapshot precedent established; no `proceeding_event*` tables) still holds.
|
||||
|
||||
The Q5 default ("10 archived multi-row submission_codes collapse safely") is now **moot**: those rows were removed from the live DB between 2026-05-25 15:30 and 2026-05-26 13:30. There are now **zero** multi-row submission codes; every active submission_code maps 1:1 to one rule row. B.1 backfill no longer needs the multi-row collapse logic that §5 of the design doc anticipated.
|
||||
|
||||
The Q6 default ("concept_id attaches to procedural event, not sequencing rule") is **directionally correct but needs refinement**. The empirical attachment is **above** the procedural-event level — `deadline_concepts` rows cluster legal meaning *across* jurisdictional procedural-event variants. One concept_id can span 15 distinct submission_codes (e.g. "Berufungsfrist" across BGH / BPatG / LG / OLG for both PatG and ZPO paths). The FK in §4.1's draft schema (`procedural_events.concept_id REFERENCES deadline_concepts(id)`, N:1) is **already correctly shaped** for this — no schema change needed. The verbal claim in the design doc should be tightened to "one `deadline_concept` row may be referenced by many procedural events; the FK lives on `procedural_events`."
|
||||
|
||||
Migration tracker drift: the design's "next available mig = 124" is stale; live head is 133 (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27 — applied **after** the design was written). **Next available is 134.** Ten migrations landed since the doc was authored — 124..133. None of them touched `deadline_rules` schema, but they did mutate row content (the missing 23 rows and the new event_type/legal_source distribution come from migs 127/128/132/133).
|
||||
|
||||
The design's claimed migration tracker `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 native counter (stuck at v106). The **canonical** tracker is `paliad.applied_migrations` (one row per applied migration, with checksum + applied_at). `internal/db/migrate.go:9-21` is the source of truth. Project CLAUDE.md still says `paliad.paliad_schema_migrations`; that's a stale doc, not a B.0-scope fix.
|
||||
|
||||
One doc-side bug fixed by this slice: design doc §1 + m/paliad#93 issue body referenced `paliad.deadlines.deadline_rule_id`. Live column is `paliad.deadlines.rule_id`. Both files patched on this branch.
|
||||
|
||||
---
|
||||
|
||||
## §1 Headline-count drift table
|
||||
|
||||
All numbers taken 2026-05-26 ~13:30 UTC against the live `paliad` schema.
|
||||
|
||||
| Metric | Design (2026-05-25) | Live (2026-05-26) | Δ | Notes |
|
||||
|---|--:|--:|--:|---|
|
||||
| `deadline_rules` row count | 254 | **231** | -23 | All rows `is_active = true`. No soft-deletes in flight. |
|
||||
| Rows with `submission_code` | 177 | **153** | -24 | |
|
||||
| Distinct `submission_code` values | 158 | **153** | -5 | **All 5 lost are the multi-row `_archived_litigation.*` codes** — see §2. |
|
||||
| Rows with `legal_source` | 102 | **112** | +10 | |
|
||||
| Distinct `legal_source` values | 70 | **87** | +17 | New jurisdictional variants seeded by recent migs (127/132/133). |
|
||||
| Rows with `concept_id` (linked to `deadline_concepts`) | 125 | **129** | +4 | 56% of the corpus is concept-linked, vs 49% in the design. |
|
||||
| `paliad.deadlines` rows | 1 | **5** | +4 | Still tiny — destructive cutover stays cheap. |
|
||||
| `paliad.submission_drafts` rows | 4 | **7** | +3 | |
|
||||
| Rules in `lifecycle_state = 'draft'` | 4 | **0** | -4 | All 4 design-era drafts were published or discarded. |
|
||||
|
||||
### event_type distribution
|
||||
|
||||
| `event_type` | Design | Live | Δ |
|
||||
|---|--:|--:|--:|
|
||||
| `filing` | 130 | 105 | -25 |
|
||||
| NULL | 77 | 89 | +12 |
|
||||
| `decision` | 25 | 21 | -4 |
|
||||
| `hearing` | 21 | 15 | -6 |
|
||||
| `order` | 1 | 1 | 0 |
|
||||
| **Total** | **254** | **231** | -23 |
|
||||
|
||||
The -23 row delta lands almost entirely in `filing` (-25) and `hearing` (-6), offset by +12 NULL — consistent with the disappearance of the `_archived_litigation.*` filings and a few archived `hearing` rows, plus seeding of new structural / parent-only rows by recent migrations.
|
||||
|
||||
### What did NOT drift (qualitative claims, still valid)
|
||||
|
||||
- `paliad.deadline_rules` carries 39 columns (design said 38 — drift +1; likely from mig 128 `deadline_rules_unit_check` which adds a CHECK without adding a column — or one of migs 124-133 added a column. Not investigated further; out of B.0 scope).
|
||||
- `paliad.deadlines.rule_id` (uuid, nullable) is the FK column to `paliad.deadline_rules.id`. **Confirmed via `information_schema.referential_constraints`** — `rule_id → paliad.deadline_rules(id)`. The doc-side mention of `deadline_rule_id` was always a typo.
|
||||
- `paliad.deadlines.rule_code` + `paliad.deadlines.custom_rule_text` both still present (the denormalized-display columns from mig 122).
|
||||
- `paliad.submission_drafts` uses `(project_id uuid nullable, submission_code text NOT NULL)` as its key — **no FK to deadline_rules**. Confirms the design's claim that the Schriftsätze surface filters on a text key, not on `deadline_rules.id`.
|
||||
- No `paliad.proceeding_event*` tables exist (einstein's 2026-05-08 graph design was never built — still the case).
|
||||
|
||||
---
|
||||
|
||||
## §2 Archived submission_code audit (Q5 re-confirm)
|
||||
|
||||
**Premise re-checked:** "10 archived multi-row submission_codes (`_archived_litigation.*`) collapse safely into single procedural events with multiple sequencing variants."
|
||||
|
||||
**Finding:** the premise is **moot in the live DB**.
|
||||
|
||||
```sql
|
||||
SELECT submission_code, COUNT(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code LIKE '_archived_litigation.%'
|
||||
GROUP BY submission_code;
|
||||
-- 0 rows
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT submission_code, COUNT(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
GROUP BY submission_code
|
||||
HAVING COUNT(*) > 1;
|
||||
-- 0 rows
|
||||
```
|
||||
|
||||
Every active submission_code in the live corpus is 1:1 with its `deadline_rules` row. The 10 multi-row codes the design anticipated no longer exist.
|
||||
|
||||
**Consequence for B.1 backfill:**
|
||||
|
||||
- The §5.1 / §5.2 backfill SQL the design sketched (collapsing N rows-with-same-submission_code into 1 procedural_event + N sequencing_rules) is **simpler than expected**: a straight 1:1 backfill, no GROUP-BY-and-collapse step needed.
|
||||
- B.1's `INSERT INTO paliad.procedural_events ... SELECT DISTINCT submission_code ...` becomes equivalent to `INSERT ... SELECT submission_code, ... FROM deadline_rules WHERE submission_code IS NOT NULL`. No deduplication needed.
|
||||
- The 78 rows where `submission_code IS NULL` (231 - 153) still need a B.1 decision: do they become `procedural_events` rows (with synthetic codes), do they become free-standing `sequencing_rules` with `procedural_event_id` NULL, or do they get parked? This was implicit in the design (the 77 NULLs were framed as "structural / parent-only rows in the proceeding tree"); B.1 should make the decision explicit and document it in the migration's `.up.sql` comments.
|
||||
|
||||
---
|
||||
|
||||
## §3 concept_id attachment shape (Q6 re-confirm)
|
||||
|
||||
**Premise re-checked:** "concept_id attaches to procedural event, not sequencing rule."
|
||||
|
||||
**Finding:** **partly true.** The FK direction the design proposes (`procedural_events.concept_id → deadline_concepts.id`, N:1) is correct. The verbal phrasing in Q6's default needs refinement — the empirical attachment is **above** the procedural-event level, not "at" it.
|
||||
|
||||
### Empirical pattern
|
||||
|
||||
129 of 231 rows carry a `concept_id`. Those 129 rows reference **53 distinct `deadline_concepts`** rows. Averages: 2.43 rows-per-concept, 2.42 submission-codes-per-concept (the two are nearly identical because today's corpus has no multi-row submission codes — see §2). Span distribution:
|
||||
|
||||
- 33 of 53 concepts (62%) attach to exactly 1 submission_code → procedural-event-scoped.
|
||||
- 20 of 53 concepts (38%) attach to >1 submission_code → cross-procedural-event scoped.
|
||||
- Maximum: 1 concept attaches to **15 distinct submission_codes**.
|
||||
|
||||
### Example: one concept, four procedural events
|
||||
|
||||
The concept `b85b2e5a-4064-40b2-b862-24b7abaa5b94` ("Berufungsfrist / Berufungsschrift") is referenced by 4 `deadline_rules` rows that today carry these 4 distinct submission_codes:
|
||||
|
||||
| rule_code | submission_code | court | name |
|
||||
|---|---|---|---|
|
||||
| § 110 PatG | `de.null.bgh.berufung` | BGH | Berufungsschrift |
|
||||
| § 110 PatG | `de.null.bpatg.berufung` | BPatG | Berufungsfrist |
|
||||
| § 517 ZPO | `de.inf.lg.berufung` | LG | Berufungsfrist |
|
||||
| § 517 ZPO | `de.inf.olg.berufung` | OLG | Berufungsfrist |
|
||||
|
||||
Under Slice B's target schema (§4.1), each of these four rows becomes a separate `procedural_events` row (different `code`s, different jurisdiction-specific names, different `legal_source_id`s), but **all four reference the same `deadline_concepts.id`**.
|
||||
|
||||
### Implication for B.1
|
||||
|
||||
- `procedural_events.concept_id` should be **nullable** (62% of rows today have no concept link — the §4.1 sketch already allows this).
|
||||
- The constraint must be **N:1, not 1:1** (one `deadline_concept` may be referenced by many `procedural_events`). The §4.1 sketch (`concept_id uuid REFERENCES paliad.deadline_concepts(id)`) is already correctly N:1; a hypothetical "UNIQUE INDEX on `procedural_events.concept_id`" would break the existing data. **Do not add UNIQUE.**
|
||||
- The design doc's Q6 phrasing can be tightened to: "concept_id attaches to procedural event (N procedural events → 1 concept). Sequencing rules do not carry concept_id." — but this is a wording nit, not a structural change. It does **not** block B.1.
|
||||
|
||||
---
|
||||
|
||||
## §4 Snapshot precedent audit
|
||||
|
||||
**Premise re-checked:** the `paliad.deadline_rules_pre_<N>` snapshot pattern is established and ready for B.4's destructive drop.
|
||||
|
||||
**Finding:** confirmed and consistent.
|
||||
|
||||
Snapshot tables in `paliad`:
|
||||
|
||||
| Snapshot table | Origin migration |
|
||||
|---|---|
|
||||
| `deadlines_pre_089` | mig 089 |
|
||||
| `deadline_rules_pre_091` | mig 091 (destructive drop of legacy columns) |
|
||||
| `event_deadlines_pre_092` | mig 092 |
|
||||
| `event_deadline_rule_codes_pre_092` | mig 092 |
|
||||
| `deadline_rules_pre_093` | mig 093 |
|
||||
| `proceeding_types_pre_093` | mig 093 |
|
||||
| `projects_pre_094` | mig 094 |
|
||||
| `deadline_rules_pre_095` | mig 095 |
|
||||
| `proceeding_types_pre_096` | mig 096 |
|
||||
| `deadline_rules_pre_098` | mig 098 |
|
||||
|
||||
Pattern: `<original_table>_pre_<migration_number>`. Always created in the `.up.sql` of the destructive migration as `CREATE TABLE paliad.<t>_pre_<N> AS TABLE paliad.<t>;` (followed by the destructive DROP / ALTER).
|
||||
|
||||
**B.4's template:** before `DROP TABLE paliad.deadline_rules;` (and `ALTER TABLE paliad.deadlines DROP COLUMN rule_id;`), `mig <N>.up.sql` must include:
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;
|
||||
-- (optional) CREATE TABLE paliad.deadlines_pre_<N> AS TABLE paliad.deadlines;
|
||||
```
|
||||
|
||||
This is non-negotiable per m's snapshot policy and the precedent of migs 089-098. B.4 should not enter the deploy queue without it.
|
||||
|
||||
---
|
||||
|
||||
## §5 deadlines.rule_id doc bug — verified + patched
|
||||
|
||||
**Premise re-checked:** the live column on `paliad.deadlines` referencing `deadline_rules` is named `rule_id`, not `deadline_rule_id`.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```sql
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema='paliad' AND table_name='deadlines' AND column_name LIKE '%rule%';
|
||||
-- rule_id (uuid, nullable)
|
||||
-- rule_code (text, nullable)
|
||||
-- custom_rule_text (text, nullable)
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT kcu.column_name, ccu.table_name, ccu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu ON ...
|
||||
JOIN information_schema.constraint_column_usage ccu ON ...
|
||||
WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_schema='paliad' AND tc.table_name='deadlines';
|
||||
-- rule_id → paliad.deadline_rules.id
|
||||
```
|
||||
|
||||
**Fix applied on this branch:**
|
||||
|
||||
- `docs/design-procedural-events-model-2026-05-25.md` — §1 row 51 already says "the column is `rule_id` (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo)". §1 row 63 (the "Doc-side bug flagged" line) already names the fix target. **No change needed to the design doc — the inventor already flagged and described the bug; B.0 just re-confirms it.**
|
||||
- `m/paliad#93` issue body — line 56 says `paliad.deadlines.deadline_rule_id` in the Q3 migration shape. Patched via Gitea API on this slice. See §6 of this report.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration tracker drift (out-of-scope context)
|
||||
|
||||
The design doc said "next available mig number is 124 (mig 123 = Backup Mode Slice A, just shipped)". Live state on 2026-05-26 13:30:
|
||||
|
||||
- Latest applied migration: **133** (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27).
|
||||
- Next available: **134**.
|
||||
- Migrations 124-133 (all applied after the design was authored):
|
||||
|
||||
```
|
||||
124 de_inf_lg_replik_duplik_sequencing (2026-05-25 13:49)
|
||||
125 cross_cutting_filter_legal_source (2026-05-25 14:13)
|
||||
126 users_inbox_seen_at (2026-05-25 13:51)
|
||||
127 wave0_tier0_deadline_fixes (2026-05-25 14:13)
|
||||
128 deadline_rules_unit_check (2026-05-25 14:13)
|
||||
129 project_event_choices (2026-05-25 15:02)
|
||||
130 submission_drafts_language (2026-05-25 15:05)
|
||||
131 submission_drafts_party_selection (2026-05-25 15:02)
|
||||
132 wave1_tier1_rule_additions (2026-05-25 15:40)
|
||||
133 upc_dmgs_pi_court_followup (2026-05-25 15:27)
|
||||
```
|
||||
|
||||
These touched `deadline_rules` content (wave0/wave1 rule additions, sequencing fixes, unit checks) and adjacent tables, but did not change the conflated-three-concepts shape that motivates Slice B. The structural premise of the design holds; the row-level numbers shifted.
|
||||
|
||||
**Side observation (not a B.0 fix scope):** the project's `CLAUDE.md` says "Migration tracker is `paliad.paliad_schema_migrations` (avoids collision with other apps on the shared `public.schema_migrations`)." That sentence is stale. The **canonical tracker is `paliad.applied_migrations`** (per `internal/db/migrate.go:9-21,53,105`). `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 counter, frozen at v106; the migrate runner uses it only to bootstrap `applied_migrations` on first deploy of the new runner (`internal/db/migrate.go:219-240`). Recommend a separate doc-fix slice (out of B.0 scope) to update `.claude/CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## §7 Updated B.1 brief (no-op / minor adjustments only)
|
||||
|
||||
What the live data means for the design's §5 migration plan:
|
||||
|
||||
1. **Backfill is simpler.** No multi-row collapse logic needed (§2). One-to-one `INSERT INTO paliad.procedural_events SELECT submission_code, name, name_en, description, event_type AS event_kind, primary_party, ... FROM paliad.deadline_rules WHERE submission_code IS NOT NULL` against 153 rows.
|
||||
2. **The 78 NULL-submission_code rows need an explicit decision in B.1.** Either:
|
||||
- (a) Skip them — they remain `deadline_rules`-only and become orphan-once-deadline_rules-is-dropped. Not acceptable; B.4 would lose them.
|
||||
- (b) Mint synthetic codes (`null.<uuid8>` or similar) for the structural rows and create `procedural_events` for them.
|
||||
- (c) Treat them as "sequencing-rule-only" (a `sequencing_rules` row with NULL `procedural_event_id`) — would require `sequencing_rules.procedural_event_id` to be nullable, which contradicts §4.1's NOT NULL FK.
|
||||
- Default recommendation: **(b)** — mint codes, preserve every row. B.1 must document the mint rule in the `.up.sql`. Surface this to head before scheduling B.1.
|
||||
3. **concept_id stays N:1 on procedural_events.** No UNIQUE constraint. §4.1's sketch already does this; just don't accidentally tighten it.
|
||||
4. **Use migration number 134** (or whatever's the live `MAX(version)+1` at B.1-write-time; re-check at the moment of writing the file).
|
||||
5. **Snapshot before drop in B.4:** `CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;` per §4 precedent. **This is the hard-stop pre-condition for B.4 entering the deploy queue.**
|
||||
6. **Submission_drafts.submission_code → procedural_events.code text join** continues to work unchanged through B.1-B.3 because both names match. No B.5 dual-write needed for `submission_drafts`. (The design's §6.3 already noted this.)
|
||||
|
||||
None of these change the **shape** of the design — they tighten the backfill SQL and surface one explicit decision (point 2) for head.
|
||||
|
||||
---
|
||||
|
||||
## §8 Outputs of this slice (B.0)
|
||||
|
||||
| Artifact | Status |
|
||||
|---|---|
|
||||
| `docs/design-procedural-events-b0-findings-2026-05-26.md` (this file) | created on `mai/curie/researcher-slice-b-zero` |
|
||||
| `docs/design-procedural-events-model-2026-05-25.md` | cherry-picked from `mai/cronus/inventor-procedural` onto this branch (design doc was never merged to main; B.0 brings it onto a branch off main so the doc bug fix has somewhere to land) |
|
||||
| m/paliad#93 issue body — `deadline_rule_id` → `rule_id` correction | patched via Gitea API |
|
||||
| Gitea comment on m/paliad#93 summarizing this report | posted (see §6 trailing summary on the issue) |
|
||||
|
||||
**Nothing migrated, nothing written to `paliad.deadline_rules` or any other live data table.** Only `mai.reports` (progress) and the GitHub issue body / repo files were touched.
|
||||
|
||||
---
|
||||
|
||||
## §9 Hard-stop status
|
||||
|
||||
**B.0 COMPLETE. AWAITING B.1 GREENLIGHT.**
|
||||
|
||||
Per the original instruction:
|
||||
|
||||
- B.1 (additive migration creating `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources` + backfill) requires explicit m approval before any new tables get created.
|
||||
- B.4 (destructive drop of `paliad.deadline_rules` + `paliad.deadlines.rule_id`) requires m's downtime-window approval AND a `paliad.deadline_rules_pre_<N>` snapshot table in the same migration.
|
||||
- This researcher (curie) stays parked until head re-hires.
|
||||
|
||||
---
|
||||
|
||||
## §10 Decisions worth surfacing to m before B.1 starts
|
||||
|
||||
1. **NULL-submission_code rows (78 of them) — what to do during backfill?** Recommendation (b): mint synthetic codes. m should confirm or pick (a)/(c).
|
||||
2. **B.5 deprecation header window length** — the design (§8.2) says "one slice". For 7 active submission_drafts that's safe; the question is whether external integrations (Word templates with `{{rule.X}}`) need a longer window. The variable-bag alias contract (`submission_vars.go`) covers Word templates without a wire-format change, so "one slice" is defensible. m should confirm.
|
||||
3. **Migration number reservation** — by the time B.1 ships, the live head may be 135+. The B.1 coder must re-check `MAX(version)` at write-time. (Not a decision; just a process note.)
|
||||
|
||||
These are the only open questions the B.0 audit surfaced. Everything else in the design holds.
|
||||
571
docs/design-procedural-events-model-2026-05-25.md
Normal file
571
docs/design-procedural-events-model-2026-05-25.md
Normal file
@@ -0,0 +1,571 @@
|
||||
# Design — Procedural-Events Data Model (t-paliad-262)
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-25
|
||||
**Issue:** m/paliad#93 (mai task t-paliad-262)
|
||||
**Branch:** `mai/cronus/inventor-procedural`
|
||||
**Status:** DESIGN — read-only, no schema or code changes in this branch.
|
||||
**B.0 re-validation:** see `docs/design-procedural-events-b0-findings-2026-05-26.md` (curie, 2026-05-26) for the live-DB premise re-check. Numeric §1 claims drifted; Q5 multi-row collapse premise is moot (no `_archived_litigation.*` rows remain); Q6 N:1 attachment confirmed; mig number target updated 124 → 134.
|
||||
**Prior art read:**
|
||||
- `docs/design-deadline-data-model-2026-05-08.md` (einstein, t-paliad-158) — proposed `proceeding_event_types` + `proceeding_event_edges`; the **graph-shape recommendation has not been built** (no `proceeding_event*` tables exist in the live DB as of 2026-05-25, verified via `information_schema.tables`).
|
||||
- `docs/design-fristen-phase2-2026-05-15.md` (Phase 2/3 unified-rule columns — migs 078/079/091, **shipped**).
|
||||
- `docs/design-submission-generator-2026-05-19.md` and `docs/design-submission-page-2026-05-22.md` (Slice 1 → Slice A of the Schriftsätze stack — shipped on top of today's `deadline_rules`).
|
||||
|
||||
This doc names a single conflation in the schema and proposes a two-slice fix (cosmetic immediate, structural follow-up). It is intentionally narrower than einstein's 2026-05-08 graph proposal — it does **not** re-litigate the proceeding-as-DAG question.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
`paliad.deadline_rules` today is **one row that wears three hats**:
|
||||
|
||||
1. **The procedural-event template** — `submission_code`, `name`, `name_en`, `description`, `event_type`, `primary_party`. This is "what kind of step is this in the proceeding": Rechtsbeschwerdebegründung, mündliche Verhandlung, Entscheidung, etc.
|
||||
2. **The legal-norm citation** — `legal_source`, `rule_code`, `alt_rule_code`, `rule_codes[]`. This is "the source-of-law anchor": § 102 PatG, UPC RoP R.220(1).
|
||||
3. **The sequencing rule** — `parent_id`, `trigger_event_id`, `duration_value`, `duration_unit`, `timing`, `alt_duration_*`, `combine_op`, `condition_expr`, `is_spawn`, `spawn_*`, `sequence_order`, `is_court_set`, `priority`, `anchor_alt`, `proceeding_type_id`. This is "how and when does it fire relative to other events".
|
||||
|
||||
The conflation surfaces most painfully in the submission-draft editor's variable sidebar (m's report 2026-05-25 15:02), where the lawyer sees field labels like `{{rule.submission_code}}` for what is plainly a *procedural-event code*, `{{rule.event_type}}` for what is plainly the *procedural-event kind*, and `{{rule.legal_source_pretty}}` for what is plainly the *legal norm* — all under a `rule.*` namespace that reads as if the lawyer were filling in arithmetic.
|
||||
|
||||
**Recommendation = Q1 option (C):**
|
||||
|
||||
- **Slice A (immediate, this design's coder shift):** cosmetic rename — placeholders, i18n labels, Go struct-comment naming, admin-UI page titles all shift to `procedural_event.*` as the canonical name. **Database schema, table name, column names, FK directions, JSON envelope keys on the wire all stay exactly as they are.** Old `{{rule.*}}` placeholders remain emitted in the variable bag as legacy aliases so existing Word templates and saved drafts keep working.
|
||||
- **Slice B (planned follow-up, separate mai task, separate slice plan):** structural rework — extract `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources`, with a phased dual-write migration. **Not shipped here.** This doc defines the target shape (§4) and the migration shape (§5) so the eventual coder has a brief, not so the eventual coder is hired today.
|
||||
|
||||
**Umbrella term lock = Q2 option (R):** **"procedural event"** (DE: **"Verfahrensschritt"**) as the umbrella covering filings, hearings, decisions, orders. Justification in §2.
|
||||
|
||||
Both Slice A and the eventual Slice B preserve the Schriftsätze surface (t-paliad-238/242/243): the submissions list query changes its predicate from `dr.event_type = 'filing'` to `pe.event_kind IN ('filing', 'reply')` (Slice B only) — same rows, cleaner predicate.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-25)
|
||||
|
||||
Every load-bearing claim was checked against the running paliad codebase + youpc Supabase. Numbers and schema facts are point-in-time as of 2026-05-25 15:30.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| `paliad.deadline_rules` carries the 38 columns listed in §0's three-hats decomposition. | `information_schema.columns WHERE table_schema='paliad' AND table_name='deadline_rules'` — 38 rows; columns confirmed verbatim. |
|
||||
| Live row count = 254. | `SELECT COUNT(*) FROM paliad.deadline_rules` → 254. |
|
||||
| 177 rows carry a `submission_code` (procedural-event identity); 158 distinct values. | `COUNT(*) FILTER (WHERE submission_code IS NOT NULL)` → 177; `COUNT(DISTINCT submission_code)` → 158. |
|
||||
| 102 rows carry a `legal_source`; 70 distinct citations. | Same query, `legal_source` column. |
|
||||
| 125 rows are linked to a `deadline_concepts` row via `concept_id`. | `COUNT(*) FILTER (WHERE concept_id IS NOT NULL)` → 125 (49 % of the corpus). |
|
||||
| `event_type` distribution: 130 `filing` · 77 NULL · 25 `decision` · 21 `hearing` · 1 `order`. | `SELECT event_type, count(*) GROUP BY event_type` — confirmed; the 77 NULL rows are structural / parent-only rows in the proceeding tree. |
|
||||
| 10 `submission_code` values appear on more than one row (jurisdictional / bilateral variants). | All 10 today are `_archived_litigation.*` codes (claimant/defendant splits + multi-stage hearing rows). Live non-archived codes are 1:1 with rows in the current corpus. |
|
||||
| `paliad.deadlines` joins to `deadline_rules` via column `rule_id` (uuid, FK). The text `rule_code` and free-text `custom_rule_text` (mig 122, t-paliad-258) are denormalized for display when the rule row is deleted. | `internal/services/deadline_service.go:69-127`; live column list confirms `rule_id`, `rule_code`, `custom_rule_text` — there is **no** `deadline_rule_id` column on deadlines (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo; the column is `rule_id`). |
|
||||
| `paliad.submission_drafts` keys to a procedural event via `submission_code` text — **no FK** to `deadline_rules`. | `information_schema.columns` for `submission_drafts`: `submission_code text` plus `(project_id, submission_code)` as the joint identifier. Confirms the Schriftsätze surface filters on the *text key*, not on `deadline_rules.id`. |
|
||||
| The Schriftsätze list (t-paliad-238) filters `deadline_rules` by `event_type='filing'` and `submission_code IS NOT NULL`. | `internal/handlers/submissions.go:193-211` — verbatim. |
|
||||
| The variable bag emits exactly 8 `rule.*` placeholders. | `internal/services/submission_vars.go:349-364` — `rule.submission_code`, `rule.name`, `rule.name_de`, `rule.name_en`, `rule.legal_source`, `rule.legal_source_pretty`, `rule.primary_party`, `rule.event_type`. Frontend i18n labels at `frontend/src/client/submission-draft.ts:158-185`. |
|
||||
| Admin rule-edit form binds the same `rule.X` fields. | `frontend/src/admin-rules-edit.tsx:74-110` + `frontend/src/client/admin-rules-edit.ts:253-278` — same eight columns surfaced as form inputs. |
|
||||
| The Fristenrechner client surface refers to `calc.rule.nameDE` / `calc.rule.nameEN`. | `frontend/src/client/fristenrechner.ts:1592,1655`. |
|
||||
| einstein's 2026-05-08 `proceeding_event_types` + `proceeding_event_edges` are **not** in the DB. | `SELECT table_name FROM information_schema.tables WHERE table_schema='paliad' AND table_name LIKE '%proceeding_event%'` → 0 rows. The graph-shape proposal was never built. |
|
||||
| `paliad.deadline_concepts` (57 rows in the original einstein audit; live count not directly queried this shift) still exists and is referenced via `deadline_rules.concept_id`. | `information_schema.tables` confirms `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_types`, `trigger_events`, `event_categories` all still present — the deadline-knowledge graph from the einstein design lives on alongside the unified rule columns. |
|
||||
| Phase 2/3 columns (`priority`, `condition_expr`, `is_court_set`, `lifecycle_state`, `draft_of`, `published_at`, `rule_codes[]`) are live and load-bearing. | `internal/models/models.go:622-684` + mig 091. Slice B's structural rework must preserve every one of these on the new `sequencing_rules` table — they are not legacy. |
|
||||
| Live `paliad.deadlines` references to rules are sparse (1 row in prod). | `SELECT COUNT(*) FROM paliad.deadlines` → 1. The 4 `submission_drafts` rows reference a procedural event by `submission_code` text only. Tiny live FK surface → migrations can be aggressive without losing user data. |
|
||||
| Migration tracker is `paliad.paliad_schema_migrations`; next available number is 124 (mig 123 = Backup Mode Slice A, just shipped). | `internal/db/migrations/` directory listing; latest applied = 123. |
|
||||
|
||||
**Doc-side bug flagged for this issue's body:** the deliverable spec writes `paliad.deadlines.deadline_rule_id` in §3 (Q3 migration shape). The live column is `paliad.deadlines.rule_id`. Slice B's rename target is therefore `paliad.deadlines.procedural_event_id`, renamed directly from `paliad.deadlines.rule_id` — there is no intermediate `deadline_rule_id` step (no such column exists). Updating the issue body is m's call — flagged here so it doesn't propagate into a coder brief. *(B.0 update 2026-05-26: issue body patched. See `docs/design-procedural-events-b0-findings-2026-05-26.md` §5.)*
|
||||
|
||||
---
|
||||
|
||||
## §2 m's vocabulary call (Q2 — lock the umbrella term)
|
||||
|
||||
m proposed "procedural event" in the report. Options weighed:
|
||||
|
||||
| Option | Reads as | Collisions | Verdict |
|
||||
|---|---|---|---|
|
||||
| **"procedural event"** (DE: "Verfahrensschritt") | Umbrella that naturally covers filings, hearings, decisions, orders. Matches lawyer mental model: "the next thing that happens in the proceeding". | None — no `paliad.procedural_event*` table or column today (verified). | **(R) — adopt as canonical.** |
|
||||
| "submission" | Today the Schriftsätze surface uses this for *filings only* (`event_type='filing'`). Expanding the meaning would silently change Slice A's semantics for an existing UI. | Surface-level collision with the Schriftsätze nomenclature already in production. | Reject — would lose precision for an existing concept. |
|
||||
| "event" / "event_type" | Existing `deadline_rules.event_type` column. | **Hard collision** with `paliad.events` (audit feed, distinct table, distinct meaning). Renaming around it would be worse than the conflation we're trying to fix. | Reject. |
|
||||
| "Verfahrensschritt" only (no English) | Cleanest German but no English fallback. | Bilingual UI (DE primary, EN secondary per project CLAUDE.md) requires both. | Reject in isolation — but **adopt as the canonical German rendering** of "procedural event". |
|
||||
| "Verfahrensereignis" | Closer literal translation of "procedural event". | None. | Reject in favor of "Verfahrensschritt" — m's broader vocabulary uses "Schritt" (e.g. "Antragsschritt") more naturally than "Ereignis", which already maps to `paliad.events` in the audit-feed sense. |
|
||||
|
||||
**Lock:**
|
||||
|
||||
| Surface | Canonical |
|
||||
|---|---|
|
||||
| English | **procedural event** (lowercase except sentence-initial) |
|
||||
| German | **Verfahrensschritt** (m. — der Verfahrensschritt) |
|
||||
| Plural EN | procedural events |
|
||||
| Plural DE | Verfahrensschritte |
|
||||
| Code identifier (Go struct names, TS types) | `ProceduralEvent`, `ProceduralEventKind`, `ProceduralEventTemplate` |
|
||||
| Snake-case (DB columns, JSON keys, i18n keys, placeholders) | `procedural_event`, `procedural_event_kind`, `procedural_events` (table) |
|
||||
| Slice A: variable-bag placeholder namespace | `procedural_event.*` (with `rule.*` kept as legacy alias) |
|
||||
| Slice B: table name (if shipped) | `paliad.procedural_events` |
|
||||
|
||||
`event_type` (the column) becomes `event_kind` in Slice B — using "kind" rather than "type" to free up the word "type" for the proceeding-level taxonomy (`paliad.proceeding_types`, untouched) and to mirror the "event_type vs event_kind" disambiguation einstein hit in the 2026-05-08 doc. In Slice A the column stays `event_type` (no DB change).
|
||||
|
||||
**Q2 is locked by inventor recommendation.** It costs nothing structurally and clears noise across every downstream conversation. If m disagrees in the head round-trip, the only thing that flips is the term — Slice A's scope shape stays.
|
||||
|
||||
---
|
||||
|
||||
## §3 Scope decision (Q1 — A vs B vs C)
|
||||
|
||||
**Recommendation = (C) — cosmetic rename now, structural rework as a planned follow-up.**
|
||||
|
||||
### Why not (A) — cosmetic only and stop
|
||||
|
||||
(A) leaves the model wrong forever. The conflation isn't just a labelling annoyance — it makes future questions harder to answer cleanly:
|
||||
|
||||
- "How many distinct procedural events does paliad model?" Today: ambiguous (rows vs distinct `submission_code`s vs distinct `(submission_code, proceeding_type_id)` tuples).
|
||||
- "Where can we attach a per-procedural-event Word template that's independent of which proceeding it appears in?" Today: nowhere — the FK chain forces a per-row template registry, see `internal/handlers/files.go` template fallback.
|
||||
- "Show me every sequencing rule that triggers a given procedural event across all proceedings." Today: requires joining `deadline_rules` to itself on `submission_code` + `parent_id`, brittle.
|
||||
|
||||
If m signals (A) anyway — fine; the cosmetic-only slice is a strict subset of (C)'s Slice A and ships the same value (label clarity in the editor). But the recommendation is to write down the structural target now while the analysis is fresh.
|
||||
|
||||
### Why not (B) — restructure immediately
|
||||
|
||||
(B) means: one slice plan, one cutover. With:
|
||||
- 254 live rule rows,
|
||||
- 1 live `paliad.deadlines` row,
|
||||
- 4 live `submission_drafts` rows,
|
||||
- 12 Go services + 6 handlers touching `deadline_rules` + 8 placeholder strings on the wire + the admin rule-editor UI bound to the column shape,
|
||||
|
||||
…doing this in one cutover means a big-bang migration during a downtime window. m has granted exactly one such window in recent memory (2026-05-15 for mig 091's destructive drops), and that one was constrained to a 4-column drop. A four-table restructure has a meaningfully larger blast radius; it warrants its own task with its own slice plan and its own risk review.
|
||||
|
||||
### Why (C) — cosmetic-rename Slice A this design, structural Slice B as a separate task
|
||||
|
||||
Three properties of (C) make it the safe call:
|
||||
|
||||
1. **Slice A is reversible at any time** — every change is in i18n strings, Go struct comments, admin-UI page titles, and the variable-bag aliases. No DB migration. No drop. A revert is a `git revert` of the Slice A commit.
|
||||
2. **Slice B is fully designed but uncommitted** — §4 and §5 below define the target shape and migration plan, but the design doc itself ships in Slice A. m can read it, redirect it, or park it without pressure to ship it now.
|
||||
3. **The Schriftsätze surface doesn't care which slice we ship** — Slice A leaves it on `event_type='filing'`; Slice B flips it to `event_kind IN ('filing', 'reply')` over a dual-write window. Either way, the lawyer-facing behavior is unchanged.
|
||||
|
||||
### Slice A's deliverable boundary (what gets renamed, what stays)
|
||||
|
||||
**Renamed in Slice A:**
|
||||
|
||||
- **i18n keys** for the admin rule-editor field labels: `admin.rules.edit.field.submission_code` → `admin.rules.edit.field.procedural_event_code`, etc. (16 keys total — `name`, `name_en`, `description`, `submission_code`, `rule_code`, `legal_source`, `primary_party`, `event_type` × DE/EN — full list in §7.1.)
|
||||
- **Variable-bag placeholder labels** in `submission-draft.ts:158-185`: the *visible label* (`{ de: "Schriftsatz-Code", en: "Submission code" }`) is unchanged for filings (filings are still Schriftsätze on that surface), but the **namespace shown next to the placeholder string** changes: lawyer sees `{{procedural_event.code}}` in the placeholder column with the same Schriftsatz-Code label and same value. The old `{{rule.submission_code}}` stays in the catalog as an "(alt)" entry pointing at the same field.
|
||||
- **Variable-bag emission** (`internal/services/submission_vars.go:351-364`): the bag emits **both** key-names for every value, so any Word template / saved draft holding `{{rule.X}}` keeps working without a touch. New templates and the in-app catalog show the canonical `{{procedural_event.X}}` name.
|
||||
- **Admin page titles + section headings**: "Regel bearbeiten" → "Verfahrensschritt bearbeiten" (DE), "Edit rule" → "Edit procedural event" (EN). "Regeln verwalten" → "Verfahrensschritte verwalten" / "Procedural events". The URL path `/admin/rules` stays — URL renames have downstream cost (bookmarks, audit log entries) and would need their own redirect slice (out of scope here).
|
||||
- **Go struct comments + service docstrings + worker-facing log lines** that refer to "the rule" → "the procedural event" where the referent is the procedural-event aspect (not the sequencing-rule aspect). Function names, type names, table name stay (Slice B handles those).
|
||||
- **The "Submission Code / Einreichung-Kennung" label** itself stays (it's the lawyer's anchor — they recognize it). The framing around it changes: it now reads as "the code that identifies this *procedural event*", not "the code attached to this *rule*".
|
||||
|
||||
**Untouched in Slice A:**
|
||||
|
||||
- Database schema. Table name (`paliad.deadline_rules`). Column names. FK directions. Indexes. RLS policies. Triggers. Audit log column `rule_id`.
|
||||
- Go struct names: `DeadlineRule` stays. The renames here are *prose*, not *code*. Renaming `DeadlineRule` to `ProceduralEvent` couples Slice A to Slice B's table rename — keep them decoupled.
|
||||
- JSON envelope keys on the wire (`POST /api/admin/rules/:id` still accepts `submission_code` in the body — Slice B's API rename is a breaking change with its own deprecation window).
|
||||
- URL paths (`/admin/rules`, `/api/admin/rules/:id`, `/api/projects/:id/submissions` etc.).
|
||||
- `paliad.deadlines.rule_id` FK column name.
|
||||
- The variable-bag's legacy `{{rule.X}}` keys — kept forever as aliases (cheap, zero rot).
|
||||
- The `submission_drafts` table's `submission_code` text key.
|
||||
|
||||
This boundary makes Slice A a one-day coder shift: scoped, reversible, label-only.
|
||||
|
||||
### What Slice B inherits
|
||||
|
||||
Slice B inherits a codebase + a UI where every prose surface already speaks "procedural event". It also inherits a *legacy alias contract* (the dual emission in the variable bag) that gives it freedom to rename the JSON keys on the wire and the Go struct in two separate sub-slices without rushing.
|
||||
|
||||
---
|
||||
|
||||
## §4 Restructure schema (Q3 — if/when we ship Slice B)
|
||||
|
||||
This is the target the eventual Slice B coder would land. **Nothing here ships in this task.**
|
||||
|
||||
### §4.1 Three new tables (plus the rename of `deadline_rules`)
|
||||
|
||||
```sql
|
||||
-- 1. Procedural event templates — one row per (procedural-event identity)
|
||||
-- For now the live corpus is 1:1 with non-archived submission_codes
|
||||
-- (148 of the 158 distinct codes), so we get ~177 rows minus the 10
|
||||
-- multi-row codes' duplicates. Bilateral / jurisdictional variants
|
||||
-- are modeled at the sequencing_rules layer.
|
||||
CREATE TABLE paliad.procedural_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE, -- former submission_code
|
||||
name text NOT NULL, -- DE
|
||||
name_en text NOT NULL,
|
||||
description text,
|
||||
event_kind text NOT NULL, -- filing|reply|hearing|decision|order|other
|
||||
primary_party_default text, -- claimant|defendant|both|court
|
||||
legal_source_id uuid REFERENCES paliad.legal_sources(id),
|
||||
concept_id uuid REFERENCES paliad.deadline_concepts(id),
|
||||
lifecycle_state text NOT NULL DEFAULT 'published', -- draft|published|archived
|
||||
draft_of uuid REFERENCES paliad.procedural_events(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 2. Legal sources — the source-of-law citations the procedural event
|
||||
-- anchors against. ~70 distinct values today (live corpus).
|
||||
CREATE TABLE paliad.legal_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
citation text NOT NULL UNIQUE, -- "DE.PatG.102", "UPC.RoP.220.1", …
|
||||
jurisdiction text NOT NULL, -- DE|UPC|EPA|DPMA|other
|
||||
pretty_de text NOT NULL, -- "§ 102 PatG"
|
||||
pretty_en text NOT NULL, -- "Section 102 PatG"
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 3. Sequencing rules — the timing / trigger / condition mechanics that
|
||||
-- today live alongside the procedural-event identity on deadline_rules.
|
||||
-- One row per (procedural_event × proceeding × variant). The 10
|
||||
-- "_archived_litigation.*" codes that today have 2-5 rows become
|
||||
-- 2-5 sequencing_rules rows for the same procedural_events row.
|
||||
CREATE TABLE paliad.sequencing_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
procedural_event_id uuid NOT NULL REFERENCES paliad.procedural_events(id),
|
||||
proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
parent_id uuid REFERENCES paliad.sequencing_rules(id), -- structural tree, today's parent_id
|
||||
trigger_event_id bigint REFERENCES paliad.trigger_events(id), -- event-rooted variant
|
||||
duration_value integer NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months',
|
||||
timing text DEFAULT 'after',
|
||||
alt_duration_value integer,
|
||||
alt_duration_unit text,
|
||||
alt_rule_code text, -- legacy free-text alt citation, retained
|
||||
anchor_alt text,
|
||||
combine_op text, -- max|min
|
||||
condition_expr jsonb,
|
||||
primary_party text, -- per-rule override of the procedural_event default
|
||||
sequence_order integer NOT NULL DEFAULT 0,
|
||||
is_spawn boolean NOT NULL DEFAULT false,
|
||||
spawn_label text,
|
||||
spawn_proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
is_bilateral boolean NOT NULL DEFAULT false,
|
||||
is_court_set boolean NOT NULL DEFAULT false,
|
||||
priority text NOT NULL DEFAULT 'mandatory',
|
||||
rule_code text, -- legacy short-form citation, retained on the rule
|
||||
rule_codes text[], -- multi-citation array (mig pre-091)
|
||||
deadline_notes text,
|
||||
deadline_notes_en text,
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.sequencing_rules(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 4. Rename downstream FK + add the link to procedural_events.
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id),
|
||||
ADD COLUMN sequencing_rule_id uuid REFERENCES paliad.sequencing_rules(id);
|
||||
-- (rule_id stays as a transitional alias during the dual-write window;
|
||||
-- dropped at end of Slice B)
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 5. Submission drafts: add procedural_event_id FK alongside submission_code.
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id);
|
||||
-- (submission_code stays — it's the cosmetic anchor lawyers recognize
|
||||
-- in URLs and chat, and it doubles as the procedural_events.code value)
|
||||
```
|
||||
|
||||
### §4.2 What goes where (column-by-column map)
|
||||
|
||||
Every column on today's `paliad.deadline_rules` lands on exactly one of the three new tables:
|
||||
|
||||
| Today's `deadline_rules` column | Lands on | Notes |
|
||||
|---|---|---|
|
||||
| `id`, `created_at`, `updated_at` | `sequencing_rules` | The current row's identity becomes a sequencing-rule row. `procedural_events.id` is **new** — backfilled from `submission_code`. |
|
||||
| `submission_code` | `procedural_events.code` | Promoted up. Multi-row codes (10 in corpus, all `_archived_litigation.*`) collapse to one row on the new table; the 2-5 sequencing rows hang off it. |
|
||||
| `name`, `name_en`, `description` | `procedural_events` | Procedural-event identity. |
|
||||
| `primary_party` | `procedural_events.primary_party_default` AND `sequencing_rules.primary_party` | Both. The procedural event has a default party (claimant for Klage etc.); the sequencing rule can override per-jurisdiction (bilateral variants — e.g. `litigation.reply` claimant vs defendant become two sequencing rows with overridden party). |
|
||||
| `event_type` | `procedural_events.event_kind` | Hat 1, with rename to `event_kind` (term lock §2). |
|
||||
| `legal_source` | `legal_sources.citation` + FK from `procedural_events.legal_source_id` | The citation moves to its own row; the procedural event points at it. `pretty_de` / `pretty_en` materialize the existing `legalSourcePretty()` function output as columns (with the function retained as the migration source). |
|
||||
| `rule_code`, `alt_rule_code`, `rule_codes[]` | `sequencing_rules` | Short-form citation arrays stay on the sequencing rule — they're rule-specific. |
|
||||
| `proceeding_type_id`, `parent_id`, `trigger_event_id`, `spawn_proceeding_type_id`, `is_spawn`, `spawn_label`, `is_bilateral`, `is_court_set`, `combine_op` | `sequencing_rules` | Hat 3 (mechanics) — exact copies. |
|
||||
| `duration_value`, `duration_unit`, `timing`, `alt_duration_value`, `alt_duration_unit`, `anchor_alt` | `sequencing_rules` | Hat 3 (mechanics). |
|
||||
| `condition_expr` (jsonb) | `sequencing_rules` | Hat 3. The grammar from mig 091 stays. |
|
||||
| `priority`, `sequence_order` | `sequencing_rules` | Hat 3. |
|
||||
| `is_active`, `lifecycle_state`, `draft_of`, `published_at` | **BOTH** `procedural_events` AND `sequencing_rules` | A procedural event can be retired independently of any one of its sequencing variants. Backfill: copy onto both during dual-write; new rows go through the rule-editor service which writes both sides together. |
|
||||
| `concept_id` (FK to `deadline_concepts`) | `procedural_events.concept_id` | The concept layer (einstein 2026-05-08) attaches to the procedural event, not the sequencing rule. |
|
||||
| `deadline_notes`, `deadline_notes_en` | `sequencing_rules` | They're rule-specific notes ("filing the appeal in DE costs €X if you also did Y") — not procedural-event-wide. |
|
||||
|
||||
Three columns disappear:
|
||||
|
||||
- The semantically-overloaded part of `event_type` (renamed to `event_kind` and moved).
|
||||
- The "what is this thing" vs "how does it fire" name conflict — gone by construction.
|
||||
- Any column that exists only because of the conflation (none of today's columns are pure overhead — they all carry data — so the count stays at 38 across the three new tables).
|
||||
|
||||
### §4.3 Indexes + RLS
|
||||
|
||||
`paliad.can_see_project()` is the canonical RLS predicate (mig 055). None of the three new tables hold project-scoped data — they're firm-wide reference tables. RLS = none, same posture as today's `deadline_rules` (which is firm-wide and unrestricted at the row level; access control is via the `lifecycle_state='published'` filter in the read paths).
|
||||
|
||||
Indexes inherited from today:
|
||||
|
||||
- `paliad.legal_sources(citation)` — UNIQUE.
|
||||
- `paliad.procedural_events(code)` — UNIQUE.
|
||||
- `paliad.procedural_events(concept_id)` — for the deadline-concept join.
|
||||
- `paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state)` — primary read path for the calculator.
|
||||
- `paliad.sequencing_rules(parent_id)` — tree walk.
|
||||
- `paliad.sequencing_rules(trigger_event_id)` — event-rooted variant.
|
||||
|
||||
---
|
||||
|
||||
## §5 Migration plan (Slice B — when it ships, not in this task)
|
||||
|
||||
Phased dual-write, so the cutover is **never** a single instant where the wire format flips. m gets to roll back any one phase with a `git revert` + an `ALTER TABLE` if a phase misbehaves in prod.
|
||||
|
||||
### §5.1 Phase 1 — Additive (no down-time)
|
||||
|
||||
1. Create `procedural_events`, `sequencing_rules`, `legal_sources`.
|
||||
2. Backfill `legal_sources` from `DISTINCT legal_source` on `deadline_rules` (70 rows). Populate `pretty_de`/`pretty_en` by calling the existing `legalSourcePretty()` function in a one-shot SQL/Go shim during the migration. Verify `COUNT(DISTINCT legal_source FROM deadline_rules) = COUNT(*) FROM legal_sources`.
|
||||
3. Backfill `procedural_events` from `DISTINCT submission_code` on `deadline_rules WHERE submission_code IS NOT NULL`. Take `name`, `name_en`, `event_type → event_kind`, `primary_party`, `concept_id`, `description` from the lowest-`id` rule row for each code (tie-breaker: lowest `sequence_order`). Verify `COUNT(*) FROM procedural_events = COUNT(DISTINCT submission_code FROM deadline_rules WHERE submission_code IS NOT NULL)` (= 158).
|
||||
4. Backfill `sequencing_rules` 1:1 from `deadline_rules` (254 rows). FK `procedural_event_id` resolved by code lookup; sequencing-rule row inherits the `deadline_rules.id` (so existing `deadlines.rule_id` FKs continue to resolve via the new column for the dual-write window — see Phase 3).
|
||||
5. Add `paliad.deadlines.procedural_event_id` + `sequencing_rule_id` columns, backfill from `deadlines.rule_id` join.
|
||||
6. Add `paliad.submission_drafts.procedural_event_id`, backfill from `submission_code` join.
|
||||
|
||||
This phase ships behind a feature flag (or just behind unused code) — readers + writers stay on `deadline_rules`. No behavior change.
|
||||
|
||||
### §5.2 Phase 2 — Dual-write (no down-time)
|
||||
|
||||
7. Update `RuleEditorService` to write to both `deadline_rules` (legacy) and (`procedural_events`, `sequencing_rules`, `legal_sources`) on every Create/Update/Publish/Archive. Audit log writes one row per side.
|
||||
8. Update read paths to **read from the new tables**, falling back to `deadline_rules` if the new row is missing (defense-in-depth during backfill catch-up).
|
||||
9. Run for ≥ 1 week (m's call on length). Compare row counts and a hash digest of the union daily — if drift, surface.
|
||||
|
||||
### §5.3 Phase 3 — Cutover (no down-time, but reversible only via re-application of the dual-write)
|
||||
|
||||
10. Flip read paths to **only** the new tables (`SubmissionVarsService.loadPublishedRule`, `DeadlineRuleService.*`, `SubmissionService.list`, `ProjectionService`, `FristenrechnerCalc`, etc.).
|
||||
11. Stop writing to `deadline_rules`.
|
||||
12. `paliad.deadlines.rule_id` is kept as a no-op alias for one more week; new writes go to `procedural_event_id` + `sequencing_rule_id`.
|
||||
13. `submission_drafts.submission_code` is kept as the URL anchor; the FK `procedural_event_id` is the primary join key going forward.
|
||||
|
||||
### §5.4 Phase 4 — Drop legacy (downtime window, destructive)
|
||||
|
||||
14. `paliad.deadline_rules_pre_<slice-B-mig>` snapshot of the entire table.
|
||||
15. DROP TABLE paliad.deadline_rules (after CASCADE-safe FK rewires).
|
||||
16. DROP COLUMN paliad.deadlines.rule_id (keep `rule_code` + `custom_rule_text` as the human-readable denormalized columns — they're the safety net for orphaned deadlines per t-paliad-258).
|
||||
|
||||
m grants this destructive phase its own window (precedent: mig 091 on 2026-05-15). Until then, the legacy table sits dormant.
|
||||
|
||||
### §5.5 Migration tracker
|
||||
|
||||
- Slice B uses migration numbers 124 (Phase 1 — create tables + backfill) and onward — a 4-5 migration sequence, one per phase boundary, mirroring the Phase 2/3 slicing that shipped under t-paliad-195.
|
||||
- Each migration includes a `paliad.audit_reason = 'mig <n>: <slice-B-phase>'` set_config like mig 091 did, so the audit log captures the schema journey.
|
||||
|
||||
---
|
||||
|
||||
## §6 Service-layer impact
|
||||
|
||||
### §6.1 Slice A — prose-only changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `internal/services/submission_vars.go` | `addRuleVars` → also emit `procedural_event.code`, `procedural_event.name`, `procedural_event.name_de`, `procedural_event.name_en`, `procedural_event.legal_source`, `procedural_event.legal_source_pretty`, `procedural_event.primary_party`, `procedural_event.event_kind` (8 new keys, 1:1 with the 8 existing `rule.*` keys, same values). Rename docstrings + the package-level placeholder map comment ("`rule.*`" → "`procedural_event.*` (with legacy alias `rule.*`)"). |
|
||||
| `internal/services/deadline_rule_service.go` | Top-of-file comment + struct comment renames only. Method names stay (`DeadlineRuleService`, `GetByID`, etc.). |
|
||||
| `internal/services/rule_editor_service.go` | Same. |
|
||||
| `internal/services/projection_service.go`, `deadline_service.go`, `fristenrechner.go`, `submission_draft_service.go`, `event_trigger_service.go`, `event_deadline_service.go`, `proceeding_mapping.go`, `export_service.go` | No code changes. Comments mentioning "the rule"/"rules" stay accurate as long as the file is about sequencing — only services that surface the **identity** aspect of the rule (`submission_vars.go`) need a prose pass. |
|
||||
| `internal/handlers/submissions.go` | No SQL change. Type+comment renames: the catalog response type stays `submissionListEntry` (it's still a Schriftsatz-level list); doc comments speak of "procedural events whose kind is filing" instead of "rules of type filing". |
|
||||
| `internal/handlers/admin_rules.go` | URL path stays. JSON envelope stays. Page-render comments + log-line text shift to "procedural event". |
|
||||
| `internal/handlers/submission_drafts.go`, `deadlines.go`, `fristenrechner.go` | No service-layer change. |
|
||||
|
||||
### §6.2 Slice B — structural
|
||||
|
||||
Mostly load-bearing; not enumerated here in detail (out of scope per (R)=C). The shape:
|
||||
|
||||
- `RuleEditorService` splits into `ProceduralEventService` + `SequencingRuleService` + `LegalSourceService`. The Save / Publish / Archive flow on the editor coordinates all three.
|
||||
- `DeadlineRuleService.GetByID` becomes `SequencingRuleService.GetByID`; the `submission_code` lookup moves to `ProceduralEventService.GetByCode`.
|
||||
- `SubmissionVarsService.loadPublishedRule` becomes `loadPublishedProceduralEvent` and returns a triple (`event`, `defaultSequencingRule`, `legalSource`); the variable-bag emission consumes all three.
|
||||
- `ProjectionService` and the Fristenrechner calculator read from `sequencing_rules` (same column set, same logic — only the table name changes).
|
||||
- `SubmissionService.list` (handlers/submissions.go) filters `procedural_events.event_kind IN ('filing', 'reply')`.
|
||||
- Backfill orphans + audit triggers (mig 079 / 089) are re-pointed at `sequencing_rules` + a new `procedural_events_audit`.
|
||||
|
||||
---
|
||||
|
||||
## §7 UI / i18n impact
|
||||
|
||||
### §7.1 i18n keys (Slice A)
|
||||
|
||||
Existing keys (DE + EN) at `frontend/src/client/i18n.ts` lines ~2834-2920 and ~5800-5890 — surface area is *labels*, not *placeholders-in-Word*:
|
||||
|
||||
| Old key | New key (Slice A) | DE label | EN label |
|
||||
|---|---|---|---|
|
||||
| `admin.rules.list.title` | `admin.procedural_events.list.title` | "Verfahrensschritte verwalten — Paliad" | "Manage procedural events — Paliad" |
|
||||
| `admin.rules.list.heading` | `admin.procedural_events.list.heading` | "Verfahrensschritte verwalten" | "Manage procedural events" |
|
||||
| `admin.rules.list.subtitle` | `admin.procedural_events.list.subtitle` | "Verfahrensschritte anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived." | "Create, edit and publish procedural events. Lifecycle: draft → published → archived." |
|
||||
| `admin.rules.list.new` | `admin.procedural_events.list.new` | "+ Neuer Verfahrensschritt" | "+ New procedural event" |
|
||||
| `admin.rules.col.submission_code` | `admin.procedural_events.col.code` | "Code" (drop "/ Einreichung-Kennung" — the new heading already disambiguates) | "Code" |
|
||||
| `admin.rules.col.legal_citation` | `admin.procedural_events.col.legal_source` | "Rechtsgrundlage" | "Legal source" |
|
||||
| `admin.rules.col.name` | `admin.procedural_events.col.name` | "Bezeichnung" | "Name" |
|
||||
| `admin.rules.col.proceeding` | `admin.procedural_events.col.proceeding` | "Verfahrenstyp" | "Proceeding" |
|
||||
| `admin.rules.col.priority` | `admin.procedural_events.col.priority` | "Priorität" | "Priority" |
|
||||
| `admin.rules.col.lifecycle` | `admin.procedural_events.col.lifecycle` | "Lifecycle" | "Lifecycle" |
|
||||
| `admin.rules.col.modified` | `admin.procedural_events.col.modified` | "Zuletzt geändert" | "Last modified" |
|
||||
| `admin.rules.edit.title` | `admin.procedural_events.edit.title` | "Verfahrensschritt bearbeiten — Paliad" | "Edit procedural event — Paliad" |
|
||||
| `admin.rules.edit.heading.loading` | `admin.procedural_events.edit.heading.loading` | "Verfahrensschritt laden…" | "Loading procedural event…" |
|
||||
| `admin.rules.edit.breadcrumb` | `admin.procedural_events.edit.breadcrumb` | "← Verfahrensschritte verwalten" | "← Manage procedural events" |
|
||||
| `admin.rules.edit.field.submission_code` | `admin.procedural_events.edit.field.code` | "Code (Schriftsatz-Code / Einreichung-Kennung)" — keep the parenthetical so lawyers familiar with the old label know what they're looking at. | "Code (submission / procedural-event identifier)" |
|
||||
| `admin.rules.edit.field.rule_code` | `admin.procedural_events.edit.field.short_citation` | "Rechtsgrundlage (Kurzform)" | "Legal source (short form)" |
|
||||
| `admin.rules.edit.field.legal_source` | `admin.procedural_events.edit.field.legal_source` | "Rechtsgrundlage (Langform)" | "Legal source (long form)" |
|
||||
| `admin.rules.edit.field.name` | `admin.procedural_events.edit.field.name` | "Bezeichnung (DE)" | "Name (DE)" |
|
||||
| `admin.rules.edit.field.name_en` | `admin.procedural_events.edit.field.name_en` | "Bezeichnung (EN)" | "Name (EN)" |
|
||||
| `admin.rules.edit.field.proceeding` | `admin.procedural_events.edit.field.proceeding` | "Verfahrenstyp" | "Proceeding type" |
|
||||
| `admin.rules.edit.field.trigger` | `admin.procedural_events.edit.field.trigger` | "Trigger-Ereignis" | "Trigger event" |
|
||||
| `admin.rules.edit.field.parent` | `admin.procedural_events.edit.field.parent` | "Übergeordneter Verfahrensschritt (UUID)" | "Parent procedural event (UUID)" |
|
||||
| `admin.rules.edit.field.concept` | `admin.procedural_events.edit.field.concept` | "Konzept (UUID)" | "Concept (UUID)" |
|
||||
| `admin.rules.edit.field.sequence_order` | `admin.procedural_events.edit.field.sequence_order` | "Reihenfolge" | "Order" |
|
||||
| `admin.rules.edit.field.duration_value` | `admin.procedural_events.edit.field.duration_value` | "Dauer" | "Duration" |
|
||||
| `admin.rules.edit.field.primary_party` | `admin.procedural_events.edit.field.primary_party` | "Partei (typisch)" | "Primary party" |
|
||||
| `admin.rules.edit.field.event_type` | `admin.procedural_events.edit.field.event_kind` | "Art des Verfahrensschritts" | "Procedural-event kind" |
|
||||
| `admin.rules.edit.field.description` | `admin.procedural_events.edit.field.description` | "Beschreibung" | "Description" |
|
||||
|
||||
**Legacy keys retained as aliases** so any existing translation imports or external integrations keep working — old keys point at the same DE/EN values during a deprecation window of one full Slice B cycle.
|
||||
|
||||
### §7.2 Variable-bag placeholders (Slice A)
|
||||
|
||||
`frontend/src/client/submission-draft.ts:155-185` — the catalog of placeholders the lawyer sees in the sidebar:
|
||||
|
||||
| Old placeholder (kept as legacy alias) | New canonical placeholder | DE label | EN label |
|
||||
|---|---|---|---|
|
||||
| `{{rule.submission_code}}` | `{{procedural_event.code}}` | "Code (Verfahrensschritt)" | "Code (procedural event)" |
|
||||
| `{{rule.name}}` | `{{procedural_event.name}}` | "Bezeichnung" | "Name" |
|
||||
| `{{rule.name_de}}` | `{{procedural_event.name_de}}` | "Bezeichnung (DE)" | "Name (DE)" |
|
||||
| `{{rule.name_en}}` | `{{procedural_event.name_en}}` | "Bezeichnung (EN)" | "Name (EN)" |
|
||||
| `{{rule.legal_source}}` | `{{procedural_event.legal_source}}` | "Rechtsgrundlage (Code)" | "Legal source (code)" |
|
||||
| `{{rule.legal_source_pretty}}` | `{{procedural_event.legal_source_pretty}}` | "Rechtsgrundlage" | "Legal source" |
|
||||
| `{{rule.primary_party}}` | `{{procedural_event.primary_party}}` | "Partei (typisch)" | "Primary party" |
|
||||
| `{{rule.event_type}}` | `{{procedural_event.event_kind}}` | "Art des Verfahrensschritts" | "Procedural-event kind" |
|
||||
|
||||
The catalog renders the canonical name in the "copy-this-placeholder" button. The variable bag (`submission_vars.go`) emits both names with identical values, so any Word template the lawyer already has continues to work; new templates are encouraged to use the canonical name.
|
||||
|
||||
### §7.3 Admin rule-editor form (Slice A)
|
||||
|
||||
`frontend/src/admin-rules-edit.tsx:74-110` — i18n key rebinds + heading text update. The DOM `id` attributes (`f-submission-code`, `f-rule-code`, `f-legal-source`, …) stay — they're internal, the rename here is cosmetic, the form still POSTs the same JSON envelope (Slice A doesn't touch the API). The fieldset `legend` for the "Identität" section changes to "Verfahrensschritt-Identität" (DE) / "Procedural-event identity" (EN). The "Verfahren & Trigger" section heading stays — that section is about sequencing, and Slice A doesn't rename sequencing-level labels (those are Slice B).
|
||||
|
||||
### §7.4 Project-detail Schriftsätze tab + dashboard
|
||||
|
||||
`frontend/src/client/submissions.ts`, `submissions-index.ts`: no surface-level label change in Slice A. The Schriftsätze tab continues to show Schriftsätze (the lawyer's preferred term for *filings specifically*). The tab is a filtered view onto procedural events of kind `filing`/`reply` — that distinction surfaces only in admin contexts.
|
||||
|
||||
### §7.5 Help text + docs
|
||||
|
||||
A short addition to the in-app help: "What is a procedural event?" — one-paragraph definition explaining the umbrella term, with examples (Klage, Klageerwiderung, mündliche Verhandlung, Endurteil). Stored in `frontend/src/client/i18n.ts` under `help.procedural_events.intro`. Out of scope for the URL/router changes — added as static copy where it fits naturally.
|
||||
|
||||
---
|
||||
|
||||
## §8 Slice plan
|
||||
|
||||
### §8.1 Slice A (this design's downstream task)
|
||||
|
||||
**Scope:** prose-only rename per §3 ("renamed in Slice A" list).
|
||||
|
||||
**Mechanics:**
|
||||
|
||||
1. Add 8 new placeholder keys to the variable bag in `submission_vars.go` (1:1 with the existing 8 `rule.*` keys). Keep the legacy keys.
|
||||
2. Update `frontend/src/client/submission-draft.ts` placeholder catalog labels.
|
||||
3. Rebind admin i18n keys per §7.1 (with legacy keys retained).
|
||||
4. Update admin page titles + section headings.
|
||||
5. Update Go struct comments + service docstrings in `submission_vars.go`, `deadline_rule_service.go`, `rule_editor_service.go`, `submission_draft_service.go`, `submissions.go` handler. No code-flow change.
|
||||
6. Update `internal/handlers/submissions.go` doc comments.
|
||||
7. Add a short `docs/glossary.md` entry (or extend an existing one) for "procedural event" / "Verfahrensschritt" — single source of truth for the term.
|
||||
8. Tests: rename strings in existing test fixtures + add a regression test that the variable bag emits **both** the legacy `rule.X` and the canonical `procedural_event.X` keys with the same value. (Critical — without this test, a future commit could drop the legacy alias and silently break user templates.)
|
||||
9. Manual smoke: open the admin rule editor, confirm the new title appears. Open the submission-draft editor, confirm both `{{rule.X}}` and `{{procedural_event.X}}` placeholders are listed (with canonical first). Generate a `.docx` from a project using each placeholder name — both render identically.
|
||||
|
||||
**Risk:** very low. No DB change, no API change, fully reversible.
|
||||
|
||||
**No hours estimate per project CLAUDE.md.**
|
||||
|
||||
### §8.2 Slice B (separate mai task — designed here, hired later)
|
||||
|
||||
**Scope:** structural rework per §4 + §5.
|
||||
|
||||
**Mechanics:** Phase 1 → Phase 4 per §5.
|
||||
|
||||
**Prerequisite:** m greenlights via a new mai task with this doc + §11's open items addressed. **Not part of Slice A.**
|
||||
|
||||
**Sub-slices (suggested for Slice B's own task):**
|
||||
|
||||
- **B.0** — Re-validate this doc's premises against live DB (numbers shift over weeks).
|
||||
- **B.1** — Phase 1 additive migration + backfill (mig 124).
|
||||
- **B.2** — Phase 2 dual-write + read-fallback.
|
||||
- **B.3** — Phase 3 read cutover (no schema change).
|
||||
- **B.4** — Phase 4 destructive drop (downtime window).
|
||||
- **B.5** — Rename Go types `DeadlineRule` → `SequencingRule` + `ProceduralEvent`; rename JSON API envelope keys with a deprecation header. Independent of B.4.
|
||||
- **B.6** — Rename admin URL paths `/admin/rules` → `/admin/procedural-events` with redirects. Optional / low-priority.
|
||||
|
||||
### §8.3 Why splitting is the right call
|
||||
|
||||
The conflation is real, but the *fix* for the most-painful surface (the editor sidebar) is independent of the table restructure. Splitting lets m ship the fix this week, see whether the prose change alone resolves enough of the cognitive friction, and then decide whether the structural rework is still worth the migration cost. If after Slice A m says "this reads fine now, B isn't worth it", that's a legitimate outcome — Slice B is a *good* refactor, not an *urgent* one.
|
||||
|
||||
---
|
||||
|
||||
## §9 Risk assessment
|
||||
|
||||
### §9.1 Slice A risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Lawyer's existing Word template has `{{rule.submission_code}}` baked in; a future commit drops the legacy alias and breaks templates. | Low (Slice A keeps the alias) | High if it happens | Regression test (§8.1 step 8) asserts both keys emit. Add an audit-log line on every variable-bag call recording which keys were consumed by the merge engine — gives a 30-day window of evidence before we'd consider deprecating the legacy keys. |
|
||||
| i18n key rename misses a binding, leaving an English string visible to a DE user. | Medium | Low | The build pipeline (`bun test` / `bun build`) fails on missing i18n keys in `i18n-keys.ts`. Add the new keys to the type union; leave the old keys in the union with `@deprecated` JSDoc. |
|
||||
| Renamed admin page heading confuses returning admin users ("Where did 'Regeln verwalten' go?"). | Medium | Low | One-time changelog entry; the URL `/admin/rules` is unchanged so muscle memory still lands them on the page. Internal users only (whitelist-gated). |
|
||||
| Slice A reads as "we're done" and Slice B never ships. | Medium | Medium (the model stays wrong) | This doc files the Slice B design as a separate task entry **before** Slice A merges, so the to-do is visible. m's call whether to schedule it. |
|
||||
|
||||
### §9.2 Slice B risks (deferred; recorded for the future task)
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Backfill collapses too eagerly: 10 multi-row submission_codes today are `_archived_litigation.*` — confirm they should collapse into one procedural event with 2-5 sequencing variants, vs. each row becoming its own procedural event. | The `_archived_litigation.*` codes are archived per their prefix — collapse is safe. **Decision-flag for Slice B's own design pass.** |
|
||||
| `deadline_concepts` linkage (125 of 254 rules link to a concept) — does the concept attach to the procedural event or the sequencing rule? §4.2 says procedural event; verify this is right when re-validating premises in B.0. | Read-path audit: every consumer that joins `deadline_rules.concept_id` (rule_editor, projection, fristenrechner) operates on the rule-level today. Reconfirm none of them depend on per-jurisdiction concept-attachment. |
|
||||
| The dual-write window introduces drift if a write hits one side and fails on the other. | Atomicity via single transaction per write in `RuleEditorService`. Daily drift-check job (one SELECT pair, alert if mismatched). |
|
||||
| `paliad.deadlines.rule_id` (1 live row, but more in future) — backfilling `procedural_event_id` + `sequencing_rule_id` must not orphan the live row. | The 1 live row joins cleanly. Backfill in the same migration that adds the new columns. |
|
||||
| The submission-draft `submission_code` text key — what if two `procedural_events.code` values collide post-rename (e.g. a draft was saved against a code that we then archive)? | Slice B Phase 1 enforces `procedural_events.code UNIQUE`; the backfill verifies no collision on the existing 158 distinct values. Drafts with codes that no longer exist as published procedural events are handled by the existing `submission_drafts.submission_code` text fallback (no FK enforcement). |
|
||||
| Slice B's API-key rename (`submission_code` → `code` in JSON) breaks external integrations. | None exist today (paliad is internal-only); add a one-Slice deprecation header (`X-Deprecated-Field: submission_code`) before flipping. |
|
||||
| **Coordination risk with future fristen/calculator work.** The Fristenrechner calculator reads `deadline_rules` directly today. Slice B Phase 2's read-fallback handles this, but a parallel calculator feature in flight could land changes that need re-merging. | B.0's job: confirm no in-flight task touches `deadline_rules` table shape before scheduling. |
|
||||
|
||||
### §9.3 What rolls Slice A back
|
||||
|
||||
`git revert <slice-a-commit>` + reload. Zero data side-effects (no DB writes). 30 seconds.
|
||||
|
||||
### §9.4 What rolls Slice B back
|
||||
|
||||
Per phase — Phases 1-3 reversible via reverting code + `DROP TABLE`. Phase 4 reversible only by restoring `deadline_rules` from the `_pre_<n>` snapshot taken at the start of Phase 4. Same posture as mig 091 — m's call when to commit to this point.
|
||||
|
||||
---
|
||||
|
||||
## §10 Out of scope
|
||||
|
||||
- **Renaming `paliad.events`** (the audit feed). Distinct table, distinct concept. The umbrella-term lock (§2) deliberately uses "procedural event" not "event" to avoid colliding with it.
|
||||
- **Renaming `paliad.deadline_concepts`** to align with the procedural-event taxonomy. The concept layer is the cross-proceeding semantic bridge (einstein 2026-05-08 Q5); the relationship "procedural event has-a concept" already reads cleanly under the new term.
|
||||
- **Per-jurisdiction variations of the same procedural event** (issue body's explicit out-of-scope). The 10 multi-row codes in the corpus today stay multi-row.
|
||||
- **Multi-tenant / cross-firm sharing of procedural events** — paliad is single-tenant per deploy via `FIRM_NAME`; cross-firm is a separate design.
|
||||
- **einstein's `proceeding_event_edges` graph proposal.** That design proposed a graph of typed event-types connected by typed edges. This design's procedural-events / sequencing-rules split is **compatible** with that graph shape (the edges would attach to procedural-event-IDs rather than sequencing-rule-IDs), but the graph layer is a Slice C, not Slice B. Flagged for future continuity, not part of either slice here.
|
||||
- **Renaming Go type `DeadlineRule` to `SequencingRule` or `ProceduralEvent` in Slice A.** Slice A is prose; Slice B's B.5 sub-slice handles the type rename. Coupling them costs the reversibility property.
|
||||
- **API-envelope key renames** (`submission_code` → `code`, `event_type` → `event_kind` on the wire). Slice B only.
|
||||
- **URL path renames** (`/admin/rules` → `/admin/procedural-events`). Slice B.6, optional.
|
||||
- **Touching `paliad.trigger_events`** beyond keeping the FK path open (today `deadline_rules.trigger_event_id`; Slice B maps to `sequencing_rules.trigger_event_id`).
|
||||
- **Touching `paliad.event_categories` / Pathway-B navigation.** Independent layer.
|
||||
|
||||
---
|
||||
|
||||
## §11 Open questions for m (escalated via `mai instruct head` per project CLAUDE.md)
|
||||
|
||||
Per project CLAUDE.md "Head answers questions — NO AskUserQuestion" rule, these are surfaced to head, not picked-as-chip with the user.
|
||||
|
||||
| ID | Question | Inventor recommendation | Material to head? |
|
||||
|---|---|---|---|
|
||||
| **Q1** | Scope: cosmetic-only (A) · full restructure (B) · cosmetic now + B as planned follow-up (C). | **(R) = C** | Yes — material. Defines whether Slice B is hired today or filed as a future task. |
|
||||
| **Q2** | Umbrella term: "procedural event" (DE: Verfahrensschritt) · "submission" (filings only) · "Verfahrensereignis" · other. | **(R) = procedural event / Verfahrensschritt** | Yes — material. The term ripples through every label in §7. Inventor's pick is the canonical choice; head can override with a single message. |
|
||||
| **Q3** | Slice B migration shape: confirmed (§4 + §5) or rescope. | **(R) = §4 + §5 as written, decision deferred until Slice B is hired** | No — informational. Locked when Slice B's own design pass runs. |
|
||||
| **Q4** | Effect on Schriftsätze surface: filter `procedural_events.event_kind IN ('filing', 'reply')` is acceptable replacement for today's `event_type='filing'`. | **(R) = yes, semantically equivalent under Slice B; no behavior change to lawyer.** | No — informational. |
|
||||
| **Q5** | Are the 10 archived multi-row submission_codes (`_archived_litigation.*`) safe to collapse into single procedural events with multiple sequencing variants in Slice B? | **(R) = yes, prefix indicates archival; collapse-safe.** | No — informational, defers to Slice B. |
|
||||
| **Q6** | `concept_id` attaches to procedural event, not sequencing rule. Confirmable? | **(R) = yes, per §4.2 (one concept per identity, not per jurisdiction).** | No — informational, defers to Slice B. |
|
||||
| **Q7** | Keep the legacy `{{rule.X}}` placeholder aliases **forever**, or set a deprecation horizon (e.g. 1 year)? | **(R) = forever, with `@deprecated` annotation in the catalog. Removing them risks breaking lawyer-authored templates that paliad doesn't see.** | Yes — material to Slice A's contract (test in §8.1 step 8 asserts both keys emit). |
|
||||
| **Q8** | Document side: update m/paliad#93 issue body to fix the `deadlines.deadline_rule_id` → `deadlines.rule_id` typo (§1 last paragraph). | **(R) = yes, head's call when to edit.** | No — informational, doc hygiene. |
|
||||
| **Q9** | After Slice A ships, do we file Slice B as a new mai task **now** (so it's visible), or wait for m to ask? | **(R) = file now, status:planning, no owner. Visibility >> deferred surprise.** | Yes — material to "does the model stay wrong forever". |
|
||||
|
||||
Q1, Q2, Q7, Q9 are the four head needs to answer before the coder shift. Q3-Q6, Q8 defer cleanly.
|
||||
|
||||
---
|
||||
|
||||
## §12 Appendix — verbatim m quote
|
||||
|
||||
From m's report 2026-05-25 15:02 (paliad#93 body):
|
||||
|
||||
> This shows how our 'rule' table system may need a revision?! It feels like we are rule based not submission based. But here we have a specific submission that is connected to a rule (as in: legal norm). And of course also connected to other 'procedural events' (which is a good term for it all) by rules how they are sequenced. But it makes it sound weird in the fields...
|
||||
|
||||
The design above takes m's three-way split — *the procedural event* / *the legal norm* / *the rule by which they are sequenced* — at face value and turns it into a column-level map (§4.2), a slice plan (§8), and a deprecation contract (§9.1).
|
||||
|
||||
---
|
||||
|
||||
*End of design.*
|
||||
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,7 +101,7 @@ 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.rules.col.legal_citation">Rechtsgrundlage</th>
|
||||
<th data-i18n="admin.rules.col.name">Name</th>
|
||||
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</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
|
||||
@@ -51,7 +51,10 @@ interface Rule {
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
// `name` is the German display name on the wire; the Go `ProceedingType`
|
||||
// model serialises `db:"name"` as JSON key `name`. Don't reach for
|
||||
// `name_de` — that field does not exist in this payload (m/paliad#113).
|
||||
name: string;
|
||||
name_en: string;
|
||||
}
|
||||
|
||||
@@ -103,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]) : "";
|
||||
}
|
||||
@@ -169,13 +172,14 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
|
||||
for (const pt of list) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(pt.id);
|
||||
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
|
||||
const name = getLang() === "en" ? pt.name_en : pt.name;
|
||||
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -194,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[] : [];
|
||||
@@ -504,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),
|
||||
@@ -526,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 }),
|
||||
@@ -548,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 }),
|
||||
@@ -561,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`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
@@ -587,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,10 +1,10 @@
|
||||
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.
|
||||
|
||||
@@ -29,7 +29,11 @@ interface Rule {
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
// `name` is the German display name on the wire; the Go `ProceedingType`
|
||||
// model serialises `db:"name"` as JSON key `name` (the schema treats DE
|
||||
// as primary). EN lives in `name_en`. Don't reach for `name_de` — that
|
||||
// field does not exist in this payload (cf. m/paliad#113).
|
||||
name: string;
|
||||
name_en: string;
|
||||
category: string;
|
||||
}
|
||||
@@ -125,7 +129,12 @@ function proceedingLabel(id: number | null | undefined): string {
|
||||
if (id == null) return "—";
|
||||
const pt = proceedings.find((p) => p.id === id);
|
||||
if (!pt) return `#${id}`;
|
||||
const name = getLang() === "en" ? pt.name_en : pt.name_de;
|
||||
const name = getLang() === "en" ? pt.name_en : pt.name;
|
||||
// Guard against a proceeding row that's missing the active-language
|
||||
// name (or against a stale field-name mismatch slipping back in).
|
||||
// Show the code on its own rather than "code · undefined" — that
|
||||
// literal string is the smell that surfaced this bug (m/paliad#113).
|
||||
if (!name) return pt.code;
|
||||
return `${pt.code} · ${name}`;
|
||||
}
|
||||
|
||||
@@ -136,7 +145,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> {
|
||||
@@ -153,7 +162,8 @@ async function loadProceedings(): Promise<void> {
|
||||
for (const pt of proceedings) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(pt.id);
|
||||
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
|
||||
const name = getLang() === "en" ? pt.name_en : pt.name;
|
||||
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
@@ -238,7 +248,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`;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -382,7 +392,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({
|
||||
@@ -406,7 +416,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();
|
||||
}
|
||||
289
frontend/src/client/date-range-picker-pure.test.ts
Normal file
289
frontend/src/client/date-range-picker-pure.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
// Unit tests for the date-range picker's pure helpers (t-paliad-248).
|
||||
// Run with `bun test`.
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import {
|
||||
horizonBounds,
|
||||
isValidHorizon,
|
||||
isValidISODate,
|
||||
validateCustomRange,
|
||||
parseURL,
|
||||
serializeURL,
|
||||
isDefault,
|
||||
ALL_HORIZONS,
|
||||
PAST_HORIZONS,
|
||||
NEXT_HORIZONS,
|
||||
type TimeHorizon,
|
||||
type TimeSpec,
|
||||
} from "./date-range-picker-pure";
|
||||
|
||||
// Anchor the clock so day-arithmetic assertions don't drift with the
|
||||
// wall clock. 2026-05-25 00:00 UTC matches the Go-side bounds test.
|
||||
const NOW = new Date(Date.UTC(2026, 4, 25));
|
||||
const DAY = (offsetDays: number): Date =>
|
||||
new Date(NOW.getTime() + offsetDays * 86_400_000);
|
||||
|
||||
describe("ALL_HORIZONS / PAST / NEXT registries", () => {
|
||||
test("registries sum to a known total without overlap", () => {
|
||||
// 6 past + 6 next + any + custom = 14 fan chips (custom is the
|
||||
// trailing entry in ALL_HORIZONS; `all` is intentionally absent —
|
||||
// surfaces don't render the legacy bidirectional-unbounded chip).
|
||||
expect(ALL_HORIZONS.length).toBe(14);
|
||||
expect(PAST_HORIZONS.length).toBe(6);
|
||||
expect(NEXT_HORIZONS.length).toBe(6);
|
||||
expect(new Set(ALL_HORIZONS).size).toBe(ALL_HORIZONS.length);
|
||||
});
|
||||
|
||||
test("PAST_HORIZONS are all past_*", () => {
|
||||
for (const h of PAST_HORIZONS) {
|
||||
expect(h.startsWith("past_")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("NEXT_HORIZONS are all next_*", () => {
|
||||
for (const h of NEXT_HORIZONS) {
|
||||
expect(h.startsWith("next_")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("ALL_HORIZONS ends with custom and contains any in the middle", () => {
|
||||
expect(ALL_HORIZONS.at(-1)).toBe("custom");
|
||||
expect(ALL_HORIZONS).toContain("any");
|
||||
});
|
||||
});
|
||||
|
||||
describe("horizonBounds", () => {
|
||||
test("future fan: bounds anchor at today, extend forward", () => {
|
||||
expect(horizonBounds("next_1d", NOW)).toEqual({ from: DAY(0), to: DAY(1) });
|
||||
expect(horizonBounds("next_7d", NOW)).toEqual({ from: DAY(0), to: DAY(7) });
|
||||
expect(horizonBounds("next_14d", NOW)).toEqual({ from: DAY(0), to: DAY(14) });
|
||||
expect(horizonBounds("next_30d", NOW)).toEqual({ from: DAY(0), to: DAY(30) });
|
||||
expect(horizonBounds("next_90d", NOW)).toEqual({ from: DAY(0), to: DAY(90) });
|
||||
});
|
||||
|
||||
test("past fan: bounds extend back, upper bound is tomorrow (exclusive end-of-today)", () => {
|
||||
expect(horizonBounds("past_1d", NOW)).toEqual({ from: DAY(-1), to: DAY(1) });
|
||||
expect(horizonBounds("past_7d", NOW)).toEqual({ from: DAY(-7), to: DAY(1) });
|
||||
expect(horizonBounds("past_14d", NOW)).toEqual({ from: DAY(-14), to: DAY(1) });
|
||||
expect(horizonBounds("past_30d", NOW)).toEqual({ from: DAY(-30), to: DAY(1) });
|
||||
expect(horizonBounds("past_90d", NOW)).toEqual({ from: DAY(-90), to: DAY(1) });
|
||||
});
|
||||
|
||||
test("next_all is one-sided: from=today, to undefined", () => {
|
||||
const b = horizonBounds("next_all", NOW);
|
||||
expect(b.from).toEqual(DAY(0));
|
||||
expect(b.to).toBeUndefined();
|
||||
});
|
||||
|
||||
test("past_all is one-sided: from undefined, to=tomorrow", () => {
|
||||
const b = horizonBounds("past_all", NOW);
|
||||
expect(b.from).toBeUndefined();
|
||||
expect(b.to).toEqual(DAY(1));
|
||||
});
|
||||
|
||||
test("any / all / custom: both bounds undefined", () => {
|
||||
expect(horizonBounds("any", NOW)).toEqual({});
|
||||
expect(horizonBounds("all", NOW)).toEqual({});
|
||||
expect(horizonBounds("custom", NOW)).toEqual({});
|
||||
});
|
||||
|
||||
test("bounds anchor on UTC start-of-day regardless of input clock time", () => {
|
||||
const nowAfternoon = new Date(Date.UTC(2026, 4, 25, 14, 37, 0));
|
||||
const nowMidnight = new Date(Date.UTC(2026, 4, 25, 0, 0, 0));
|
||||
expect(horizonBounds("past_7d", nowAfternoon)).toEqual(horizonBounds("past_7d", nowMidnight));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidHorizon", () => {
|
||||
test("accepts every entry in ALL_HORIZONS plus 'all' (legacy)", () => {
|
||||
for (const h of ALL_HORIZONS) {
|
||||
expect(isValidHorizon(h)).toBe(true);
|
||||
}
|
||||
expect(isValidHorizon("all")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects unknown strings, numbers, undefined, null", () => {
|
||||
expect(isValidHorizon("next_5d")).toBe(false);
|
||||
expect(isValidHorizon("past_100d")).toBe(false);
|
||||
expect(isValidHorizon("")).toBe(false);
|
||||
expect(isValidHorizon(7)).toBe(false);
|
||||
expect(isValidHorizon(undefined)).toBe(false);
|
||||
expect(isValidHorizon(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidISODate", () => {
|
||||
test("accepts valid YYYY-MM-DD", () => {
|
||||
expect(isValidISODate("2026-05-25")).toBe(true);
|
||||
expect(isValidISODate("2026-12-31")).toBe(true);
|
||||
expect(isValidISODate("2024-02-29")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects shape mismatches", () => {
|
||||
expect(isValidISODate("2026/05/25")).toBe(false);
|
||||
expect(isValidISODate("25.05.2026")).toBe(false);
|
||||
expect(isValidISODate("2026-5-25")).toBe(false);
|
||||
expect(isValidISODate("")).toBe(false);
|
||||
expect(isValidISODate(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects calendar-impossible dates (Date.parse silently rolls over)", () => {
|
||||
expect(isValidISODate("2026-02-30")).toBe(false);
|
||||
expect(isValidISODate("2026-13-01")).toBe(false);
|
||||
expect(isValidISODate("2026-04-31")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects 2025-02-29 (non-leap February)", () => {
|
||||
expect(isValidISODate("2025-02-29")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCustomRange", () => {
|
||||
test("requires both bounds present and valid", () => {
|
||||
expect(validateCustomRange(undefined, undefined)).toBe("date_range.custom.invalid_missing");
|
||||
expect(validateCustomRange("2026-05-25", undefined)).toBe("date_range.custom.invalid_missing");
|
||||
expect(validateCustomRange(undefined, "2026-05-25")).toBe("date_range.custom.invalid_missing");
|
||||
});
|
||||
|
||||
test("rejects malformed dates with format error", () => {
|
||||
expect(validateCustomRange("bogus", "2026-05-25")).toBe("date_range.custom.invalid_format");
|
||||
expect(validateCustomRange("2026-13-01", "2026-12-31")).toBe("date_range.custom.invalid_format");
|
||||
});
|
||||
|
||||
test("rejects to <= from with invalid error", () => {
|
||||
expect(validateCustomRange("2026-05-25", "2026-05-25")).toBe("date_range.custom.invalid");
|
||||
expect(validateCustomRange("2026-05-25", "2026-05-24")).toBe("date_range.custom.invalid");
|
||||
});
|
||||
|
||||
test("accepts strictly-ordered valid pair", () => {
|
||||
expect(validateCustomRange("2026-05-25", "2026-05-26")).toBeNull();
|
||||
expect(validateCustomRange("2026-01-01", "2026-12-31")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseURL", () => {
|
||||
test("missing horizon yields contract default", () => {
|
||||
expect(parseURL(new URLSearchParams(""))).toEqual({ horizon: "any" });
|
||||
expect(parseURL(new URLSearchParams(""), { default: "next_30d" })).toEqual({ horizon: "next_30d" });
|
||||
});
|
||||
|
||||
test("unknown horizon falls back to default, doesn't throw", () => {
|
||||
expect(parseURL(new URLSearchParams("horizon=mystery"), { default: "next_7d" }))
|
||||
.toEqual({ horizon: "next_7d" });
|
||||
});
|
||||
|
||||
test("every fan horizon round-trips on a fresh URLSearchParams", () => {
|
||||
for (const h of ALL_HORIZONS) {
|
||||
if (h === "custom") continue;
|
||||
const params = new URLSearchParams(`horizon=${h}`);
|
||||
expect(parseURL(params)).toEqual({ horizon: h });
|
||||
}
|
||||
});
|
||||
|
||||
test("custom horizon reads from+to", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30");
|
||||
expect(parseURL(params)).toEqual({
|
||||
horizon: "custom",
|
||||
from: "2026-03-15",
|
||||
to: "2026-04-30",
|
||||
});
|
||||
});
|
||||
|
||||
test("custom with malformed dates falls back to default rather than half-state", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-99-99&horizon_to=2026-04-30");
|
||||
expect(parseURL(params, { default: "next_30d" })).toEqual({ horizon: "next_30d" });
|
||||
});
|
||||
|
||||
test("custom with from>=to falls back", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-05-25&horizon_to=2026-05-25");
|
||||
expect(parseURL(params)).toEqual({ horizon: "any" });
|
||||
});
|
||||
|
||||
test("custom URL key override", () => {
|
||||
const params = new URLSearchParams("range=past_30d");
|
||||
expect(parseURL(params, { key: "range" })).toEqual({ horizon: "past_30d" });
|
||||
expect(parseURL(params)).toEqual({ horizon: "any" }); // default `horizon` key absent
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeURL", () => {
|
||||
test("default horizon is omitted (canonical URL stays short)", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "any" }, params);
|
||||
expect(params.toString()).toBe("");
|
||||
});
|
||||
|
||||
test("explicit default param removed when value matches default", () => {
|
||||
const params = new URLSearchParams("horizon=past_30d&other=keep");
|
||||
serializeURL({ horizon: "past_30d" }, params, { default: "past_30d" });
|
||||
expect(params.toString()).toBe("other=keep");
|
||||
});
|
||||
|
||||
test("non-default horizon is written", () => {
|
||||
const params = new URLSearchParams("other=keep");
|
||||
serializeURL({ horizon: "next_7d" }, params);
|
||||
expect(params.toString()).toBe("other=keep&horizon=next_7d");
|
||||
});
|
||||
|
||||
test("custom writes horizon+from+to", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "custom", from: "2026-03-15", to: "2026-04-30" }, params);
|
||||
expect(params.toString()).toBe("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30");
|
||||
});
|
||||
|
||||
test("custom partial bounds: from/to are written individually", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "custom", from: "2026-03-15" }, params);
|
||||
expect(params.toString()).toBe("horizon=custom&horizon_from=2026-03-15");
|
||||
});
|
||||
|
||||
test("stale params cleared on re-serialize", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30&other=keep");
|
||||
serializeURL({ horizon: "past_30d" }, params);
|
||||
expect(params.toString()).toBe("other=keep&horizon=past_30d");
|
||||
// Stale from/to must be gone.
|
||||
expect(params.has("horizon_from")).toBe(false);
|
||||
expect(params.has("horizon_to")).toBe(false);
|
||||
});
|
||||
|
||||
test("key override propagates to from/to", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "custom", from: "2026-03-15", to: "2026-04-30" }, params, { key: "range" });
|
||||
expect(params.toString()).toBe("range=custom&range_from=2026-03-15&range_to=2026-04-30");
|
||||
});
|
||||
|
||||
test("URL round-trips through parse → serialize → parse", () => {
|
||||
const specs: TimeSpec[] = [
|
||||
{ horizon: "any" },
|
||||
{ horizon: "next_7d" },
|
||||
{ horizon: "past_all" },
|
||||
{ horizon: "next_all" },
|
||||
{ horizon: "custom", from: "2026-03-15", to: "2026-04-30" },
|
||||
];
|
||||
for (const spec of specs) {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL(spec, params);
|
||||
expect(parseURL(params)).toEqual(spec);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDefault", () => {
|
||||
test("true when horizon matches default exactly", () => {
|
||||
expect(isDefault({ horizon: "any" }, "any")).toBe(true);
|
||||
expect(isDefault({ horizon: "next_30d" }, "next_30d")).toBe(true);
|
||||
});
|
||||
|
||||
test("false when horizon differs", () => {
|
||||
expect(isDefault({ horizon: "past_7d" }, "any")).toBe(false);
|
||||
expect(isDefault({ horizon: "next_30d" }, "next_7d")).toBe(false);
|
||||
});
|
||||
|
||||
test("custom is never default — even when bounds match", () => {
|
||||
// No surface treats "custom" as the natural default, so any custom
|
||||
// selection IS user-driven and the closed button must surface
|
||||
// the non-default indicator.
|
||||
expect(isDefault({ horizon: "custom", from: "2026-01-01", to: "2026-12-31" }, "custom" as TimeHorizon))
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
292
frontend/src/client/date-range-picker-pure.ts
Normal file
292
frontend/src/client/date-range-picker-pure.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
// date-range-picker-pure.ts — pure helpers for the symmetric date-range
|
||||
// picker (t-paliad-248). No DOM access; runnable under `bun test`. The
|
||||
// picker's boot client (date-range-picker.ts) drives the popover, but
|
||||
// every interesting decision — what does "Letzte 7 Tage" mean today,
|
||||
// what URL params should land, when is a custom range valid — lives
|
||||
// here so it can be tested without a browser.
|
||||
//
|
||||
// The Go side (internal/services/view_service.go:computeViewSpecBounds)
|
||||
// is the canonical materializer; horizonBounds() below MUST stay in
|
||||
// step with it. The bounds test in pure-tests pins the shape so a
|
||||
// divergent change to one side breaks the assertions on the other.
|
||||
|
||||
import type { I18nKey } from "../i18n-keys";
|
||||
|
||||
/**
|
||||
* TimeHorizon — the full 14-value union the symmetric picker can emit.
|
||||
* Mirrors `internal/services/filter_spec.go` TimeHorizon.
|
||||
*
|
||||
* The fan chips: 6 past + 6 next + the ALLES centre (`any`) + custom.
|
||||
* `all` is the legacy bidirectional-unbounded value, gated to
|
||||
* scope=explicit by the validator (Q26); the picker doesn't surface it
|
||||
* but parseURL accepts it for back-compat with saved Custom Views.
|
||||
*/
|
||||
export type TimeHorizon =
|
||||
| "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "all" | "custom";
|
||||
|
||||
/**
|
||||
* TimeSpec — the wire shape mirrored from the Go FilterSpec.TimeSpec.
|
||||
* `from`/`to` are ISO YYYY-MM-DD strings — UTC dates, not timestamps.
|
||||
* Times-of-day intentionally absent from the picker's contract.
|
||||
*/
|
||||
export interface TimeSpec {
|
||||
horizon: TimeHorizon;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full list of horizon values the picker is willing to render
|
||||
* as chips. Order is the picker's reading order — past edge → past
|
||||
* → ALLES → next → next edge, with `custom` last because it lives
|
||||
* below the chip rows in the popover, not in the row itself.
|
||||
*/
|
||||
export const ALL_HORIZONS: readonly TimeHorizon[] = [
|
||||
"past_all",
|
||||
"past_90d",
|
||||
"past_30d",
|
||||
"past_14d",
|
||||
"past_7d",
|
||||
"past_1d",
|
||||
"any",
|
||||
"next_1d",
|
||||
"next_7d",
|
||||
"next_14d",
|
||||
"next_30d",
|
||||
"next_90d",
|
||||
"next_all",
|
||||
"custom",
|
||||
];
|
||||
|
||||
// Strict-validity set. Includes the legacy bidirectional-unbounded `all`
|
||||
// horizon so a saved Custom View JSON ({"horizon":"all", …}) deserializes
|
||||
// without falling back to the surface default. The picker UI itself
|
||||
// doesn't surface a chip for `all` — it's read in, kept as state, but
|
||||
// the chip the user sees light up is `any` (the centre ALLES button).
|
||||
const ALL_HORIZONS_SET: ReadonlySet<string> = new Set([...ALL_HORIZONS, "all"]);
|
||||
|
||||
/**
|
||||
* Past chips, in reading order (outermost → innermost). The picker
|
||||
* renders this left-to-right in the popover's past fan.
|
||||
*/
|
||||
export const PAST_HORIZONS: readonly TimeHorizon[] = [
|
||||
"past_all",
|
||||
"past_90d",
|
||||
"past_30d",
|
||||
"past_14d",
|
||||
"past_7d",
|
||||
"past_1d",
|
||||
];
|
||||
|
||||
/**
|
||||
* Future chips, in reading order (innermost → outermost). The picker
|
||||
* renders this left-to-right in the popover's future fan.
|
||||
*/
|
||||
export const NEXT_HORIZONS: readonly TimeHorizon[] = [
|
||||
"next_1d",
|
||||
"next_7d",
|
||||
"next_14d",
|
||||
"next_30d",
|
||||
"next_90d",
|
||||
"next_all",
|
||||
];
|
||||
|
||||
/**
|
||||
* The i18n key for the closed-button label and chip text of every
|
||||
* horizon. Lives here (not in the TSX) so a single dictionary lookup
|
||||
* sites can hand back a translated string at any point.
|
||||
*/
|
||||
export const HORIZON_LABEL_KEY: Record<TimeHorizon, I18nKey> = {
|
||||
past_all: "date_range.horizon.past_all",
|
||||
past_90d: "date_range.horizon.past_90d",
|
||||
past_30d: "date_range.horizon.past_30d",
|
||||
past_14d: "date_range.horizon.past_14d",
|
||||
past_7d: "date_range.horizon.past_7d",
|
||||
past_1d: "date_range.horizon.past_1d",
|
||||
any: "date_range.horizon.any",
|
||||
next_1d: "date_range.horizon.next_1d",
|
||||
next_7d: "date_range.horizon.next_7d",
|
||||
next_14d: "date_range.horizon.next_14d",
|
||||
next_30d: "date_range.horizon.next_30d",
|
||||
next_90d: "date_range.horizon.next_90d",
|
||||
next_all: "date_range.horizon.next_all",
|
||||
all: "date_range.horizon.any", // legacy alias — surfaces "Alles" in the closed label
|
||||
custom: "date_range.horizon.custom",
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounds for a given horizon, anchored at `now`. Pure function: the
|
||||
* caller passes the clock so tests can pin a specific day without
|
||||
* mocking Date. Bounds are UTC dates; the `to` bound is exclusive
|
||||
* (start-of-day-after) so "past 7d" includes today.
|
||||
*
|
||||
* Returns `{}` for `any` / `all` / `custom` — the picker's surface
|
||||
* lifts the from/to out of TimeSpec directly when horizon === custom,
|
||||
* and treats unbounded values as "no narrowing in that direction".
|
||||
*/
|
||||
export function horizonBounds(
|
||||
horizon: TimeHorizon,
|
||||
now: Date,
|
||||
): { from?: Date; to?: Date } {
|
||||
const day = new Date(Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
));
|
||||
const offset = (days: number): Date =>
|
||||
new Date(day.getTime() + days * 86_400_000);
|
||||
|
||||
switch (horizon) {
|
||||
case "past_1d": return { from: offset(-1), to: offset(1) };
|
||||
case "past_7d": return { from: offset(-7), to: offset(1) };
|
||||
case "past_14d": return { from: offset(-14), to: offset(1) };
|
||||
case "past_30d": return { from: offset(-30), to: offset(1) };
|
||||
case "past_90d": return { from: offset(-90), to: offset(1) };
|
||||
case "past_all": return { to: offset(1) };
|
||||
case "next_1d": return { from: day, to: offset(1) };
|
||||
case "next_7d": return { from: day, to: offset(7) };
|
||||
case "next_14d": return { from: day, to: offset(14) };
|
||||
case "next_30d": return { from: day, to: offset(30) };
|
||||
case "next_90d": return { from: day, to: offset(90) };
|
||||
case "next_all": return { from: day };
|
||||
case "any":
|
||||
case "all":
|
||||
case "custom":
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* isValidHorizon — narrows an unknown string to a TimeHorizon, used
|
||||
* by parseURL and by surface-side URL alias adapters.
|
||||
*/
|
||||
export function isValidHorizon(s: unknown): s is TimeHorizon {
|
||||
return typeof s === "string" && ALL_HORIZONS_SET.has(s);
|
||||
}
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* isValidISODate — `YYYY-MM-DD` shape check plus a real-date validity
|
||||
* check (rejects 2026-02-30). Doesn't enforce timezone or floor at any
|
||||
* particular date.
|
||||
*/
|
||||
export function isValidISODate(s: unknown): s is string {
|
||||
if (typeof s !== "string" || !ISO_DATE_RE.test(s)) return false;
|
||||
const ms = Date.parse(`${s}T00:00:00Z`);
|
||||
if (Number.isNaN(ms)) return false;
|
||||
// Reject 2026-02-30 etc. — Date.parse accepts those by rolling over.
|
||||
return new Date(ms).toISOString().slice(0, 10) === s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a custom range. Returns null on success, an i18n key
|
||||
* pointing at the error message on failure.
|
||||
*
|
||||
* Rules:
|
||||
* - Both `from` and `to` must be valid ISO YYYY-MM-DD.
|
||||
* - `to` must be strictly after `from` (single-day ranges use
|
||||
* `from=2026-05-25&to=2026-05-26`, NOT `from=to=2026-05-25`).
|
||||
*/
|
||||
export function validateCustomRange(
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
): I18nKey | null {
|
||||
if (!from || !to) return "date_range.custom.invalid_missing";
|
||||
if (!isValidISODate(from) || !isValidISODate(to)) return "date_range.custom.invalid_format";
|
||||
if (Date.parse(`${from}T00:00:00Z`) >= Date.parse(`${to}T00:00:00Z`)) {
|
||||
return "date_range.custom.invalid";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* URLContract — the picker's stable URL serialization. Surfaces can
|
||||
* override the param name via `key` so two pickers on the same page
|
||||
* (rare) don't collide.
|
||||
*/
|
||||
export interface URLContract {
|
||||
/** Base param name, defaults to "horizon". */
|
||||
key?: string;
|
||||
/** Default value omitted from URL (matches surface's natural default). */
|
||||
default?: TimeHorizon;
|
||||
}
|
||||
|
||||
/**
|
||||
* parseURL — reads a URL search-params object into a TimeSpec.
|
||||
*
|
||||
* ?horizon=past_30d → {horizon:"past_30d"}
|
||||
* ?horizon=custom&from=2026-03-15&to=… → {horizon:"custom",from,to}
|
||||
* (no params) → {horizon: contract.default ?? "any"}
|
||||
*
|
||||
* Unknown / malformed values fall back to the default. Out-of-shape
|
||||
* custom dates clamp to {horizon: default} — the picker never lands
|
||||
* in a half-custom state from a URL.
|
||||
*/
|
||||
export function parseURL(
|
||||
params: URLSearchParams,
|
||||
contract: URLContract = {},
|
||||
): TimeSpec {
|
||||
const key = contract.key ?? "horizon";
|
||||
const fallback: TimeHorizon = contract.default ?? "any";
|
||||
|
||||
const raw = params.get(key);
|
||||
if (raw === null) return { horizon: fallback };
|
||||
if (!isValidHorizon(raw)) return { horizon: fallback };
|
||||
if (raw !== "custom") return { horizon: raw };
|
||||
|
||||
const from = params.get(`${key}_from`) ?? undefined;
|
||||
const to = params.get(`${key}_to`) ?? undefined;
|
||||
if (validateCustomRange(from, to) !== null) {
|
||||
return { horizon: fallback };
|
||||
}
|
||||
return { horizon: "custom", from, to };
|
||||
}
|
||||
|
||||
/**
|
||||
* serializeURL — writes a TimeSpec into the URL search-params object,
|
||||
* mutating the passed-in instance. Values equal to the surface
|
||||
* default are OMITTED — the canonical URL stays short.
|
||||
*
|
||||
* Always deletes `horizon`, `<key>_from`, `<key>_to` first so a
|
||||
* re-serialise after the picker reverts to default cleans up rather
|
||||
* than accumulating stale entries.
|
||||
*/
|
||||
export function serializeURL(
|
||||
spec: TimeSpec,
|
||||
params: URLSearchParams,
|
||||
contract: URLContract = {},
|
||||
): void {
|
||||
const key = contract.key ?? "horizon";
|
||||
const fromKey = `${key}_from`;
|
||||
const toKey = `${key}_to`;
|
||||
|
||||
params.delete(key);
|
||||
params.delete(fromKey);
|
||||
params.delete(toKey);
|
||||
|
||||
if (spec.horizon === (contract.default ?? "any") && spec.horizon !== "custom") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (spec.horizon === "custom") {
|
||||
params.set(key, "custom");
|
||||
if (spec.from) params.set(fromKey, spec.from);
|
||||
if (spec.to) params.set(toKey, spec.to);
|
||||
return;
|
||||
}
|
||||
|
||||
params.set(key, spec.horizon);
|
||||
}
|
||||
|
||||
/**
|
||||
* isDefault — used by surfaces to decide whether to render the
|
||||
* "value is non-default" dot on the closed button.
|
||||
*/
|
||||
export function isDefault(spec: TimeSpec, defaultHorizon: TimeHorizon): boolean {
|
||||
if (spec.horizon !== defaultHorizon) return false;
|
||||
if (spec.horizon === "custom") return false;
|
||||
return true;
|
||||
}
|
||||
490
frontend/src/client/date-range-picker.ts
Normal file
490
frontend/src/client/date-range-picker.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
// date-range-picker.ts — boot client + DOM mount for the symmetric
|
||||
// date-range picker (t-paliad-248). The picker is a controlled
|
||||
// component: callers pass `value` + `onChange`, the component renders
|
||||
// the trigger button + popover scaffold, the popover materialises a
|
||||
// chip row and (when "Anpassen" is picked) an inline date-pair editor.
|
||||
//
|
||||
// The picker reuses the existing `.agenda-chip` styling for chips and
|
||||
// the `.multi-panel` popover pattern (auto-positioned under a
|
||||
// `.multi-anchor` wrapper). Both patterns are battle-tested by the
|
||||
// filter-bar + multi-select widgets — no new design tokens, no new
|
||||
// dark-mode contrast risk.
|
||||
|
||||
import { t } from "./i18n";
|
||||
import {
|
||||
ALL_HORIZONS,
|
||||
HORIZON_LABEL_KEY,
|
||||
NEXT_HORIZONS,
|
||||
PAST_HORIZONS,
|
||||
isDefault,
|
||||
isValidISODate,
|
||||
validateCustomRange,
|
||||
type TimeHorizon,
|
||||
type TimeSpec,
|
||||
} from "./date-range-picker-pure";
|
||||
|
||||
export interface MountOpts {
|
||||
/** Current value. The picker is fully controlled. */
|
||||
value: TimeSpec;
|
||||
/** Fired on every committed change (chip click or Anwenden). */
|
||||
onChange(next: TimeSpec): void;
|
||||
/**
|
||||
* Which horizon constitutes the "default" for this surface. Used
|
||||
* for the non-default indicator dot. Defaults to `"any"`.
|
||||
*/
|
||||
defaultHorizon?: TimeHorizon;
|
||||
/**
|
||||
* Which chips to render. Order is preserved. Defaults to the full
|
||||
* 14-chip fan from ALL_HORIZONS.
|
||||
*/
|
||||
presets?: readonly TimeHorizon[];
|
||||
/**
|
||||
* Stable surface tag — feeds into the `data-testid` on every DOM
|
||||
* node the picker creates so tests can scope. Example: "agenda",
|
||||
* "filter-bar.time", "audit-log".
|
||||
*/
|
||||
surface: string;
|
||||
/**
|
||||
* Optional prefix for the closed-button label. The label always
|
||||
* starts with the resolved horizon name (e.g. "Letzte 30 Tage").
|
||||
* Surfaces that want a heading prefix ("Zeitraum: Letzte 30 Tage")
|
||||
* pass it here.
|
||||
*/
|
||||
labelPrefix?: string;
|
||||
}
|
||||
|
||||
export interface PickerHandle {
|
||||
/** Root element — append to the host container. */
|
||||
element: HTMLElement;
|
||||
/** Read the current value (may have been edited via Anpassen). */
|
||||
getValue(): TimeSpec;
|
||||
/** Update the value from the host (e.g. after URL change). */
|
||||
setValue(next: TimeSpec): void;
|
||||
/** Force-close the popover. Safe to call when already closed. */
|
||||
close(): void;
|
||||
/** Detach event listeners + remove from DOM. */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a date-range picker. The returned `element` is a single
|
||||
* inline node containing both the trigger button and the popover
|
||||
* (absolutely positioned via `.multi-anchor` + `.multi-panel`).
|
||||
*
|
||||
* The popover stays in the DOM permanently; opening/closing toggles
|
||||
* the `[hidden]` attribute. This keeps the chip's tab-order stable
|
||||
* and matches the multi-select widget's behaviour.
|
||||
*/
|
||||
export function mountDateRangePicker(opts: MountOpts): PickerHandle {
|
||||
const presets = opts.presets ?? ALL_HORIZONS;
|
||||
const defaultHorizon = opts.defaultHorizon ?? "any";
|
||||
let value: TimeSpec = normalize(opts.value);
|
||||
|
||||
// Cached drafts for the "Anpassen" editor — preserved across
|
||||
// open/close so the user doesn't lose their typing if they peek
|
||||
// away. Seeded from the live value when the editor opens.
|
||||
let customFromDraft = value.horizon === "custom" ? (value.from ?? "") : "";
|
||||
let customToDraft = value.horizon === "custom" ? (value.to ?? "") : "";
|
||||
let customEditorOpen = value.horizon === "custom";
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "date-range-anchor multi-anchor";
|
||||
root.dataset.testid = `${opts.surface}.date-range-picker`;
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "date-range-trigger";
|
||||
trigger.setAttribute("aria-haspopup", "dialog");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.dataset.testid = `${opts.surface}.date-range-trigger`;
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "date-range-panel multi-panel";
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-label", t("date_range.dialog.label"));
|
||||
panel.hidden = true;
|
||||
panel.dataset.testid = `${opts.surface}.date-range-panel`;
|
||||
|
||||
root.appendChild(trigger);
|
||||
root.appendChild(panel);
|
||||
|
||||
renderTrigger();
|
||||
renderPanel();
|
||||
|
||||
// Open/close wiring. Click outside the root collapses the popover;
|
||||
// Esc inside it bubbles up to the same handler via keydown delegate.
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (panel.hidden) return;
|
||||
if (e.target instanceof Node && root.contains(e.target)) return;
|
||||
closePopover();
|
||||
};
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (panel.hidden) return;
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
closePopover();
|
||||
trigger.focus();
|
||||
}
|
||||
};
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
if (panel.hidden) openPopover();
|
||||
else closePopover();
|
||||
});
|
||||
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
|
||||
function openPopover(): void {
|
||||
panel.hidden = false;
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
// Re-render to reflect the very latest value (host may have
|
||||
// patched via setValue between open/close).
|
||||
renderPanel();
|
||||
// Move keyboard focus into the panel so Esc works without a
|
||||
// prior click. The first chip is the natural landing spot.
|
||||
const firstChip = panel.querySelector<HTMLButtonElement>(".date-range-chip");
|
||||
firstChip?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function closePopover(): void {
|
||||
panel.hidden = true;
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function commit(next: TimeSpec, closeAfter: boolean): void {
|
||||
value = normalize(next);
|
||||
customEditorOpen = value.horizon === "custom";
|
||||
if (value.horizon === "custom") {
|
||||
customFromDraft = value.from ?? "";
|
||||
customToDraft = value.to ?? "";
|
||||
}
|
||||
renderTrigger();
|
||||
renderPanel();
|
||||
opts.onChange(value);
|
||||
if (closeAfter) {
|
||||
closePopover();
|
||||
trigger.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrigger(): void {
|
||||
trigger.replaceChildren();
|
||||
if (!isDefault(value, defaultHorizon)) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "date-range-trigger-dot";
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
trigger.appendChild(dot);
|
||||
}
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.className = "date-range-trigger-label";
|
||||
labelSpan.textContent = labelFor(value, opts.labelPrefix);
|
||||
trigger.appendChild(labelSpan);
|
||||
|
||||
const chev = document.createElement("span");
|
||||
chev.className = "date-range-trigger-chev";
|
||||
chev.setAttribute("aria-hidden", "true");
|
||||
chev.textContent = "▾";
|
||||
trigger.appendChild(chev);
|
||||
}
|
||||
|
||||
function renderPanel(): void {
|
||||
panel.replaceChildren();
|
||||
|
||||
// Three vertical columns: Past (closest→farthest top→bottom),
|
||||
// NOW (Heute + Alles), Future (closest→farthest). The grid
|
||||
// visualises time as space around NOW — each column's top is
|
||||
// closest to the current moment, bottom is furthest away.
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "date-range-grid";
|
||||
|
||||
// Past column: PAST_HORIZONS registry is outermost→innermost
|
||||
// (past_all → past_1d); reverse for closeness-to-NOW ordering
|
||||
// (past_1d at top, past_all at bottom).
|
||||
const pastCol = renderColumn(
|
||||
"past",
|
||||
t("date_range.fan.past.label"),
|
||||
[...PAST_HORIZONS].reverse().filter((h) => presets.includes(h)),
|
||||
);
|
||||
const nowCol = renderNowColumn();
|
||||
// Future column: NEXT_HORIZONS registry is already in closeness
|
||||
// order (next_1d → next_all). next_1d moves to the NOW column as
|
||||
// "Heute" (semantically just-today, single-day window), so the
|
||||
// future column skips it.
|
||||
const futureCol = renderColumn(
|
||||
"future",
|
||||
t("date_range.fan.future.label"),
|
||||
NEXT_HORIZONS.filter((h) => h !== "next_1d" && presets.includes(h)),
|
||||
);
|
||||
|
||||
if (pastCol) grid.appendChild(pastCol);
|
||||
if (nowCol) grid.appendChild(nowCol);
|
||||
if (futureCol) grid.appendChild(futureCol);
|
||||
|
||||
panel.appendChild(grid);
|
||||
|
||||
// Custom-range section ("Anpassen"). Toggle button + collapsible
|
||||
// date-pair editor below.
|
||||
if (presets.includes("custom")) {
|
||||
panel.appendChild(renderCustomSection());
|
||||
}
|
||||
}
|
||||
|
||||
function renderColumn(
|
||||
side: "past" | "future",
|
||||
heading: string,
|
||||
horizons: readonly TimeHorizon[],
|
||||
): HTMLElement | null {
|
||||
if (horizons.length === 0) return null;
|
||||
const col = document.createElement("div");
|
||||
col.className = `date-range-col date-range-col--${side}`;
|
||||
col.setAttribute("role", "group");
|
||||
col.setAttribute("aria-label", heading);
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "date-range-col-heading";
|
||||
head.textContent = heading;
|
||||
col.appendChild(head);
|
||||
|
||||
for (const h of horizons) {
|
||||
col.appendChild(makeChip(h));
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
function renderNowColumn(): HTMLElement | null {
|
||||
const showHeute = presets.includes("next_1d");
|
||||
const showAlles = presets.includes("any");
|
||||
if (!showHeute && !showAlles) return null;
|
||||
|
||||
const col = document.createElement("div");
|
||||
col.className = "date-range-col date-range-col--now";
|
||||
col.setAttribute("role", "group");
|
||||
col.setAttribute("aria-label", t("date_range.center.label"));
|
||||
|
||||
const glyph = document.createElement("div");
|
||||
glyph.className = "date-range-col-heading date-range-col-heading--glyph";
|
||||
glyph.setAttribute("aria-hidden", "true");
|
||||
glyph.textContent = "⌖"; // ⌖ POSITION INDICATOR
|
||||
col.appendChild(glyph);
|
||||
|
||||
if (showHeute) col.appendChild(makeChip("next_1d"));
|
||||
if (showAlles) {
|
||||
const allesChip = makeChip("any");
|
||||
// Legacy "all" horizon also lights up Alles for back-compat
|
||||
// with saved Custom Views that store the bidirectional-unbounded
|
||||
// value (Q26 — parser preserves it, picker surfaces it here).
|
||||
if (value.horizon === "all") {
|
||||
allesChip.classList.add("agenda-chip-active");
|
||||
allesChip.setAttribute("aria-pressed", "true");
|
||||
}
|
||||
col.appendChild(allesChip);
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
function makeChip(h: TimeHorizon): HTMLButtonElement {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "agenda-chip date-range-chip";
|
||||
if (value.horizon === h) chip.classList.add("agenda-chip-active");
|
||||
chip.setAttribute("aria-pressed", String(value.horizon === h));
|
||||
chip.textContent = t(HORIZON_LABEL_KEY[h]);
|
||||
chip.dataset.testid = `${opts.surface}.date-range-chip.${h}`;
|
||||
chip.addEventListener("click", () => {
|
||||
commit({ horizon: h }, /*closeAfter*/ true);
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
|
||||
function renderCustomSection(): HTMLElement {
|
||||
const section = document.createElement("div");
|
||||
section.className = "date-range-custom";
|
||||
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.type = "button";
|
||||
toggleBtn.className = "agenda-chip date-range-chip date-range-chip--custom";
|
||||
if (value.horizon === "custom") toggleBtn.classList.add("agenda-chip-active");
|
||||
toggleBtn.setAttribute("aria-expanded", String(customEditorOpen));
|
||||
toggleBtn.dataset.testid = `${opts.surface}.date-range-chip.custom`;
|
||||
toggleBtn.textContent = t("date_range.horizon.custom");
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
customEditorOpen = !customEditorOpen;
|
||||
renderPanel();
|
||||
if (customEditorOpen) {
|
||||
// Focus the first input on expand.
|
||||
panel.querySelector<HTMLInputElement>(".date-range-custom-from")?.focus();
|
||||
}
|
||||
});
|
||||
section.appendChild(toggleBtn);
|
||||
|
||||
if (!customEditorOpen) return section;
|
||||
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "date-range-custom-editor";
|
||||
|
||||
const fromWrap = document.createElement("label");
|
||||
fromWrap.className = "date-range-custom-field";
|
||||
const fromLbl = document.createElement("span");
|
||||
fromLbl.className = "date-range-custom-label";
|
||||
fromLbl.textContent = t("date_range.custom.from");
|
||||
const fromInput = document.createElement("input");
|
||||
fromInput.type = "date";
|
||||
fromInput.lang = "de";
|
||||
fromInput.className = "date-range-custom-from";
|
||||
fromInput.value = customFromDraft;
|
||||
fromInput.dataset.testid = `${opts.surface}.date-range-custom-from`;
|
||||
fromInput.addEventListener("input", () => {
|
||||
customFromDraft = fromInput.value;
|
||||
refreshValidity();
|
||||
});
|
||||
fromWrap.appendChild(fromLbl);
|
||||
fromWrap.appendChild(fromInput);
|
||||
|
||||
const toWrap = document.createElement("label");
|
||||
toWrap.className = "date-range-custom-field";
|
||||
const toLbl = document.createElement("span");
|
||||
toLbl.className = "date-range-custom-label";
|
||||
toLbl.textContent = t("date_range.custom.to");
|
||||
const toInput = document.createElement("input");
|
||||
toInput.type = "date";
|
||||
toInput.lang = "de";
|
||||
toInput.className = "date-range-custom-to";
|
||||
toInput.value = customToDraft;
|
||||
toInput.dataset.testid = `${opts.surface}.date-range-custom-to`;
|
||||
toInput.addEventListener("input", () => {
|
||||
customToDraft = toInput.value;
|
||||
refreshValidity();
|
||||
});
|
||||
toWrap.appendChild(toLbl);
|
||||
toWrap.appendChild(toInput);
|
||||
|
||||
const applyBtn = document.createElement("button");
|
||||
applyBtn.type = "button";
|
||||
applyBtn.className = "date-range-custom-apply";
|
||||
applyBtn.textContent = t("date_range.custom.apply");
|
||||
applyBtn.dataset.testid = `${opts.surface}.date-range-custom-apply`;
|
||||
applyBtn.addEventListener("click", () => {
|
||||
const err = validateCustomRange(customFromDraft, customToDraft);
|
||||
if (err !== null) {
|
||||
showError(err);
|
||||
return;
|
||||
}
|
||||
commit(
|
||||
{ horizon: "custom", from: customFromDraft, to: customToDraft },
|
||||
/*closeAfter*/ true,
|
||||
);
|
||||
});
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "date-range-custom-cancel";
|
||||
cancelBtn.textContent = t("date_range.custom.cancel");
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
customEditorOpen = false;
|
||||
// Restore drafts from live value so a re-open shows the
|
||||
// committed state rather than the abandoned typing.
|
||||
customFromDraft = value.horizon === "custom" ? (value.from ?? "") : "";
|
||||
customToDraft = value.horizon === "custom" ? (value.to ?? "") : "";
|
||||
renderPanel();
|
||||
});
|
||||
|
||||
const errEl = document.createElement("div");
|
||||
errEl.className = "date-range-custom-error";
|
||||
errEl.hidden = true;
|
||||
errEl.dataset.testid = `${opts.surface}.date-range-custom-error`;
|
||||
|
||||
editor.appendChild(fromWrap);
|
||||
editor.appendChild(toWrap);
|
||||
editor.appendChild(applyBtn);
|
||||
editor.appendChild(cancelBtn);
|
||||
editor.appendChild(errEl);
|
||||
section.appendChild(editor);
|
||||
|
||||
refreshValidity();
|
||||
|
||||
function refreshValidity(): void {
|
||||
const err = validateCustomRange(customFromDraft, customToDraft);
|
||||
if (err === null) {
|
||||
applyBtn.disabled = false;
|
||||
errEl.hidden = true;
|
||||
errEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
applyBtn.disabled = true;
|
||||
// Only surface the *content* error (`invalid` = inverted range)
|
||||
// while the user is typing. Empty / format errors are visible
|
||||
// through the disabled-Anwenden state alone — surfacing them on
|
||||
// every keystroke would be noisy.
|
||||
if (err === "date_range.custom.invalid") {
|
||||
showError(err);
|
||||
} else {
|
||||
errEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(key: Parameters<typeof t>[0]): void {
|
||||
errEl.textContent = t(key);
|
||||
errEl.hidden = false;
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
return {
|
||||
element: root,
|
||||
getValue: () => normalize(value),
|
||||
setValue(next: TimeSpec) {
|
||||
value = normalize(next);
|
||||
customEditorOpen = value.horizon === "custom";
|
||||
if (value.horizon === "custom") {
|
||||
customFromDraft = value.from ?? "";
|
||||
customToDraft = value.to ?? "";
|
||||
}
|
||||
renderTrigger();
|
||||
renderPanel();
|
||||
},
|
||||
close: closePopover,
|
||||
destroy() {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(spec: TimeSpec): TimeSpec {
|
||||
if (spec.horizon === "custom") {
|
||||
return {
|
||||
horizon: "custom",
|
||||
from: spec.from && isValidISODate(spec.from) ? spec.from : undefined,
|
||||
to: spec.to && isValidISODate(spec.to) ? spec.to : undefined,
|
||||
};
|
||||
}
|
||||
return { horizon: spec.horizon };
|
||||
}
|
||||
|
||||
function labelFor(spec: TimeSpec, prefix?: string): string {
|
||||
let body: string;
|
||||
if (spec.horizon === "custom") {
|
||||
if (spec.from && spec.to) {
|
||||
body = t("date_range.button.label.custom_range")
|
||||
.replace("{from}", formatISO(spec.from))
|
||||
.replace("{to}", formatISO(spec.to));
|
||||
} else {
|
||||
body = t("date_range.horizon.custom");
|
||||
}
|
||||
} else {
|
||||
body = t(HORIZON_LABEL_KEY[spec.horizon]);
|
||||
}
|
||||
return prefix ? `${prefix}: ${body}` : body;
|
||||
}
|
||||
|
||||
function formatISO(iso: string): string {
|
||||
if (!isValidISODate(iso)) return iso;
|
||||
// DE locale: DD.MM.YYYY. The picker is German-first; surfaces in EN
|
||||
// can override via labelPrefix or by formatting before commit if
|
||||
// they want a different shape.
|
||||
const [y, m, d] = iso.split("-");
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
@@ -465,7 +465,8 @@ function refreshRuleAutoDisplay(): void {
|
||||
panel.style.display = "";
|
||||
const r = currentAutoRule();
|
||||
if (r) {
|
||||
text.textContent = formatRuleLabel(r);
|
||||
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
|
||||
text.innerHTML = formatRuleLabelHTML(r, esc);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { formatRuleLabel } from "./rule-label";
|
||||
import { formatRuleLabel, formatRuleLabelHTML } from "./rule-label";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
@@ -192,7 +192,8 @@ function refreshRuleAutoDisplay(): void {
|
||||
panel.style.display = "";
|
||||
const rule = currentAutoRule();
|
||||
if (rule) {
|
||||
text.textContent = formatRuleLabel(rule);
|
||||
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
|
||||
text.innerHTML = formatRuleLabelHTML(rule, esc);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
// New classes are scoped under .filter-bar-* so they don't bleed.
|
||||
|
||||
import { t, tDyn, type I18nKey } from "../i18n";
|
||||
import { mountDateRangePicker } from "../date-range-picker";
|
||||
import {
|
||||
ALL_HORIZONS as DRP_ALL_HORIZONS,
|
||||
type TimeHorizon as DRPTimeHorizon,
|
||||
type TimeSpec as DRPTimeSpec,
|
||||
} from "../date-range-picker-pure";
|
||||
import type { BarState, AxisKey, InboxFocus } from "./types";
|
||||
|
||||
export interface AxisCtx {
|
||||
@@ -59,60 +65,66 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// time — chip cluster (presets + Anpassen)
|
||||
// time — symmetric date-range picker (t-paliad-248, replaces the t-163
|
||||
// chip-cluster + disabled Anpassen stub). The picker emits a TimeSpec
|
||||
// (horizon + optional custom from/to); the bar patches that onto
|
||||
// BarState.time directly.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
|
||||
|
||||
const TIME_PRESET_LABELS: Record<TimeHorizonValue, I18nKey> = {
|
||||
next_7d: "views.bar.time.next_7d",
|
||||
next_30d: "views.bar.time.next_30d",
|
||||
next_90d: "views.bar.time.next_90d",
|
||||
past_7d: "views.bar.time.past_7d",
|
||||
past_30d: "views.bar.time.past_30d",
|
||||
past_90d: "views.bar.time.past_90d",
|
||||
any: "views.bar.time.any",
|
||||
all: "views.bar.time.all",
|
||||
custom: "views.bar.time.custom",
|
||||
};
|
||||
|
||||
// Default chip set when the surface doesn't override. Mirrors m's
|
||||
// 3-column picker spec (t-paliad-278): symmetric 7d/30d/90d/all fan
|
||||
// per side, plus Heute (next_1d) + Alles (any) in the centre column,
|
||||
// plus Anpassen. Surfaces with a tighter scope (project history is
|
||||
// past-only) keep overriding via `timePresets`.
|
||||
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
|
||||
"next_7d", "next_30d", "next_90d", "past_30d", "any",
|
||||
"past_7d", "past_30d", "past_90d", "past_all",
|
||||
"next_1d", "any",
|
||||
"next_7d", "next_30d", "next_90d", "next_all",
|
||||
"custom",
|
||||
];
|
||||
|
||||
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {
|
||||
const wrap = group("views.bar.label.time");
|
||||
const row = chipRow();
|
||||
const presets = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
|
||||
// "any" / "all" are both unbounded — clearing state is the cleanest
|
||||
// representation, so each maps to "no overlay" rather than a stored
|
||||
// horizon. The chip's active state then keys off "no time set".
|
||||
const current = ctx.get("time")?.horizon ?? "any";
|
||||
for (const preset of presets) {
|
||||
if (preset === "custom") continue; // custom rendered separately below
|
||||
const isUnbounded = preset === "any" || preset === "all";
|
||||
const isActive = isUnbounded
|
||||
? !ctx.get("time")
|
||||
: preset === current;
|
||||
const chip = chipBtn(t(TIME_PRESET_LABELS[preset]), isActive);
|
||||
chip.addEventListener("click", () => {
|
||||
if (isUnbounded) {
|
||||
const presetSource = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
|
||||
// The picker's pure module owns the complete chip set; we narrow it
|
||||
// here to whatever this surface declares (preserving the surface's
|
||||
// chip order so timePresets remains the override knob it always was).
|
||||
const presets: DRPTimeHorizon[] = presetSource.flatMap((p) =>
|
||||
DRP_ALL_HORIZONS.includes(p as DRPTimeHorizon) ? [p as DRPTimeHorizon] : [],
|
||||
);
|
||||
|
||||
const current = ctx.get("time");
|
||||
const initialValue: DRPTimeSpec = current
|
||||
? { horizon: current.horizon as DRPTimeHorizon, from: current.from, to: current.to }
|
||||
: { horizon: "any" };
|
||||
|
||||
const picker = mountDateRangePicker({
|
||||
value: initialValue,
|
||||
onChange(next) {
|
||||
// The bar treats `any` as "no time overlay" (matches the legacy
|
||||
// chip-cluster's behaviour) so the BarState stays minimal when
|
||||
// the user lands on the centre ALLES button.
|
||||
if (next.horizon === "any") {
|
||||
ctx.patch({ time: undefined });
|
||||
} else {
|
||||
ctx.patch({ time: { horizon: preset } });
|
||||
return;
|
||||
}
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
// Custom range — placeholder chip; opens a small popover with two
|
||||
// <input type="date"> in Phase 2. For Phase 1 we render the chip
|
||||
// disabled with a tooltip so the affordance is discoverable.
|
||||
const customChip = chipBtn(t("views.bar.time.custom"), current === "custom");
|
||||
customChip.classList.add("filter-bar-chip-pending");
|
||||
customChip.title = t("views.bar.time.custom.coming_soon");
|
||||
customChip.disabled = true;
|
||||
row.appendChild(customChip);
|
||||
wrap.appendChild(row);
|
||||
ctx.patch({
|
||||
time: {
|
||||
horizon: next.horizon as TimeHorizonValue,
|
||||
from: next.horizon === "custom" ? next.from : undefined,
|
||||
to: next.horizon === "custom" ? next.to : undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
defaultHorizon: "any",
|
||||
presets,
|
||||
surface: "filter-bar.time",
|
||||
labelPrefix: t("views.bar.label.time"),
|
||||
});
|
||||
|
||||
wrap.appendChild(picker.element);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
|
||||
126
frontend/src/client/filter-bar/compute-effective.test.ts
Normal file
126
frontend/src/client/filter-bar/compute-effective.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// Unit tests for the FilterBar's computeEffective() overlay. These pin
|
||||
// the contract that any chip the user clicks ends up as a predicate the
|
||||
// server can see — the t-paliad-283 regression had four sources picking
|
||||
// up zero narrowing for /views/any because the bar's chip click didn't
|
||||
// produce a non-empty `filter.predicates` for that source.
|
||||
//
|
||||
// Run with `bun test`.
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { computeEffective } from "./index";
|
||||
import type { FilterSpec, RenderSpec } from "../views/types";
|
||||
import type { BarState } from "./types";
|
||||
|
||||
// Mirrors paliad.user_views row {slug: "any"} — the saved Custom View
|
||||
// that triggered the t-paliad-283 regression report.
|
||||
const ANY_VIEW_FILTER: FilterSpec = {
|
||||
version: 1,
|
||||
sources: ["deadline", "appointment", "project_event", "approval_request"],
|
||||
scope: { projects: { mode: "all_visible" } },
|
||||
time: { field: "auto", horizon: "past_30d" },
|
||||
};
|
||||
|
||||
const ANY_VIEW_RENDER: RenderSpec = {
|
||||
shape: "list",
|
||||
list: { sort: "date_asc", density: "comfortable" },
|
||||
};
|
||||
|
||||
describe("filter-bar/computeEffective — /views/any (all 4 sources)", () => {
|
||||
test("empty state leaves base spec intact (no overlays)", () => {
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
|
||||
expect(eff.filter.sources).toEqual([
|
||||
"deadline", "appointment", "project_event", "approval_request",
|
||||
]);
|
||||
expect(eff.filter.time).toEqual({ field: "auto", horizon: "past_30d" });
|
||||
// predicates may be {} (the bar zero-fills it) but never carries a
|
||||
// stray narrowing on any source — that would silently filter
|
||||
// results the user never asked to filter.
|
||||
for (const src of ANY_VIEW_FILTER.sources) {
|
||||
expect(eff.filter.predicates?.[src]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("deadline_status chip narrows deadline predicate", () => {
|
||||
const state: BarState = { deadline_status: ["pending"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
|
||||
});
|
||||
|
||||
test("appointment_type chip narrows appointment predicate", () => {
|
||||
const state: BarState = { appointment_type: ["hearing"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
|
||||
});
|
||||
|
||||
test("approval_viewer_role chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_viewer_role: "any_visible" };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.viewer_role).toBe("any_visible");
|
||||
});
|
||||
|
||||
test("approval_status chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_status: ["pending", "approved"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending", "approved"]);
|
||||
});
|
||||
|
||||
test("approval_entity_type chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_entity_type: ["deadline"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.entity_types).toEqual(["deadline"]);
|
||||
});
|
||||
|
||||
test("project_event_kind chip narrows project_event predicate", () => {
|
||||
const state: BarState = { project_event_kind: ["deadline_created"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
|
||||
});
|
||||
|
||||
test("time chip overrides base horizon", () => {
|
||||
const state: BarState = { time: { horizon: "past_7d" } };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.time.horizon).toBe("past_7d");
|
||||
expect(eff.filter.time.field).toBe("auto"); // preserved from base
|
||||
});
|
||||
|
||||
test("personal_only chip flips scope flag", () => {
|
||||
const state: BarState = { personal_only: true };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.scope.personal_only).toBe(true);
|
||||
});
|
||||
|
||||
test("multiple chips combine into the same effective spec", () => {
|
||||
const state: BarState = {
|
||||
time: { horizon: "past_7d" },
|
||||
deadline_status: ["pending"],
|
||||
appointment_type: ["hearing"],
|
||||
approval_status: ["pending"],
|
||||
project_event_kind: ["deadline_created"],
|
||||
};
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.time.horizon).toBe("past_7d");
|
||||
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
|
||||
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
|
||||
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending"]);
|
||||
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
|
||||
});
|
||||
|
||||
test("overlay does not mutate the caller's base filter", () => {
|
||||
const base: FilterSpec = JSON.parse(JSON.stringify(ANY_VIEW_FILTER));
|
||||
const state: BarState = { deadline_status: ["pending"], time: { horizon: "past_7d" } };
|
||||
computeEffective(base, ANY_VIEW_RENDER, state);
|
||||
// The bar deep-clones; the base must come back unchanged so a
|
||||
// second click doesn't compound the previous click's overlay.
|
||||
expect(base).toEqual(ANY_VIEW_FILTER);
|
||||
});
|
||||
|
||||
test("inbox-only axes do not affect a /views/any spec (no inbox axis exposed)", () => {
|
||||
// /views/any's axes don't include unread_only or inbox_focus, so
|
||||
// those keys never appear in state. Verify that even if they did,
|
||||
// the bar's overlay doesn't silently mutate sources or predicates
|
||||
// in a way that would break a 4-source Custom View.
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
|
||||
expect(eff.filter.sources).toHaveLength(4);
|
||||
expect(eff.filter.unread_only ?? false).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -79,7 +79,13 @@ export interface BarState {
|
||||
}
|
||||
|
||||
export interface TimeOverlay {
|
||||
horizon: "next_7d" | "next_30d" | "next_90d" | "past_7d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
|
||||
// Mirrors internal/services/filter_spec.go TimeHorizon. t-paliad-248
|
||||
// added the symmetric 1d / 14d / all chips on each side; the union
|
||||
// here is the wire-shape the URL codec parses and the picker emits.
|
||||
horizon:
|
||||
| "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "all" | "custom";
|
||||
from?: string; // ISO 8601 — only when horizon === "custom"
|
||||
to?: string;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@ describe("filter-bar/url-codec", () => {
|
||||
});
|
||||
|
||||
test("time horizon round-trips", () => {
|
||||
for (const h of ["next_7d", "next_30d", "next_90d", "past_30d", "past_90d", "any", "all"] as const) {
|
||||
// Includes the t-paliad-248 symmetric additions (1d / 14d / all on each side).
|
||||
for (const h of [
|
||||
"next_1d", "next_7d", "next_14d", "next_30d", "next_90d", "next_all",
|
||||
"past_1d", "past_7d", "past_14d", "past_30d", "past_90d", "past_all",
|
||||
"any", "all",
|
||||
] as const) {
|
||||
expect(roundTrip({ time: { horizon: h } })).toEqual({ time: { horizon: h } });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -192,12 +192,18 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
|
||||
|
||||
function parseHorizon(s: string): TimeOverlay["horizon"] | null {
|
||||
switch (s) {
|
||||
case "next_1d":
|
||||
case "next_7d":
|
||||
case "next_14d":
|
||||
case "next_30d":
|
||||
case "next_90d":
|
||||
case "next_all":
|
||||
case "past_1d":
|
||||
case "past_7d":
|
||||
case "past_14d":
|
||||
case "past_30d":
|
||||
case "past_90d":
|
||||
case "past_all":
|
||||
case "any":
|
||||
case "all":
|
||||
case "custom":
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
import {
|
||||
attachEventCardChoices,
|
||||
reseedChips,
|
||||
type EventChoice,
|
||||
type ChoiceKind,
|
||||
} from "./views/event-card-choices";
|
||||
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
@@ -162,6 +168,13 @@ async function calculate() {
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
// t-paliad-265 — when project-bound, the server pulls per-card
|
||||
// choices from paliad.project_event_choices. The frontend has
|
||||
// already pre-fetched them into perCardChoicesCache so chip
|
||||
// indicators repaint in step with the calc; sending projectId here
|
||||
// is the persistence path.
|
||||
const projectIdForCalc = currentStep1Context.kind === "project" ? currentStep1Context.projectId : "";
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
@@ -169,6 +182,7 @@ async function calculate() {
|
||||
flags,
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
projectId: projectIdForCalc || undefined,
|
||||
});
|
||||
if (seq !== procCalcSeq) return;
|
||||
if (!data) return;
|
||||
@@ -439,6 +453,10 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
// t-paliad-265: rehydrate per-event-card chip indicators after the
|
||||
// innerHTML rewrite. Safe to call before attachEventCardChoices() —
|
||||
// it no-ops when no state was attached yet.
|
||||
reseedChips(container);
|
||||
printBtn.style.display = "block";
|
||||
if (saveBtn) {
|
||||
// Ad-hoc explore-mode has no project to save against — show the
|
||||
@@ -461,6 +479,49 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
applyPendingFocus();
|
||||
}
|
||||
|
||||
// initEventCardChoicesForFristenrechner attaches the per-event-card
|
||||
// popover to the timeline container. The fristenrechner page is the
|
||||
// project-bound surface: commits POST/DELETE to the persistence
|
||||
// endpoint; the next calculate() pulls the fresh state from the
|
||||
// server. (t-paliad-265)
|
||||
async function initEventCardChoicesForFristenrechner(container: HTMLElement): Promise<void> {
|
||||
// Load the current persisted state for the project context, if any.
|
||||
const initial: EventChoice[] = [];
|
||||
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices`);
|
||||
if (resp.ok) {
|
||||
const rows = (await resp.json()) as EventChoice[];
|
||||
for (const r of rows) initial.push(r);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("event-choices: initial load failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
attachEventCardChoices({
|
||||
container,
|
||||
initial,
|
||||
commit: async (choice) => {
|
||||
if (currentStep1Context.kind !== "project" || !currentStep1Context.projectId) return;
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(choice),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`event-choices PUT ${resp.status}`);
|
||||
scheduleProcCalc(0);
|
||||
},
|
||||
remove: async (submissionCode, kind) => {
|
||||
if (currentStep1Context.kind !== "project" || !currentStep1Context.projectId) return;
|
||||
const url = `/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices/${encodeURIComponent(submissionCode)}/${encodeURIComponent(kind)}`;
|
||||
const resp = await fetch(url, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 404) throw new Error(`event-choices DELETE ${resp.status}`);
|
||||
scheduleProcCalc(0);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// onDateEditCommit is the click-to-edit callback handed to the shared
|
||||
// wireDateEditClicks() helper: persist the per-rule override (empty value
|
||||
// clears it) then recompute so downstream rules re-anchor.
|
||||
@@ -648,6 +709,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const timelineContainer = document.getElementById("timeline-container");
|
||||
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
|
||||
|
||||
// t-paliad-265 — per-event-card choices. Project-bound surface, so
|
||||
// commits POST to /api/projects/{id}/event-choices. The popover
|
||||
// module owns the popover; this page owns the recalc trigger. When
|
||||
// there's no project context yet (Step 1 not picked), the popover
|
||||
// still works but commits silently no-op (project_id missing).
|
||||
if (timelineContainer) {
|
||||
void initEventCardChoicesForFristenrechner(timelineContainer);
|
||||
}
|
||||
|
||||
// Reset button
|
||||
document.getElementById("reset-btn")!.addEventListener("click", reset);
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
@@ -207,6 +207,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
|
||||
"deadlines.step1": "Verfahrensart w\u00e4hlen",
|
||||
"deadlines.step2": "Ausgangsdatum eingeben",
|
||||
"deadlines.step2.perspective": "Perspektive und Datum",
|
||||
"deadlines.step3": "Ergebnis",
|
||||
"deadlines.upc": "UPC",
|
||||
"deadlines.de": "Deutsche Gerichte",
|
||||
@@ -236,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)",
|
||||
@@ -253,6 +261,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.party.both.label": "beide Seiten",
|
||||
"deadlines.court.set": "vom Gericht bestimmt",
|
||||
"deadlines.court.indirect": "unbestimmt",
|
||||
"deadlines.conditional.depends_on": "abhängig von {parent}",
|
||||
"deadlines.conditional.unset": "abhängig von vorgelagertem Ereignis",
|
||||
"deadlines.optional.badge": "auf Antrag",
|
||||
"deadlines.priority.mandatory": "Pflicht",
|
||||
"deadlines.priority.recommended": "empfohlen",
|
||||
@@ -302,10 +312,39 @@ 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 …",
|
||||
"choices.appellant.claimant": "Klägerseite",
|
||||
"choices.appellant.defendant": "Beklagtenseite",
|
||||
"choices.appellant.both": "beide Parteien",
|
||||
"choices.appellant.none": "keine Berufung",
|
||||
"choices.include_ccr.title": "Nichtigkeitswiderklage einbeziehen",
|
||||
"choices.include_ccr.true": "Ja",
|
||||
"choices.include_ccr.false": "Nein",
|
||||
"choices.skip.title": "Für diese Akte überspringen",
|
||||
"choices.skip.true": "Überspringen",
|
||||
"choices.skip.false": "Einbeziehen",
|
||||
"choices.skipped.chip": "übersprungen",
|
||||
"choices.appellant.chip": "Berufung:",
|
||||
"choices.include_ccr.chip": "mit Nichtigkeitswiderklage",
|
||||
"choices.reset": "Auswahl zurücksetzen",
|
||||
"choices.commit.error": "Konnte Auswahl nicht speichern",
|
||||
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
|
||||
"choices.show_hidden.label": "Ausgeblendete anzeigen",
|
||||
"choices.show_hidden.count": "Ausgeblendete ({n})",
|
||||
"choices.unhide.chip": "Wieder einblenden",
|
||||
// t-paliad-293 \u2014 iconified state markers on the Verfahrensablauf
|
||||
// event cards. Tooltip-only text; the glyph is the primary signal.
|
||||
"state.optional.tooltip": "Optionales Ereignis",
|
||||
"state.hidden.tooltip": "Ausgeblendet \u2014 \u00fcber Optionen-Men\u00fc wieder einblenden",
|
||||
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||
"deadlines.mode.event": "Was kommt nach\u2026",
|
||||
@@ -420,11 +459,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Seite:",
|
||||
"deadlines.side.claimant": "Klägerseite",
|
||||
"deadlines.side.defendant": "Beklagtenseite",
|
||||
"deadlines.side.both": "Beide",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.side.undefined": "Nicht festgelegt",
|
||||
"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.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -1117,6 +1155,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.appointment_updated": "Termin ge\u00e4ndert",
|
||||
"event.title.appointment_deleted": "Termin gel\u00f6scht",
|
||||
"event.title.appointment_project_changed": "Termin verschoben",
|
||||
// Umbrella audit kind + admin churn surfaced by the FilterBar
|
||||
// project_event_kind chip cluster (KnownProjectEventKinds).
|
||||
"event.title.approval_decided": "Genehmigung entschieden",
|
||||
"event.title.member_role_changed": "Teamrolle ge\u00e4ndert",
|
||||
// 4-eye approval lifecycle (t-paliad-138). Verlauf renders these as
|
||||
// a paired card with the original lifecycle event (e.g.
|
||||
// "Frist angelegt" + "Genehmigung erteilt von Bert").
|
||||
@@ -1469,6 +1511,27 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.name.placeholder": "Name dieses Entwurfs",
|
||||
"submissions.draft.preview.title": "Vorschau",
|
||||
"submissions.draft.preview.hint": "Read-only Vorschau — finale Bearbeitung in Word.",
|
||||
// t-paliad-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Aus Projekt importieren",
|
||||
"submissions.draft.parties.title": "Parteien",
|
||||
"submissions.draft.parties.hint": "Wählen Sie die im Schriftsatz genannten Parteien oder fügen Sie pro Seite weitere hinzu.",
|
||||
// t-paliad-276 — DE/EN language toggle on the draft editor.
|
||||
"submissions.draft.language": "Sprache",
|
||||
"submissions.draft.language.de": "DE",
|
||||
"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",
|
||||
@@ -2703,11 +2766,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.scope.my_subtree": "Mein Teilbaum",
|
||||
"views.scope.explicit": "Bestimmte Projekte",
|
||||
"views.scope.personal_only": "Nur persönliche",
|
||||
"views.horizon.next_1d": "Morgen",
|
||||
"views.horizon.next_7d": "Nächste 7 Tage",
|
||||
"views.horizon.next_14d": "Nächste 14 Tage",
|
||||
"views.horizon.next_30d": "Nächste 30 Tage",
|
||||
"views.horizon.next_90d": "Nächste 90 Tage",
|
||||
"views.horizon.next_all": "Ganze Zukunft",
|
||||
"views.horizon.past_1d": "Letzter Tag",
|
||||
"views.horizon.past_7d": "Letzte 7 Tage",
|
||||
"views.horizon.past_14d": "Letzte 14 Tage",
|
||||
"views.horizon.past_30d": "Letzte 30 Tage",
|
||||
"views.horizon.past_90d": "Letzte 90 Tage",
|
||||
"views.horizon.past_all": "Ganze Vergangenheit",
|
||||
"views.horizon.any": "Beliebig",
|
||||
"views.horizon.all": "Komplett (alle Daten)",
|
||||
"views.horizon.custom": "Benutzerdefiniert",
|
||||
@@ -2791,16 +2861,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.label.density": "Dichte",
|
||||
"views.bar.label.sort": "Sortierung",
|
||||
"views.bar.common.all": "Alle",
|
||||
"views.bar.time.next_7d": "7 Tage",
|
||||
"views.bar.time.next_30d": "30 Tage",
|
||||
"views.bar.time.next_90d": "90 Tage",
|
||||
"views.bar.time.past_7d": "Letzte 7 T.",
|
||||
"views.bar.time.past_30d": "Letzte 30 T.",
|
||||
"views.bar.time.past_90d": "Letzte 90 T.",
|
||||
"views.bar.time.any": "Beliebig",
|
||||
"views.bar.time.all": "Alle Zeit",
|
||||
"views.bar.time.custom": "Anpassen",
|
||||
"views.bar.time.custom.coming_soon": "Benutzerdefinierter Zeitraum folgt in einer der nächsten Iterationen.",
|
||||
// views.bar.time.* keys retired in t-paliad-248 — the filter-bar time
|
||||
// axis now mounts the symmetric date-range picker, whose labels live
|
||||
// under date_range.horizon.* (see end of this dict). The picker reuses
|
||||
// views.bar.label.time as the closed-button prefix.
|
||||
"views.bar.personal.on": "Nur eigene",
|
||||
"views.bar.approval_role.approver_eligible": "Zur Genehmigung",
|
||||
"views.bar.approval_role.self_requested": "Eigene Anfragen",
|
||||
@@ -2840,21 +2904,25 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.save.error.network": "Netzwerkfehler — bitte erneut versuchen.",
|
||||
|
||||
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
||||
"nav.admin.rules": "Regeln verwalten",
|
||||
"nav.admin.rules_export": "Regel-Migrations",
|
||||
"admin.card.rules.title": "Regeln verwalten",
|
||||
"admin.card.rules.desc": "Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
|
||||
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
|
||||
// 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",
|
||||
"admin.card.rules.title": "Verfahrensschritte verwalten",
|
||||
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
|
||||
|
||||
"admin.rules.list.title": "Regeln verwalten — Paliad",
|
||||
"admin.rules.list.heading": "Regeln verwalten",
|
||||
"admin.rules.list.subtitle": "Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ Neue Regel",
|
||||
"admin.rules.list.export": "Migrations exportieren",
|
||||
"admin.rules.list.title": "Verfahrensschritte verwalten — Paliad",
|
||||
"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.tab.rules": "Regeln",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Lade…",
|
||||
"admin.rules.empty": "Keine Regeln für die gewählten Filter.",
|
||||
"admin.rules.error.load": "Konnte Regeln nicht laden.",
|
||||
"admin.rules.empty": "Keine Verfahrensschritte für die gewählten Filter.",
|
||||
"admin.rules.error.load": "Konnte Verfahrensschritte nicht laden.",
|
||||
|
||||
"admin.rules.filter.proceeding": "Verfahrenstyp",
|
||||
"admin.rules.filter.proceeding.any": "Alle",
|
||||
@@ -2865,7 +2933,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.filter.search": "Suche",
|
||||
"admin.rules.filter.search.placeholder": "Name, Submission Code, Rechtsgrundlage…",
|
||||
|
||||
"admin.rules.col.submission_code": "Submission Code / Einreichung-Kennung",
|
||||
"admin.rules.col.submission_code": "Code (Verfahrensschritt)",
|
||||
"admin.rules.col.legal_citation": "Rechtsgrundlage",
|
||||
"admin.rules.col.name": "Name",
|
||||
"admin.rules.col.proceeding": "Verfahrenstyp",
|
||||
@@ -2895,8 +2963,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.orphans.reason.manual_unbound": "Manuell entkoppelt",
|
||||
"admin.rules.orphans.resolved": "Orphan zugeordnet.",
|
||||
|
||||
"admin.rules.modal.new.title": "Neue Regel anlegen",
|
||||
"admin.rules.modal.new.body": "Eine neue Regel wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.",
|
||||
"admin.rules.modal.new.title": "Neuen Verfahrensschritt anlegen",
|
||||
"admin.rules.modal.new.body": "Ein neuer Verfahrensschritt wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.",
|
||||
"admin.rules.modal.resolve.title": "Orphan zuordnen",
|
||||
"admin.rules.modal.resolve.body": "Bitte einen Grund (mind. 10 Zeichen) angeben. Die Regel-Verknüpfung wird sofort auf der Deadline gespeichert.",
|
||||
"admin.rules.modal.reason": "Grund",
|
||||
@@ -2911,12 +2979,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.modal.error.create": "Anlegen fehlgeschlagen.",
|
||||
"admin.rules.modal.error.resolve": "Zuordnung fehlgeschlagen.",
|
||||
|
||||
"admin.rules.edit.title": "Regel bearbeiten — Paliad",
|
||||
"admin.rules.edit.heading.loading": "Regel laden…",
|
||||
"admin.rules.edit.breadcrumb": "← Regeln verwalten",
|
||||
"admin.rules.edit.error.bad_id": "Ungültige Regel-ID in der URL.",
|
||||
"admin.rules.edit.error.not_found": "Regel nicht gefunden.",
|
||||
"admin.rules.edit.error.load": "Konnte Regel nicht laden.",
|
||||
"admin.rules.edit.title": "Verfahrensschritt bearbeiten — Paliad",
|
||||
"admin.rules.edit.heading.loading": "Verfahrensschritt laden…",
|
||||
"admin.rules.edit.breadcrumb": "← Verfahrensschritte verwalten",
|
||||
"admin.rules.edit.error.bad_id": "Ungültige Verfahrensschritt-ID in der URL.",
|
||||
"admin.rules.edit.error.not_found": "Verfahrensschritt nicht gefunden.",
|
||||
"admin.rules.edit.error.load": "Konnte Verfahrensschritt nicht laden.",
|
||||
|
||||
"admin.rules.edit.section.identity": "Identität",
|
||||
"admin.rules.edit.section.proceeding": "Verfahren & Trigger",
|
||||
@@ -2929,14 +2997,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.field.name": "Name (DE)",
|
||||
"admin.rules.edit.field.name_en": "Name (EN)",
|
||||
"admin.rules.edit.field.description": "Beschreibung",
|
||||
"admin.rules.edit.field.submission_code": "Submission Code / Einreichung-Kennung",
|
||||
"admin.rules.edit.field.submission_code": "Code (Verfahrensschritt-Identifikator)",
|
||||
"admin.rules.edit.field.rule_code": "Rechtsgrundlage (Kurzform)",
|
||||
"admin.rules.edit.field.legal_source": "Rechtsgrundlage (Langform)",
|
||||
"admin.rules.edit.field.proceeding": "Verfahrenstyp",
|
||||
"admin.rules.edit.field.proceeding.none": "—",
|
||||
"admin.rules.edit.field.trigger": "Trigger-Ereignis",
|
||||
"admin.rules.edit.field.trigger.none": "—",
|
||||
"admin.rules.edit.field.parent": "Parent-Regel (UUID)",
|
||||
"admin.rules.edit.field.parent": "Übergeordneter Verfahrensschritt (UUID)",
|
||||
"admin.rules.edit.field.concept": "Konzept (UUID)",
|
||||
"admin.rules.edit.field.sequence_order": "Reihenfolge",
|
||||
"admin.rules.edit.field.duration_value": "Dauer",
|
||||
@@ -2948,7 +3016,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.field.alt_rule_code": "Alt-Rule-Code",
|
||||
"admin.rules.edit.field.anchor_alt": "Alt-Anchor",
|
||||
"admin.rules.edit.field.primary_party": "Primäre Partei",
|
||||
"admin.rules.edit.field.event_type": "Event-Typ (frei)",
|
||||
"admin.rules.edit.field.event_type": "Art des Verfahrensschritts (filing / hearing / decision / order)",
|
||||
"admin.rules.edit.field.deadline_notes": "Hinweise (DE)",
|
||||
"admin.rules.edit.field.deadline_notes_en": "Hinweise (EN)",
|
||||
"admin.rules.edit.field.priority": "Priorität",
|
||||
@@ -3011,22 +3079,52 @@ 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
|
||||
// /admin/audit-log to the same component.
|
||||
"date_range.button.label": "Zeitraum",
|
||||
"date_range.button.label.custom_range": "Von {from} bis {to}",
|
||||
"date_range.horizon.next_1d": "Heute",
|
||||
"date_range.horizon.next_7d": "Nächste 7 Tage",
|
||||
"date_range.horizon.next_14d": "Nächste 14 Tage",
|
||||
"date_range.horizon.next_30d": "Nächste 30 Tage",
|
||||
"date_range.horizon.next_90d": "Nächste 90 Tage",
|
||||
"date_range.horizon.next_all": "Ganze Zukunft",
|
||||
"date_range.horizon.past_1d": "Letzter Tag",
|
||||
"date_range.horizon.past_7d": "Letzte 7 Tage",
|
||||
"date_range.horizon.past_14d": "Letzte 14 Tage",
|
||||
"date_range.horizon.past_30d": "Letzte 30 Tage",
|
||||
"date_range.horizon.past_90d": "Letzte 90 Tage",
|
||||
"date_range.horizon.past_all": "Ganze Vergangenheit",
|
||||
"date_range.horizon.any": "Alles",
|
||||
"date_range.horizon.custom": "Anpassen",
|
||||
"date_range.dialog.label": "Zeitraum wählen",
|
||||
"date_range.fan.past.label": "Vergangenheit",
|
||||
"date_range.fan.future.label": "Zukunft",
|
||||
"date_range.center.label": "Alles",
|
||||
"date_range.custom.from": "Von",
|
||||
"date_range.custom.to": "Bis",
|
||||
"date_range.custom.apply": "Anwenden",
|
||||
"date_range.custom.cancel": "Abbrechen",
|
||||
"date_range.custom.invalid": "Bis-Datum muss nach Von-Datum liegen.",
|
||||
"date_range.custom.invalid_format": "Datum nicht erkannt (Format JJJJ-MM-TT).",
|
||||
"date_range.custom.invalid_missing": "Bitte beide Datumsfelder ausfüllen.",
|
||||
|
||||
// t-paliad-262 Slice A — canonical `procedural_event` i18n contract.
|
||||
// The values are identical to the legacy `admin.rules.*` keys above —
|
||||
// these aliases let .tsx files rebind in Slice B (B.5) without
|
||||
// touching DE/EN strings then. Adding/changing values? Update BOTH
|
||||
// sides.
|
||||
"admin.procedural_events.list.title": "Verfahrensschritte verwalten — Paliad",
|
||||
"admin.procedural_events.list.heading": "Verfahrensschritte verwalten",
|
||||
"admin.procedural_events.list.new": "+ Neuer Verfahrensschritt",
|
||||
"admin.procedural_events.col.code": "Code (Verfahrensschritt)",
|
||||
"admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad",
|
||||
"admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten",
|
||||
"admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)",
|
||||
"admin.procedural_events.edit.field.event_kind": "Art des Verfahrensschritts (filing / hearing / decision / order)",
|
||||
"admin.procedural_events.edit.field.parent": "Übergeordneter Verfahrensschritt (UUID)",
|
||||
},
|
||||
|
||||
en: {
|
||||
@@ -3089,7 +3187,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}`,
|
||||
@@ -3217,6 +3315,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
|
||||
"deadlines.step1": "Select Proceeding Type",
|
||||
"deadlines.step2": "Enter Trigger Date",
|
||||
"deadlines.step2.perspective": "Perspective and Date",
|
||||
"deadlines.step3": "Result",
|
||||
"deadlines.upc": "UPC",
|
||||
"deadlines.de": "German Courts",
|
||||
@@ -3245,6 +3344,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",
|
||||
@@ -3263,6 +3369,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.party.both.label": "both parties",
|
||||
"deadlines.court.set": "set by court",
|
||||
"deadlines.court.indirect": "tbd",
|
||||
"deadlines.conditional.depends_on": "depends on {parent}",
|
||||
"deadlines.conditional.unset": "depends on an upstream event",
|
||||
"deadlines.optional.badge": "on request",
|
||||
"deadlines.priority.mandatory": "Mandatory",
|
||||
"deadlines.priority.recommended": "Recommended",
|
||||
@@ -3312,10 +3420,39 @@ 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 …",
|
||||
"choices.appellant.claimant": "Claimant side",
|
||||
"choices.appellant.defendant": "Defendant side",
|
||||
"choices.appellant.both": "both parties",
|
||||
"choices.appellant.none": "no appeal",
|
||||
"choices.include_ccr.title": "Include nullity counterclaim",
|
||||
"choices.include_ccr.true": "Yes",
|
||||
"choices.include_ccr.false": "No",
|
||||
"choices.skip.title": "Skip for this case",
|
||||
"choices.skip.true": "Skip",
|
||||
"choices.skip.false": "Include",
|
||||
"choices.skipped.chip": "skipped",
|
||||
"choices.appellant.chip": "Appeal:",
|
||||
"choices.include_ccr.chip": "with nullity counterclaim",
|
||||
"choices.reset": "Reset choice",
|
||||
"choices.commit.error": "Could not save selection",
|
||||
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
|
||||
"choices.show_hidden.label": "Show hidden",
|
||||
"choices.show_hidden.count": "Hidden ({n})",
|
||||
"choices.unhide.chip": "Show again",
|
||||
// t-paliad-293 — iconified state markers on the Verfahrensablauf
|
||||
// event cards. Tooltip-only text; the glyph is the primary signal.
|
||||
"state.optional.tooltip": "Optional event",
|
||||
"state.hidden.tooltip": "Hidden — restore via the options menu",
|
||||
"deadlines.adjusted": "Adjusted",
|
||||
"deadlines.adjusted.reason": "weekend/holiday",
|
||||
"deadlines.adjusted.weekend": "weekend",
|
||||
@@ -3437,11 +3574,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Side:",
|
||||
"deadlines.side.claimant": "Claimant",
|
||||
"deadlines.side.defendant": "Defendant",
|
||||
"deadlines.side.both": "Both",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.side.undefined": "Undefined",
|
||||
"deadlines.side.from_project": "From case:",
|
||||
"deadlines.side.override": "Choose other side",
|
||||
"deadlines.side.hint": "Pick a side to focus the columns.",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
@@ -4110,6 +4246,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.appointment_updated": "Appointment updated",
|
||||
"event.title.appointment_deleted": "Appointment deleted",
|
||||
"event.title.appointment_project_changed": "Appointment moved",
|
||||
// Umbrella audit kind + admin churn surfaced by the FilterBar
|
||||
// project_event_kind chip cluster (KnownProjectEventKinds).
|
||||
"event.title.approval_decided": "Approval decided",
|
||||
"event.title.member_role_changed": "Team role changed",
|
||||
// 4-eye approval lifecycle (t-paliad-138).
|
||||
"event.title.deadline_approval_requested": "Approval requested",
|
||||
"event.title.deadline_approval_approved": "Approval granted",
|
||||
@@ -4459,7 +4599,28 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.switcher.label": "Draft",
|
||||
"submissions.draft.name.placeholder": "Name of this draft",
|
||||
"submissions.draft.preview.title": "Preview",
|
||||
// t-paliad-276 — DE/EN language toggle on the draft editor.
|
||||
"submissions.draft.language": "Language",
|
||||
"submissions.draft.language.de": "DE",
|
||||
"submissions.draft.language.en": "EN",
|
||||
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
|
||||
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
|
||||
// t-paliad-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Import from project",
|
||||
"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",
|
||||
@@ -5685,11 +5846,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.scope.my_subtree": "My subtree",
|
||||
"views.scope.explicit": "Specific projects",
|
||||
"views.scope.personal_only": "Personal only",
|
||||
"views.horizon.next_1d": "Tomorrow",
|
||||
"views.horizon.next_7d": "Next 7 days",
|
||||
"views.horizon.next_14d": "Next 14 days",
|
||||
"views.horizon.next_30d": "Next 30 days",
|
||||
"views.horizon.next_90d": "Next 90 days",
|
||||
"views.horizon.next_all": "All future",
|
||||
"views.horizon.past_1d": "Last day",
|
||||
"views.horizon.past_7d": "Last 7 days",
|
||||
"views.horizon.past_14d": "Last 14 days",
|
||||
"views.horizon.past_30d": "Last 30 days",
|
||||
"views.horizon.past_90d": "Last 90 days",
|
||||
"views.horizon.past_all": "All past",
|
||||
"views.horizon.any": "Any",
|
||||
"views.horizon.all": "All-time",
|
||||
"views.horizon.custom": "Custom",
|
||||
@@ -5772,16 +5940,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.label.density": "Density",
|
||||
"views.bar.label.sort": "Sort",
|
||||
"views.bar.common.all": "All",
|
||||
"views.bar.time.next_7d": "7 days",
|
||||
"views.bar.time.next_30d": "30 days",
|
||||
"views.bar.time.next_90d": "90 days",
|
||||
"views.bar.time.past_7d": "Past 7d",
|
||||
"views.bar.time.past_30d": "Past 30 d.",
|
||||
"views.bar.time.past_90d": "Past 90 d.",
|
||||
"views.bar.time.any": "Any",
|
||||
"views.bar.time.all": "All time",
|
||||
"views.bar.time.custom": "Custom",
|
||||
"views.bar.time.custom.coming_soon": "Custom date range arrives in a follow-up iteration.",
|
||||
// views.bar.time.* keys retired in t-paliad-248 — see the DE block
|
||||
// for context. The filter-bar time axis now mounts the symmetric
|
||||
// date-range picker whose labels live under date_range.horizon.*.
|
||||
"views.bar.personal.on": "Mine only",
|
||||
"views.bar.approval_role.approver_eligible": "To approve",
|
||||
"views.bar.approval_role.self_requested": "My requests",
|
||||
@@ -5821,21 +5982,20 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.save.error.network": "Network error — please retry.",
|
||||
|
||||
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
||||
"nav.admin.rules": "Manage Rules",
|
||||
"nav.admin.rules_export": "Rule Migrations",
|
||||
"admin.card.rules.title": "Manage Rules",
|
||||
"admin.card.rules.desc": "Author, edit and publish deadline rules. Audit log, preview, migration export.",
|
||||
// t-paliad-262 Slice A — "Rule" relabelled as "Procedural event".
|
||||
"nav.admin.rules": "Manage procedural events",
|
||||
"admin.card.rules.title": "Manage procedural events",
|
||||
"admin.card.rules.desc": "Author, edit and publish procedural-event templates. Audit log, preview, migration export.",
|
||||
|
||||
"admin.rules.list.title": "Manage Rules — Paliad",
|
||||
"admin.rules.list.heading": "Manage Rules",
|
||||
"admin.rules.list.subtitle": "Author, edit and publish deadline rules. Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ New Rule",
|
||||
"admin.rules.list.export": "Export migrations",
|
||||
"admin.rules.list.title": "Manage procedural events — Paliad",
|
||||
"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.tab.rules": "Rules",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Loading…",
|
||||
"admin.rules.empty": "No rules for the chosen filters.",
|
||||
"admin.rules.error.load": "Could not load rules.",
|
||||
"admin.rules.empty": "No procedural events for the chosen filters.",
|
||||
"admin.rules.error.load": "Could not load procedural events.",
|
||||
|
||||
"admin.rules.filter.proceeding": "Proceeding type",
|
||||
"admin.rules.filter.proceeding.any": "Any",
|
||||
@@ -5846,7 +6006,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.filter.search": "Search",
|
||||
"admin.rules.filter.search.placeholder": "Name, submission code, legal citation…",
|
||||
|
||||
"admin.rules.col.submission_code": "Submission code",
|
||||
"admin.rules.col.submission_code": "Code (procedural event)",
|
||||
"admin.rules.col.legal_citation": "Legal citation",
|
||||
"admin.rules.col.name": "Name",
|
||||
"admin.rules.col.proceeding": "Proceeding type",
|
||||
@@ -5876,8 +6036,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.orphans.reason.manual_unbound": "Manually unbound",
|
||||
"admin.rules.orphans.resolved": "Orphan resolved.",
|
||||
|
||||
"admin.rules.modal.new.title": "Create new rule",
|
||||
"admin.rules.modal.new.body": "A new rule will be created as a draft. Please supply a reason (≥10 chars) — recorded in the audit log and exported into the migration file.",
|
||||
"admin.rules.modal.new.title": "Create new procedural event",
|
||||
"admin.rules.modal.new.body": "A new procedural event will be created as a draft. Please supply a reason (≥10 chars) — recorded in the audit log and exported into the migration file.",
|
||||
"admin.rules.modal.resolve.title": "Resolve orphan",
|
||||
"admin.rules.modal.resolve.body": "Please supply a reason (≥10 chars). The rule binding is persisted immediately on the deadline.",
|
||||
"admin.rules.modal.reason": "Reason",
|
||||
@@ -5892,12 +6052,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.modal.error.create": "Creation failed.",
|
||||
"admin.rules.modal.error.resolve": "Resolution failed.",
|
||||
|
||||
"admin.rules.edit.title": "Edit Rule — Paliad",
|
||||
"admin.rules.edit.heading.loading": "Loading rule…",
|
||||
"admin.rules.edit.breadcrumb": "← Manage Rules",
|
||||
"admin.rules.edit.error.bad_id": "Invalid rule id in URL.",
|
||||
"admin.rules.edit.error.not_found": "Rule not found.",
|
||||
"admin.rules.edit.error.load": "Could not load rule.",
|
||||
"admin.rules.edit.title": "Edit procedural event — Paliad",
|
||||
"admin.rules.edit.heading.loading": "Loading procedural event…",
|
||||
"admin.rules.edit.breadcrumb": "← Manage procedural events",
|
||||
"admin.rules.edit.error.bad_id": "Invalid procedural-event id in URL.",
|
||||
"admin.rules.edit.error.not_found": "Procedural event not found.",
|
||||
"admin.rules.edit.error.load": "Could not load procedural event.",
|
||||
|
||||
"admin.rules.edit.section.identity": "Identity",
|
||||
"admin.rules.edit.section.proceeding": "Proceeding & Trigger",
|
||||
@@ -5910,14 +6070,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.field.name": "Name (DE)",
|
||||
"admin.rules.edit.field.name_en": "Name (EN)",
|
||||
"admin.rules.edit.field.description": "Description",
|
||||
"admin.rules.edit.field.submission_code": "Submission code",
|
||||
"admin.rules.edit.field.submission_code": "Code (procedural-event identifier)",
|
||||
"admin.rules.edit.field.rule_code": "Legal citation (short form)",
|
||||
"admin.rules.edit.field.legal_source": "Legal citation (long form)",
|
||||
"admin.rules.edit.field.proceeding": "Proceeding type",
|
||||
"admin.rules.edit.field.proceeding.none": "—",
|
||||
"admin.rules.edit.field.trigger": "Trigger event",
|
||||
"admin.rules.edit.field.trigger.none": "—",
|
||||
"admin.rules.edit.field.parent": "Parent rule (UUID)",
|
||||
"admin.rules.edit.field.parent": "Parent procedural event (UUID)",
|
||||
"admin.rules.edit.field.concept": "Concept (UUID)",
|
||||
"admin.rules.edit.field.sequence_order": "Order",
|
||||
"admin.rules.edit.field.duration_value": "Duration",
|
||||
@@ -5929,7 +6089,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.field.alt_rule_code": "Alt rule code",
|
||||
"admin.rules.edit.field.anchor_alt": "Alt anchor",
|
||||
"admin.rules.edit.field.primary_party": "Primary party",
|
||||
"admin.rules.edit.field.event_type": "Event type (free)",
|
||||
"admin.rules.edit.field.event_type": "Procedural-event kind (filing / hearing / decision / order)",
|
||||
"admin.rules.edit.field.deadline_notes": "Notes (DE)",
|
||||
"admin.rules.edit.field.deadline_notes_en": "Notes (EN)",
|
||||
"admin.rules.edit.field.priority": "Priority",
|
||||
@@ -5992,22 +6152,47 @@ 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}",
|
||||
"date_range.horizon.next_1d": "Today",
|
||||
"date_range.horizon.next_7d": "Next 7 days",
|
||||
"date_range.horizon.next_14d": "Next 14 days",
|
||||
"date_range.horizon.next_30d": "Next 30 days",
|
||||
"date_range.horizon.next_90d": "Next 90 days",
|
||||
"date_range.horizon.next_all": "All future",
|
||||
"date_range.horizon.past_1d": "Last day",
|
||||
"date_range.horizon.past_7d": "Last 7 days",
|
||||
"date_range.horizon.past_14d": "Last 14 days",
|
||||
"date_range.horizon.past_30d": "Last 30 days",
|
||||
"date_range.horizon.past_90d": "Last 90 days",
|
||||
"date_range.horizon.past_all": "All past",
|
||||
"date_range.horizon.any": "All",
|
||||
"date_range.horizon.custom": "Customize",
|
||||
"date_range.dialog.label": "Choose time range",
|
||||
"date_range.fan.past.label": "Past",
|
||||
"date_range.fan.future.label": "Future",
|
||||
"date_range.center.label": "All",
|
||||
"date_range.custom.from": "From",
|
||||
"date_range.custom.to": "To",
|
||||
"date_range.custom.apply": "Apply",
|
||||
"date_range.custom.cancel": "Cancel",
|
||||
"date_range.custom.invalid": "End date must be strictly after start date.",
|
||||
"date_range.custom.invalid_format": "Date not recognised (format YYYY-MM-DD).",
|
||||
"date_range.custom.invalid_missing": "Please fill in both date fields.",
|
||||
|
||||
// t-paliad-262 Slice A — canonical `procedural_event` i18n contract.
|
||||
// Mirrors the DE block; values identical to the legacy
|
||||
// `admin.rules.*` keys. Adding/changing values? Update BOTH sides.
|
||||
"admin.procedural_events.list.title": "Manage procedural events — Paliad",
|
||||
"admin.procedural_events.list.heading": "Manage procedural events",
|
||||
"admin.procedural_events.list.new": "+ New procedural event",
|
||||
"admin.procedural_events.col.code": "Code (procedural event)",
|
||||
"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)",
|
||||
"admin.procedural_events.edit.field.event_kind": "Procedural-event kind (filing / hearing / decision / order)",
|
||||
"admin.procedural_events.edit.field.parent": "Parent procedural event (UUID)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -397,18 +397,26 @@ function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] {
|
||||
// horizons that show up on the Verlauf bar. Forward-looking horizons
|
||||
// (next_*) are absent on this surface — the timePresets override hides
|
||||
// them — but the function tolerates them for forward-compatibility with
|
||||
// the SmartTimeline redesign.
|
||||
// the SmartTimeline redesign. Open-ended ranges (next_all / past_all)
|
||||
// leave the matching bound undefined; the upstream filter treats that
|
||||
// as "no narrowing in that direction".
|
||||
function horizonBounds(horizon: string): { from?: Date; to?: Date } {
|
||||
const now = new Date();
|
||||
const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const offset = (days: number) => new Date(day.getTime() + days * 86400000);
|
||||
switch (horizon) {
|
||||
case "past_1d": return { from: offset(-1), to: offset(1) };
|
||||
case "past_7d": return { from: offset(-7), to: offset(1) };
|
||||
case "past_14d": return { from: offset(-14), to: offset(1) };
|
||||
case "past_30d": return { from: offset(-30), to: offset(1) };
|
||||
case "past_90d": return { from: offset(-90), to: offset(1) };
|
||||
case "past_all": return { to: offset(1) };
|
||||
case "next_1d": return { from: day, to: offset(1) };
|
||||
case "next_7d": return { from: day, to: offset(7) };
|
||||
case "next_14d": return { from: day, to: offset(14) };
|
||||
case "next_30d": return { from: day, to: offset(30) };
|
||||
case "next_90d": return { from: day, to: offset(90) };
|
||||
case "next_all": return { from: day };
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,6 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
@@ -21,38 +20,71 @@ import {
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
import {
|
||||
attachEventCardChoices,
|
||||
reseedChips,
|
||||
type EventChoice,
|
||||
} 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.
|
||||
let currentSide: Side = null;
|
||||
let currentAppellant: Side = null;
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// 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;
|
||||
|
||||
// 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,
|
||||
// the side row renders as a read-only chip instead of the radio cluster.
|
||||
// The user can flip to free-pick via the "Andere Seite wählen" override
|
||||
// link, which clears this flag (radio cluster takes over again).
|
||||
let sidePrefilledFromProject = false;
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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",
|
||||
@@ -61,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
|
||||
@@ -98,6 +223,20 @@ function writeAppellantToURL(a: Side) {
|
||||
const anchorOverrides = new Map<string, string>();
|
||||
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||
|
||||
// Per-event-card choices (t-paliad-265). Unbound on this page (no
|
||||
// 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);
|
||||
|
||||
// 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.
|
||||
// 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";
|
||||
|
||||
@@ -114,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 /
|
||||
@@ -204,20 +358,48 @@ 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,
|
||||
flags: readFlags(),
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
perCardChoices,
|
||||
includeHidden: showHidden,
|
||||
appealTarget,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
lastResponse = data;
|
||||
renderResults(data);
|
||||
syncHiddenBadge(data.hiddenCount ?? 0);
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
// syncHiddenBadge updates the "Ausgeblendete (N)" count next to the
|
||||
// toggle. Visible regardless of toggle state so the user knows whether
|
||||
// there's anything to re-surface even when the toggle is OFF. Hides the
|
||||
// whole row when the projection has zero hidden cards — no clutter on
|
||||
// a project that's never used the skip feature. (t-paliad-290)
|
||||
function syncHiddenBadge(count: number) {
|
||||
const row = document.getElementById("show-hidden-row");
|
||||
const badge = document.getElementById("show-hidden-count");
|
||||
if (!row || !badge) return;
|
||||
if (count <= 0) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
badge.textContent = tDyn("choices.show_hidden.count").replace("{n}", String(count));
|
||||
}
|
||||
|
||||
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||
// label from the calc response. Precedence:
|
||||
//
|
||||
@@ -292,16 +474,33 @@ 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";
|
||||
if (toggle) toggle.style.display = "";
|
||||
|
||||
syncTriggerEventLabel();
|
||||
|
||||
// t-paliad-265: rehydrate per-event-card chip indicators after every
|
||||
// re-render so the popover-driven active state survives the
|
||||
// innerHTML rewrite the timeline body just did.
|
||||
reseedChips(container);
|
||||
}
|
||||
|
||||
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
|
||||
@@ -340,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 || "";
|
||||
@@ -350,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +644,138 @@ function syncRadioGroup(name: string, value: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Project context (t-paliad-279 / m/paliad#111). When the page is opened
|
||||
// with ?project=<id> and the project carries an our_side value, the side
|
||||
// row renders as a read-only chip with an "Andere Seite wählen" override
|
||||
// link. The proceeding picker + appellant axis stay untouched — only the
|
||||
// side selector pre-fills.
|
||||
interface ProjectOurSide {
|
||||
id: string;
|
||||
our_side?:
|
||||
| "claimant"
|
||||
| "defendant"
|
||||
| "applicant"
|
||||
| "appellant"
|
||||
| "respondent"
|
||||
| "third_party"
|
||||
| "other"
|
||||
| null;
|
||||
}
|
||||
|
||||
function readProjectFromURL(): string {
|
||||
return new URLSearchParams(window.location.search).get("project") || "";
|
||||
}
|
||||
|
||||
// ourSideToSide maps the project-level our_side enum (t-paliad-222) onto
|
||||
// the side-selector's two-value axis. Active roles (claimant / applicant /
|
||||
// appellant) collapse to "claimant"; reactive roles (defendant /
|
||||
// respondent) collapse to "defendant"; everything else (third_party /
|
||||
// other / NULL) returns null = no pre-fill. Mirrors fristenrechner.ts
|
||||
// ourSideToPerspective() so projects render consistently across both
|
||||
// surfaces.
|
||||
function ourSideToSide(os: ProjectOurSide["our_side"] | undefined): Side {
|
||||
switch (os) {
|
||||
case "claimant":
|
||||
case "applicant":
|
||||
case "appellant":
|
||||
return "claimant";
|
||||
case "defendant":
|
||||
case "respondent":
|
||||
return "defendant";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as ProjectOurSide;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sideLabelI18n(s: Side): string {
|
||||
if (s === "claimant") return t("deadlines.side.claimant");
|
||||
if (s === "defendant") return t("deadlines.side.defendant");
|
||||
return t("deadlines.side.undefined");
|
||||
}
|
||||
|
||||
// syncSideHintVisibility shows the "pick a side" hint chip only while
|
||||
// currentSide is unset (m/paliad#120). When the user has picked
|
||||
// claimant / defendant the columns are already focused, so the prompt
|
||||
// would be misleading.
|
||||
function syncSideHintVisibility() {
|
||||
const hint = document.getElementById("side-hint");
|
||||
if (!hint) return;
|
||||
hint.style.display = currentSide === null ? "" : "none";
|
||||
}
|
||||
|
||||
// renderSideChip swaps the radio cluster for a read-only chip showing
|
||||
// the auto-filled side + an "Andere Seite wählen" override link. Called
|
||||
// after fetchProjectOurSide resolves to a side. The override link clears
|
||||
// the prefilled flag and swaps back to the radio cluster — the user can
|
||||
// then pick any side freely.
|
||||
function renderSideChip(side: Side) {
|
||||
const cluster = document.getElementById("side-radio-cluster");
|
||||
const chip = document.getElementById("side-chip");
|
||||
const value = document.getElementById("side-chip-value");
|
||||
if (!cluster || !chip || !value) return;
|
||||
cluster.style.display = "none";
|
||||
chip.style.display = "";
|
||||
value.textContent = sideLabelI18n(side);
|
||||
}
|
||||
|
||||
function showSideRadioCluster() {
|
||||
const cluster = document.getElementById("side-radio-cluster");
|
||||
const chip = document.getElementById("side-chip");
|
||||
if (!cluster || !chip) return;
|
||||
cluster.style.display = "";
|
||||
chip.style.display = "none";
|
||||
// Cluster re-appears after override → re-evaluate hint visibility so
|
||||
// we don't leave a stale "pick a side" prompt above a checked radio.
|
||||
syncSideHintVisibility();
|
||||
}
|
||||
|
||||
// applySidePrefill takes a project's our_side, maps it to the side axis,
|
||||
// and locks the side row to a read-only chip if a mapping exists. URL
|
||||
// wins — if ?side= is already explicit, the user (or shared link) has
|
||||
// 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 (parseSideFromSearch(window.location.search) !== null) return;
|
||||
const next = ourSideToSide(os);
|
||||
if (next === null) return;
|
||||
currentSide = next;
|
||||
applyURLFilters({ side: next });
|
||||
syncRadioGroup("side", next);
|
||||
sidePrefilledFromProject = true;
|
||||
renderSideChip(next);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
}
|
||||
|
||||
function clearSidePrefill() {
|
||||
sidePrefilledFromProject = false;
|
||||
showSideRadioCluster();
|
||||
// Drop ?project= from the URL so a reload doesn't re-lock the side.
|
||||
// ?side= stays — that's the user's last pick at this point.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("project");
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
async function initProjectAutofill() {
|
||||
const projectID = readProjectFromURL();
|
||||
if (!projectID) return;
|
||||
const project = await fetchProjectOurSide(projectID);
|
||||
if (!project) return;
|
||||
applySidePrefill(project.our_side);
|
||||
}
|
||||
|
||||
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
|
||||
// Mirrors the events.ts pattern (body.events-view-*). The print
|
||||
// stylesheet keys `body.verfahrensablauf-view-timeline` to
|
||||
@@ -432,28 +820,37 @@ 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) => {
|
||||
input.addEventListener("change", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -474,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());
|
||||
|
||||
@@ -526,9 +952,74 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Durations toggle (m/paliad#133, t-paliad-302) — sibling of the
|
||||
// notes toggle. Hover-only labels (default) become inline labels when
|
||||
// the user opts in.
|
||||
const durationsShowCb = document.getElementById("verfahrensablauf-durations-show") as HTMLInputElement | null;
|
||||
if (durationsShowCb) {
|
||||
durationsShowCb.checked = showDurations;
|
||||
durationsShowCb.addEventListener("change", () => {
|
||||
showDurations = durationsShowCb.checked;
|
||||
writeDurationsPref(showDurations);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-290 — show-hidden toggle. 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;
|
||||
writeBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden, showHidden);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
// 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({
|
||||
container: timelineEl,
|
||||
initial: perCardChoices,
|
||||
commit: (choice) => {
|
||||
perCardChoices = perCardChoices.filter(
|
||||
(c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind),
|
||||
);
|
||||
perCardChoices.push(choice);
|
||||
writeEventChoices(scenarioStorage, perCardChoices);
|
||||
scheduleCalc(0);
|
||||
},
|
||||
remove: (submissionCode, kind) => {
|
||||
perCardChoices = perCardChoices.filter(
|
||||
(c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
|
||||
);
|
||||
writeEventChoices(scenarioStorage, perCardChoices);
|
||||
scheduleCalc(0);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-279 — override link on the prefilled side chip — swaps back
|
||||
// to the radio cluster and clears ?project= from the URL.
|
||||
document.getElementById("side-chip-override")?.addEventListener("click", clearSidePrefill);
|
||||
|
||||
// Project autofill — runs after the radio cluster has its URL-driven
|
||||
// state so we never clobber an explicit ?side= pick. Fire-and-forget;
|
||||
// the chip swap happens once the project resolves.
|
||||
void initProjectAutofill();
|
||||
|
||||
|
||||
onLangChange(() => {
|
||||
// Active-button name updates with language change (the data-i18n
|
||||
// pass swaps the inner <strong>'s text). Re-collapse the summary
|
||||
@@ -539,12 +1030,41 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const summary = document.getElementById("proceeding-summary-name");
|
||||
if (summary) summary.textContent = proceedingDisplayName(activeBtn);
|
||||
}
|
||||
// Side-chip label tracks language so a DE/EN flip while the chip is
|
||||
// visible re-renders the inferred side in the active language.
|
||||
if (sidePrefilledFromProject) {
|
||||
const value = document.getElementById("side-chip-value");
|
||||
if (value) value.textContent = sideLabelI18n(currentSide);
|
||||
}
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
320
frontend/src/client/views/event-card-choices.ts
Normal file
320
frontend/src/client/views/event-card-choices.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
// Per-event-card choice popover + chip indicator (t-paliad-265 /
|
||||
// m/paliad#96).
|
||||
//
|
||||
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
|
||||
// button on cards that carry a non-empty `choices_offered` declaration
|
||||
// and an inert chip span next to the title. This module:
|
||||
//
|
||||
// 1. Wires a delegated click handler on the result container so the
|
||||
// caret opens a popover with the offered choice-kinds.
|
||||
// 2. Commits the user's pick — either by POSTing to the project-
|
||||
// bound endpoint or by mutating the in-memory state for the
|
||||
// unbound (no-project) case.
|
||||
// 3. Rehydrates the chip on every render + after every commit so the
|
||||
// glanceable indicator matches the active state.
|
||||
//
|
||||
// Two consumer pages — /tools/verfahrensablauf (unbound) and
|
||||
// /tools/fristenrechner (project-bound) — both wire this module
|
||||
// once at boot via attachEventCardChoices().
|
||||
|
||||
import { escAttr, escHtml } from "./verfahrensablauf-core";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
|
||||
|
||||
export interface EventChoice {
|
||||
submission_code: string;
|
||||
choice_kind: ChoiceKind;
|
||||
choice_value: string;
|
||||
}
|
||||
|
||||
// State surface — the page passes in callbacks that own persistence.
|
||||
// commit / remove must trigger a recalc on the page side (the popover
|
||||
// only owns its own visual state).
|
||||
export interface EventCardChoicesOpts {
|
||||
container: HTMLElement;
|
||||
// Initial state: a list of choices. The page seeds this from the
|
||||
// server response (project-bound) or from URL params (unbound).
|
||||
initial: EventChoice[];
|
||||
// commit gets called for an UPSERT. The page POSTs to the API (or
|
||||
// mutates URL state) AND triggers a recalc.
|
||||
commit: (choice: EventChoice) => Promise<void> | void;
|
||||
// remove gets called when the user resets a choice.
|
||||
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// One mutable bag per attach() call. The current implementation is a
|
||||
// single-page singleton — paginated views (admin tables) are not in
|
||||
// scope. Last-write-wins on the in-memory state.
|
||||
interface AttachedState {
|
||||
opts: EventCardChoicesOpts;
|
||||
// active: submission_code → kind → value. Rebuilt from `initial`
|
||||
// on every reseed() call.
|
||||
active: Map<string, Map<ChoiceKind, string>>;
|
||||
popover: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, AttachedState>();
|
||||
|
||||
// attachEventCardChoices wires the delegated click + popover lifecycle
|
||||
// to the given container. Call once per page after mount; safe to call
|
||||
// again with a fresh container.
|
||||
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
|
||||
const state: AttachedState = {
|
||||
opts,
|
||||
active: new Map(),
|
||||
popover: null,
|
||||
};
|
||||
for (const c of opts.initial) {
|
||||
if (!state.active.has(c.submission_code)) {
|
||||
state.active.set(c.submission_code, new Map());
|
||||
}
|
||||
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
|
||||
}
|
||||
states.set(opts.container, state);
|
||||
|
||||
opts.container.addEventListener("click", (e) => {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (caret) {
|
||||
e.stopPropagation();
|
||||
openPopover(state, caret);
|
||||
return;
|
||||
}
|
||||
// Outside-click closes the popover.
|
||||
if (state.popover && !state.popover.contains(e.target as Node)) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// ESC also closes.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.popover) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// Repaint chips on every renderResults() call. The page is
|
||||
// responsible for calling reseedChips() after re-render so the chip
|
||||
// dom node (re-created by the renderer) picks the active state up.
|
||||
reseedChips(opts.container);
|
||||
}
|
||||
|
||||
// reseedChips walks every chip span in the container and re-renders
|
||||
// its content from the active state map. Idempotent.
|
||||
export function reseedChips(container: HTMLElement): void {
|
||||
const state = states.get(container);
|
||||
if (!state) return;
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const kinds = state.active.get(code);
|
||||
if (!kinds || kinds.size === 0) {
|
||||
chip.innerHTML = "";
|
||||
chip.dataset.empty = "true";
|
||||
return;
|
||||
}
|
||||
chip.dataset.empty = "false";
|
||||
chip.innerHTML = renderChip(kinds);
|
||||
});
|
||||
// Skipped rows fade out via a class on the card-item ancestor.
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const skipped = state.active.get(code)?.get("skip") === "true";
|
||||
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
|
||||
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
|
||||
});
|
||||
}
|
||||
|
||||
function renderChip(kinds: Map<ChoiceKind, string>): string {
|
||||
const parts: string[] = [];
|
||||
if (kinds.get("skip") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
|
||||
}
|
||||
const ap = kinds.get("appellant");
|
||||
if (ap && ap !== "" ) {
|
||||
let label = "";
|
||||
switch (ap) {
|
||||
case "claimant": label = t("choices.appellant.claimant"); break;
|
||||
case "defendant": label = t("choices.appellant.defendant"); break;
|
||||
case "both": label = t("choices.appellant.both"); break;
|
||||
case "none": label = t("choices.appellant.none"); break;
|
||||
}
|
||||
if (label) {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
|
||||
}
|
||||
}
|
||||
if (kinds.get("include_ccr") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
closePopover(state);
|
||||
const code = caret.dataset.submissionCode || "";
|
||||
if (!code) return;
|
||||
let offered: Record<string, unknown> = {};
|
||||
try {
|
||||
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const isHidden = caret.dataset.isHidden === "1";
|
||||
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "event-card-choices-popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", t("choices.caret.title"));
|
||||
|
||||
const blocks: string[] = [];
|
||||
// t-paliad-293: hidden-card prominence. When the user opens the
|
||||
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
|
||||
// most likely intent — surface it as a single high-contrast action
|
||||
// at the top of the popover (rather than burying it under the skip
|
||||
// toggle's reset link). Clicking it clears the `skip` choice, which
|
||||
// is the same wire effect as the legacy inline chip from t-paliad-290.
|
||||
if (isHidden) {
|
||||
blocks.push(renderUnhideBlock());
|
||||
}
|
||||
if (Array.isArray(offered.appellant)) {
|
||||
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
|
||||
}
|
||||
if (Array.isArray(offered.include_ccr)) {
|
||||
blocks.push(renderToggleBlock(state, code, "include_ccr"));
|
||||
}
|
||||
if (Array.isArray(offered.skip)) {
|
||||
blocks.push(renderToggleBlock(state, code, "skip"));
|
||||
}
|
||||
pop.innerHTML = blocks.join("");
|
||||
|
||||
document.body.appendChild(pop);
|
||||
state.popover = pop;
|
||||
positionPopover(pop, caret);
|
||||
|
||||
pop.addEventListener("click", async (e) => {
|
||||
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
|
||||
const value = btn.dataset.choiceValue || "";
|
||||
const action = btn.dataset.choiceAction;
|
||||
if (!kind) return;
|
||||
try {
|
||||
if (action === "set") {
|
||||
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
if (!state.active.has(code)) state.active.set(code, new Map());
|
||||
state.active.get(code)!.set(kind, value);
|
||||
} else if (action === "clear") {
|
||||
await state.opts.remove(code, kind);
|
||||
state.active.get(code)?.delete(kind);
|
||||
}
|
||||
reseedChips(state.opts.container);
|
||||
closePopover(state);
|
||||
} catch (err) {
|
||||
console.error("event card choice commit failed", err);
|
||||
// Surface a soft inline error inside the popover; do NOT close.
|
||||
const errEl = document.createElement("div");
|
||||
errEl.className = "event-card-choices-error";
|
||||
errEl.textContent = t("choices.commit.error");
|
||||
pop.appendChild(errEl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
|
||||
const current = state.active.get(code)?.get("appellant") || "";
|
||||
const buttons = values
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.map((v) => {
|
||||
const labelKey = `choices.appellant.${v}` as const;
|
||||
const isActive = v === current;
|
||||
return `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="appellant"
|
||||
data-choice-value="${escAttr(v)}"
|
||||
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
})
|
||||
.join("");
|
||||
const reset = current
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
|
||||
<div class="event-card-choices-options">${buttons}</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
|
||||
const current = state.active.get(code)?.get(kind) || "false";
|
||||
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
|
||||
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
|
||||
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
|
||||
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="${kind}"
|
||||
data-choice-value="${v}"
|
||||
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
const reset = state.active.get(code)?.has(kind)
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
|
||||
<div class="event-card-choices-options">
|
||||
${opt("true", trueKey)}
|
||||
${opt("false", falseKey)}
|
||||
</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
|
||||
// action — surfaced only when the caret is opened on a re-surfaced
|
||||
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
|
||||
// the same `clear` action as the skip-block reset link below, but
|
||||
// labelled in the user's terms ("restore this card" rather than
|
||||
// "reset skip choice"). Drops out of the popover automatically on
|
||||
// non-hidden cards so the popover stays minimal. (t-paliad-293)
|
||||
function renderUnhideBlock(): string {
|
||||
const label = t("choices.unhide.chip");
|
||||
return `<div class="event-card-choices-block event-card-choices-block--unhide">
|
||||
<button type="button"
|
||||
data-choice-action="clear"
|
||||
data-choice-kind="skip"
|
||||
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function closePopover(state: AttachedState): void {
|
||||
if (state.popover) {
|
||||
state.popover.remove();
|
||||
state.popover = null;
|
||||
}
|
||||
}
|
||||
|
||||
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
|
||||
const rect = caret.getBoundingClientRect();
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
||||
pop.style.position = "absolute";
|
||||
pop.style.top = `${rect.bottom + scrollY + 4}px`;
|
||||
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
|
||||
pop.style.zIndex = "1000";
|
||||
}
|
||||
|
||||
// Returns the current in-memory choice list for the given container —
|
||||
// used by the unbound /tools/verfahrensablauf page to keep the URL
|
||||
// param in sync.
|
||||
export function currentChoices(container: HTMLElement): EventChoice[] {
|
||||
const state = states.get(container);
|
||||
if (!state) return [];
|
||||
const out: EventChoice[] = [];
|
||||
state.active.forEach((kinds, code) => {
|
||||
kinds.forEach((value, kind) => {
|
||||
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -19,8 +19,8 @@ export interface ScopeSpec {
|
||||
}
|
||||
|
||||
export type TimeHorizon =
|
||||
| "next_7d" | "next_30d" | "next_90d"
|
||||
| "past_7d" | "past_30d" | "past_90d"
|
||||
| "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "all" | "custom";
|
||||
|
||||
export type TimeField = "auto" | "created_at";
|
||||
@@ -66,7 +66,13 @@ export interface FilterSpec {
|
||||
sources: DataSource[];
|
||||
scope: ScopeSpec;
|
||||
time: TimeSpec;
|
||||
predicates?: Partial<Record<DataSource, Predicates>>;
|
||||
// Per-source narrowing. Flat shape — one entry per data source. The
|
||||
// Go side (internal/services/filter_spec.go: FilterSpec.Predicates)
|
||||
// mirrors this exactly; the previous Partial<Record<DataSource,
|
||||
// Predicates>> spelling was a latent contract bug (t-paliad-283)
|
||||
// where every chip click sent a single-nested shape the server
|
||||
// unmarshalled to no-op.
|
||||
predicates?: Predicates;
|
||||
// Inbox unread-only overlay (t-paliad-249). When true, the view
|
||||
// service drops project_event rows older than the caller's
|
||||
// users.inbox_seen_at cursor. Pending approval_requests always
|
||||
|
||||
@@ -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
|
||||
@@ -67,6 +71,153 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-293 (m/paliad#125): the "Wieder einblenden" affordance
|
||||
// moved from an inline chip in the card header into the caret popover
|
||||
// to fix horizontal-scroll on narrow viewports (the long German label
|
||||
// pushed the card past its column width). The renderer now signals
|
||||
// hidden state two ways: (1) a 👁⃠ state-icon in the title row and
|
||||
// (2) data-is-hidden="1" on the caret button so event-card-choices.ts
|
||||
// can surface the prominent "Wieder einblenden" popover entry when
|
||||
// the user opens the menu. The legacy `.event-card-choices-unhide`
|
||||
// inline chip class must NOT appear in the output.
|
||||
describe("deadlineCardHtml — isHidden surfaces state-icon + caret hint (t-paliad-293)", () => {
|
||||
test("isHidden=true emits the hidden state-icon", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).toContain("timeline-state-icon--hidden");
|
||||
});
|
||||
|
||||
test("isHidden=true with choicesOffered.skip annotates the caret with data-is-hidden=\"1\"", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).toContain('data-is-hidden="1"');
|
||||
expect(html).toContain("event-card-choices-caret");
|
||||
});
|
||||
|
||||
test("isHidden=false (default) suppresses the state-icon and reports data-is-hidden=\"0\"", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).not.toContain("timeline-state-icon--hidden");
|
||||
expect(html).toContain('data-is-hidden="0"');
|
||||
});
|
||||
|
||||
test("isHidden=true with empty choicesOffered still emits caret with synthesized skip offer (defensive)", () => {
|
||||
// Edge case: admin edits the rule's choices_offered after a user
|
||||
// has already saved a `skip=true` choice. Without the fallback
|
||||
// the card would re-surface as hidden with no popover entrypoint
|
||||
// — the user would have no way to un-hide it. The renderer
|
||||
// synthesizes a `{skip:[true,false]}` offer so the prominent
|
||||
// "Wieder einblenden" button still renders in the popover.
|
||||
const html = deadlineCardHtml(dl({ isHidden: true }), { showParty: true });
|
||||
expect(html).toContain("event-card-choices-caret");
|
||||
expect(html).toContain('data-is-hidden="1"');
|
||||
expect(html).toContain("data-choices-offered=\"{"skip":[true,false]}\"");
|
||||
});
|
||||
|
||||
test("isHidden=false with empty choicesOffered suppresses caret (regression guard)", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).not.toContain("event-card-choices-caret");
|
||||
});
|
||||
|
||||
test("legacy inline `.event-card-choices-unhide` class is no longer emitted", () => {
|
||||
// Pinned to catch a regression that would re-introduce the
|
||||
// horizontal-scroll surface that motivated the move. The popover
|
||||
// now uses `.event-card-choices-unhide-btn` (with the -btn suffix)
|
||||
// inside the body-attached popover dom node — never in the card
|
||||
// header HTML the renderer returns.
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).not.toContain('class="event-card-choices-unhide"');
|
||||
expect(html).not.toMatch(/event-card-choices-unhide(?!-btn)/);
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-293: the `optional` priority used to render an inline text
|
||||
// badge in the card title. The overhaul replaces it with a ⊙ state
|
||||
// icon so the title row stays compact on narrow viewports. Tooltip is
|
||||
// driven by the `state.optional.tooltip` i18n key.
|
||||
describe("deadlineCardHtml — optional priority renders the state icon (t-paliad-293)", () => {
|
||||
test("priority='optional' emits the timeline-state-icon--optional marker", () => {
|
||||
const html = deadlineCardHtml(dl({ priority: "optional" }), { showParty: true });
|
||||
expect(html).toContain("timeline-state-icon--optional");
|
||||
expect(html).not.toContain("optional-badge");
|
||||
});
|
||||
|
||||
test("priority='mandatory' (default) omits the optional marker", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).not.toContain("timeline-state-icon--optional");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-289 — isConditional rules render an "abhängig von <parent>"
|
||||
// chip in place of the date column, and the chip keeps the click-to-edit
|
||||
// affordance so the user can pin a real date once the upstream anchor
|
||||
// resolves (oral hearing scheduled, opposing party's motion received, …).
|
||||
// Mirrors Symptom A (R.109(1) backward-anchor without oral-hearing date)
|
||||
// and Symptom B (R.262(2) without recorded Vertraulichkeitsantrag) from
|
||||
// the issue.
|
||||
describe("deadlineCardHtml — isConditional rendering (t-paliad-289)", () => {
|
||||
test("isConditional + parentRuleName emits 'abhängig von <parent>' chip with click-to-edit", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({
|
||||
code: "upc.inf.cfi.translation_request",
|
||||
isConditional: true,
|
||||
parentRuleCode: "upc.inf.cfi.oral",
|
||||
parentRuleName: "Mündliche Verhandlung",
|
||||
}),
|
||||
{ showParty: true, editable: true },
|
||||
);
|
||||
expect(html).toContain("timeline-conditional");
|
||||
expect(html).toContain("abhängig von Mündliche Verhandlung");
|
||||
expect(html).toContain('data-rule-code="upc.inf.cfi.translation_request"');
|
||||
expect(html).toContain('role="button"');
|
||||
expect(html).not.toContain("timeline-court-set");
|
||||
});
|
||||
|
||||
test("isConditional with no parentRuleName falls back to generic upstream-event label", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isConditional: true }),
|
||||
{ showParty: true, editable: true },
|
||||
);
|
||||
expect(html).toContain("timeline-conditional");
|
||||
expect(html).toContain("abhängig von vorgelagertem Ereignis");
|
||||
});
|
||||
|
||||
test("isConditional wins over isCourtSet — overlapping cases render conditional chip", () => {
|
||||
// Court-set ancestor without override sets BOTH isCourtSet=true AND
|
||||
// isConditional=true on the wire. The renderer must pick the
|
||||
// conditional chip; otherwise the row keeps the legacy "wird vom
|
||||
// Gericht bestimmt" label and the user can't see WHICH upstream
|
||||
// event blocks them.
|
||||
const html = deadlineCardHtml(
|
||||
dl({
|
||||
isConditional: true,
|
||||
isCourtSet: true,
|
||||
isCourtSetIndirect: true,
|
||||
parentRuleName: "Entscheidung",
|
||||
}),
|
||||
{ showParty: true, editable: true },
|
||||
);
|
||||
expect(html).toContain("abhängig von Entscheidung");
|
||||
expect(html).not.toContain("timeline-court-set");
|
||||
});
|
||||
|
||||
test("isConditional=false keeps the normal date span (regression guard)", () => {
|
||||
const html = deadlineCardHtml(dl({ isConditional: false }), { showParty: true });
|
||||
expect(html).toContain("timeline-date");
|
||||
expect(html).not.toContain("timeline-conditional");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||
@@ -178,6 +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([
|
||||
@@ -191,6 +365,48 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
|
||||
});
|
||||
|
||||
test("appellantContext overrides the page-level appellant for descendants (t-paliad-265)", () => {
|
||||
// A per-decision pick stamps AppellantContext on descendants of
|
||||
// that decision. The bucketer prefers it over the page-level
|
||||
// appellant: if a "both" row carries appellantContext='defendant',
|
||||
// it collapses to defendant's column regardless of the global
|
||||
// appellant opt.
|
||||
const dl: CalculatedDeadline = {
|
||||
...both("Notice of Appeal", "2026-07-23"),
|
||||
appellantContext: "defendant",
|
||||
};
|
||||
const rows = bucketDeadlinesIntoColumns([dl], { appellant: "claimant" });
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("appellantContext='claimant' + side='defendant' lands the row in opponent (claimant ≠ us)", () => {
|
||||
// The user is on the defendant side; per-card pick says the
|
||||
// claimant appealed. The "both" row collapses to the claimant's
|
||||
// column, which after the side-swap is opponent (right).
|
||||
const dl: CalculatedDeadline = {
|
||||
...both("Notice of Appeal", "2026-07-23"),
|
||||
appellantContext: "claimant",
|
||||
};
|
||||
const rows = bucketDeadlinesIntoColumns([dl], { side: "defendant", appellant: "defendant" });
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("appellantContext='both' or 'none' falls back to page-level mirror (t-paliad-265)", () => {
|
||||
// 'both' and 'none' aren't side-collapse values — they're
|
||||
// statements about who appealed but don't pick a column. The
|
||||
// bucketer treats them as no override, so the page-level
|
||||
// appellant (or default mirror) applies.
|
||||
const both1: CalculatedDeadline = {
|
||||
...both("Notice of Appeal", "2026-07-23"),
|
||||
appellantContext: "both",
|
||||
};
|
||||
const rowsBoth = bucketDeadlinesIntoColumns([both1]);
|
||||
expect(rowsBoth[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rowsBoth[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("court", "Oral Hearing", ""),
|
||||
@@ -203,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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,145 @@ export interface CalculatedDeadline {
|
||||
// Frontend save-modal logic doesn't read this; the rule editor
|
||||
// (Slice 11) is the consumer. Unknown shape on this side — pass-through.
|
||||
conditionExpr?: unknown;
|
||||
// choicesOffered (t-paliad-265): declares which per-card choice-kinds
|
||||
// this rule offers on the Verfahrensablauf timeline. Object shape:
|
||||
// { appellant?: string[], include_ccr?: [true,false], skip?: [true,false] }.
|
||||
// null/undefined = no caret affordance.
|
||||
choicesOffered?: Record<string, unknown>;
|
||||
// appellantContext (t-paliad-265): the per-decision appellant pick
|
||||
// that applies to descendants of the closest ancestor decision card
|
||||
// with a per-card appellant set. Empty = no per-card override (the
|
||||
// page-level appellant axis still applies in that case). The bucketer
|
||||
// reads this in preference to the page-level appellant.
|
||||
appellantContext?: string;
|
||||
// isHidden (t-paliad-290 / m/paliad#122): server-side flag set when
|
||||
// a previously-hidden card is re-surfaced via the "Ausgeblendete
|
||||
// anzeigen" toggle. The renderer fades the card and exposes an
|
||||
// inline "Wieder einblenden" chip that deletes the skip choice.
|
||||
isHidden?: boolean;
|
||||
// isConditional (t-paliad-289): the rule's anchor is uncertain, so
|
||||
// no concrete date is projected. Set by the calculator when the rule
|
||||
// depends on a court-set ancestor without override, when a backward-
|
||||
// anchored rule's forward anchor isn't set, or for optional rules
|
||||
// whose true triggering event sits outside the rule data (e.g.
|
||||
// R.262(2) Erwiderung auf Vertraulichkeitsantrag — anchored on SoC
|
||||
// in the data, but the real trigger is the opposing party's
|
||||
// confidentiality motion). The renderer drops the date column entry
|
||||
// and shows an "abhängig von <parentRuleName>" chip instead.
|
||||
isConditional?: boolean;
|
||||
// parentRuleCode / parentRuleName / parentRuleNameEN surface the
|
||||
// parent rule's identity so the renderer can label the
|
||||
// "abhängig von <parent>" chip on conditional rows. Populated for
|
||||
// every rule with a parent (not just conditional ones), so the
|
||||
// dependency-footer logic can reuse it. Empty for root rules.
|
||||
parentRuleCode?: string;
|
||||
parentRuleName?: string;
|
||||
parentRuleNameEN?: string;
|
||||
// durationValue / durationUnit / timing surface the rule's arithmetic
|
||||
// so the timeline card can show "2 Mo. nach" on hover (and inline when
|
||||
// the "Dauern anzeigen" toggle is on). Zero-duration rules (root
|
||||
// event, court-set) carry durationValue=0 and the renderer suppresses
|
||||
// the affordance — those don't have an explainable interval.
|
||||
// (m/paliad#133, t-paliad-302)
|
||||
durationValue?: number;
|
||||
durationUnit?: string;
|
||||
timing?: string;
|
||||
// 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
|
||||
@@ -120,6 +259,13 @@ export interface DeadlineResponse {
|
||||
// (m/paliad#81)
|
||||
triggerEventLabel?: string;
|
||||
triggerEventLabelEN?: string;
|
||||
// hiddenCount (t-paliad-290 / m/paliad#122): number of rules that
|
||||
// would have been hidden in this projection (i.e. their
|
||||
// submission_code is in skipRules and they passed the condition_expr
|
||||
// gate). Surfaces the "Ausgeblendete (N)" badge on the toggle even
|
||||
// when the toggle is OFF — so users know there's something to
|
||||
// re-surface.
|
||||
hiddenCount?: number;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -139,6 +285,27 @@ export interface CalcParams {
|
||||
flags?: string[];
|
||||
anchorOverrides?: Record<string, string>;
|
||||
courtId?: string;
|
||||
// t-paliad-265: per-event-card choices. Either pass `projectId` for
|
||||
// server-side lookup against paliad.project_event_choices, OR pass
|
||||
// an inline list (for the unbound /tools/verfahrensablauf surface).
|
||||
// When both are supplied the inline list wins server-side.
|
||||
projectId?: string;
|
||||
perCardChoices?: Array<{
|
||||
submission_code: string;
|
||||
choice_kind: string;
|
||||
choice_value: string;
|
||||
}>;
|
||||
// includeHidden (t-paliad-290): when true the calculator returns
|
||||
// previously-skipped rules as faded cards instead of dropping them.
|
||||
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
|
||||
// ON.
|
||||
includeHidden?: boolean;
|
||||
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung
|
||||
// (upc.apl) timeline to the rule subset whose applies_to_target
|
||||
// contains the requested slug. Empty = no filter. Valid values:
|
||||
// endentscheidung | kostenentscheidung | anordnung |
|
||||
// schadensbemessung | bucheinsicht.
|
||||
appealTarget?: string;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -154,10 +321,20 @@ export function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// Pure-string HTML escape — keeps the module testable in bun test
|
||||
// (plain Node, no jsdom). Used to be backed by document.createElement,
|
||||
// which forced fixtures to leave any field that flowed through it
|
||||
// empty just to exercise unrelated branches; the regex form is safe
|
||||
// for arbitrary text including the per-rule name strings that the
|
||||
// conditional-row chip ("abhängig von <parent>") now exposes.
|
||||
// (t-paliad-289)
|
||||
export function escHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
@@ -249,27 +426,128 @@ 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"))}"`
|
||||
: "";
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
const dateStr = dl.isCourtSet
|
||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0"${dateTitle ? ` title="${escAttr(dateTitle)}"` : ""}`
|
||||
: (dateTitle ? ` title="${escAttr(dateTitle)}"` : "");
|
||||
// Conditional rows (t-paliad-289) replace the date column with an
|
||||
// "abhängig von <parent>" chip. The chip remains click-to-edit so
|
||||
// the user can pin a real date once known (e.g. once the oral
|
||||
// hearing date is set, or the opposing party's Vertraulichkeits-
|
||||
// antrag arrives) — the same data-rule-code wiring fires the
|
||||
// existing inline date editor. IsConditional wins over IsCourtSet:
|
||||
// they overlap (court-set ancestor without override produces both),
|
||||
// and "abhängig von <parent>" is the clearer user-facing signal.
|
||||
const parentLabel = (getLang() === "en"
|
||||
? (dl.parentRuleNameEN || dl.parentRuleName)
|
||||
: dl.parentRuleName) || "";
|
||||
let dateStr: string;
|
||||
if (dl.isConditional) {
|
||||
const chipText = parentLabel
|
||||
? tDyn("deadlines.conditional.depends_on").replace("{parent}", escHtml(parentLabel))
|
||||
: t("deadlines.conditional.unset");
|
||||
dateStr = `<span class="timeline-conditional frist-date-edit"${editAttrs}>${chipText}</span>`;
|
||||
} else if (dl.isCourtSet) {
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
dateStr = `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`;
|
||||
} else {
|
||||
dateStr = `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
}
|
||||
|
||||
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
|
||||
// priority directly. Optional badge fires only on 'optional'
|
||||
// priority (RoP.151-style opt-in deadlines).
|
||||
const mandatoryBadge = dl.priority === "optional"
|
||||
? '<span class="optional-badge">optional</span>'
|
||||
// t-paliad-293 — iconified state markers. The card surface speaks
|
||||
// "cut the tree of possibilities": each card carries 0–N small icons
|
||||
// in the title row that summarise its decision state at a glance.
|
||||
// The text "optional" badge that used to sit inline next to the name
|
||||
// is now a ⊙ icon (state.optional). Hidden cards get a 👁⃠ eye-slash
|
||||
// marker. Conditional cards already have the date-column chip; the
|
||||
// marker is redundant in the title row. CCR-included / appellant
|
||||
// picks remain on the chip row (event-card-choices-chip) — see below.
|
||||
// Tooltips are i18n-driven so they read in the user's language.
|
||||
const stateIcons: string[] = [];
|
||||
if (dl.priority === "optional") {
|
||||
stateIcons.push(
|
||||
`<span class="timeline-state-icon timeline-state-icon--optional" role="img" aria-label="${escAttr(t("state.optional.tooltip"))}" title="${escAttr(t("state.optional.tooltip"))}">⊙</span>`,
|
||||
);
|
||||
}
|
||||
if (dl.isHidden) {
|
||||
stateIcons.push(
|
||||
`<span class="timeline-state-icon timeline-state-icon--hidden" role="img" aria-label="${escAttr(t("state.hidden.tooltip"))}" title="${escAttr(t("state.hidden.tooltip"))}">👁⃠</span>`,
|
||||
);
|
||||
}
|
||||
const stateIconsHtml = stateIcons.join("");
|
||||
|
||||
// t-paliad-265 — caret affordance + chip indicator when this rule
|
||||
// offers per-card choices and the user has made a pick. The popover
|
||||
// open/commit lifecycle lives in client/views/event-card-choices.ts;
|
||||
// the data-* attributes here are the wire contract between the two.
|
||||
//
|
||||
// t-paliad-293 — hidden cards always expose the caret so the user
|
||||
// can un-hide via the popover's "Wieder einblenden" entry. Normally
|
||||
// a hidden card was hidden via a skip choice, so `choicesOffered.skip`
|
||||
// is present. Defensive fallback: if a rule's `choices_offered` was
|
||||
// edited away after the skip entry was saved, the user would lose
|
||||
// the un-hide path entirely. Synthesize a `{skip:[true,false]}`
|
||||
// offer for the popover in that edge case so the prominent
|
||||
// "Wieder einblenden" button still renders.
|
||||
const offeredForCaret = (dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0)
|
||||
? dl.choicesOffered
|
||||
: (dl.isHidden ? { skip: [true, false] } : null);
|
||||
const showCaret = dl.code !== "" && offeredForCaret !== null;
|
||||
const choicesHtml = showCaret
|
||||
? `<button type="button" class="event-card-choices-caret"
|
||||
data-submission-code="${escAttr(dl.code)}"
|
||||
data-choices-offered="${escAttr(JSON.stringify(offeredForCaret))}"
|
||||
data-is-hidden="${dl.isHidden ? "1" : "0"}"
|
||||
aria-label="${escAttr(t("choices.caret.title"))}"
|
||||
title="${escAttr(t("choices.caret.title"))}">▾</button>`
|
||||
: "";
|
||||
|
||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||
@@ -293,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>`
|
||||
@@ -302,20 +587,40 @@ 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>`
|
||||
: "";
|
||||
|
||||
// Chip indicator surfaces the active per-card pick (t-paliad-265).
|
||||
// The popover module rehydrates this on commit so it stays in sync.
|
||||
const chipHtml = dl.code !== ""
|
||||
? `<span class="event-card-choices-chip"
|
||||
data-submission-code="${escAttr(dl.code)}"
|
||||
data-empty="true"></span>`
|
||||
: "";
|
||||
|
||||
return `<div class="timeline-item-header">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${mandatoryBadge}
|
||||
${stateIconsHtml}
|
||||
${chipHtml}
|
||||
</span>
|
||||
${dateStr}
|
||||
${choicesHtml}
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
@@ -403,17 +708,54 @@ 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 = [
|
||||
"timeline-item",
|
||||
dl.isRootEvent ? "timeline-root" : "",
|
||||
// t-paliad-290: re-surfaced hidden cards render faded via the
|
||||
// shared timeline-item--hidden modifier (same modifier the columns
|
||||
// view uses; see fr-col-item--hidden below).
|
||||
dl.isHidden ? "timeline-item--hidden" : "",
|
||||
// t-paliad-289: dotted-border + faded styling for conditional rows
|
||||
// so the "abhängig von <parent>" state is visually distinct from
|
||||
// both anchored deadlines and direct court-set rows.
|
||||
dl.isConditional ? "timeline-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
html += `
|
||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||||
<div class="${itemClasses}">
|
||||
<div class="timeline-dot-col">
|
||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
${deadlineCardHtml(dl, opts)}
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -460,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).
|
||||
@@ -469,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
|
||||
@@ -484,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
|
||||
@@ -518,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);
|
||||
@@ -532,11 +897,49 @@ export function bucketDeadlinesIntoColumns(
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
if (appellantColumn !== null) {
|
||||
// t-paliad-265: a per-card appellant set on a decision
|
||||
// ancestor propagates as appellantContext on this rule. When
|
||||
// present, it overrides the page-level appellant for the
|
||||
// collapse decision on THIS row. Falls through to page-level
|
||||
// when empty.
|
||||
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);
|
||||
}
|
||||
@@ -559,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) {
|
||||
@@ -578,7 +997,17 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const mirrorTag = showMirrorTag && dl.party === "both"
|
||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
const itemClasses = [
|
||||
"fr-col-item",
|
||||
dl.isRootEvent ? "fr-col-root" : "",
|
||||
// t-paliad-290: re-surfaced hidden cards render faded via the
|
||||
// shared fr-col-item--hidden modifier.
|
||||
dl.isHidden ? "fr-col-item--hidden" : "",
|
||||
// t-paliad-289: same conditional treatment as the linear
|
||||
// timeline-item — dotted border + faded styling.
|
||||
dl.isConditional ? "fr-col-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return `<div class="${itemClasses}">
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
@@ -590,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);
|
||||
@@ -625,6 +1069,12 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
? params.anchorOverrides
|
||||
: undefined,
|
||||
courtId: params.courtId || undefined,
|
||||
projectId: params.projectId || undefined,
|
||||
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
|
||||
? 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"
|
||||
@@ -290,6 +296,15 @@ export type I18nKey =
|
||||
| "admin.partner_units.new.heading"
|
||||
| "admin.partner_units.subtitle"
|
||||
| "admin.partner_units.title"
|
||||
| "admin.procedural_events.col.code"
|
||||
| "admin.procedural_events.edit.breadcrumb"
|
||||
| "admin.procedural_events.edit.field.code"
|
||||
| "admin.procedural_events.edit.field.event_kind"
|
||||
| "admin.procedural_events.edit.field.parent"
|
||||
| "admin.procedural_events.edit.title"
|
||||
| "admin.procedural_events.list.heading"
|
||||
| "admin.procedural_events.list.new"
|
||||
| "admin.procedural_events.list.title"
|
||||
| "admin.rules.col.legal_citation"
|
||||
| "admin.rules.col.lifecycle"
|
||||
| "admin.rules.col.modified"
|
||||
@@ -392,22 +407,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"
|
||||
@@ -419,7 +418,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"
|
||||
@@ -999,6 +997,26 @@ export type I18nKey =
|
||||
| "checklisten.tab.mine"
|
||||
| "checklisten.tab.templates"
|
||||
| "checklisten.title"
|
||||
| "choices.appellant.both"
|
||||
| "choices.appellant.chip"
|
||||
| "choices.appellant.claimant"
|
||||
| "choices.appellant.defendant"
|
||||
| "choices.appellant.none"
|
||||
| "choices.appellant.title"
|
||||
| "choices.caret.title"
|
||||
| "choices.commit.error"
|
||||
| "choices.include_ccr.chip"
|
||||
| "choices.include_ccr.false"
|
||||
| "choices.include_ccr.title"
|
||||
| "choices.include_ccr.true"
|
||||
| "choices.reset"
|
||||
| "choices.show_hidden.count"
|
||||
| "choices.show_hidden.label"
|
||||
| "choices.skip.false"
|
||||
| "choices.skip.title"
|
||||
| "choices.skip.true"
|
||||
| "choices.skipped.chip"
|
||||
| "choices.unhide.chip"
|
||||
| "common.cancel"
|
||||
| "common.close"
|
||||
| "common.forbidden"
|
||||
@@ -1137,6 +1155,33 @@ export type I18nKey =
|
||||
| "dashboard.urgency.urgent"
|
||||
| "dashboard.when.today"
|
||||
| "dashboard.when.tomorrow"
|
||||
| "date_range.button.label"
|
||||
| "date_range.button.label.custom_range"
|
||||
| "date_range.center.label"
|
||||
| "date_range.custom.apply"
|
||||
| "date_range.custom.cancel"
|
||||
| "date_range.custom.from"
|
||||
| "date_range.custom.invalid"
|
||||
| "date_range.custom.invalid_format"
|
||||
| "date_range.custom.invalid_missing"
|
||||
| "date_range.custom.to"
|
||||
| "date_range.dialog.label"
|
||||
| "date_range.fan.future.label"
|
||||
| "date_range.fan.past.label"
|
||||
| "date_range.horizon.any"
|
||||
| "date_range.horizon.custom"
|
||||
| "date_range.horizon.next_14d"
|
||||
| "date_range.horizon.next_1d"
|
||||
| "date_range.horizon.next_30d"
|
||||
| "date_range.horizon.next_7d"
|
||||
| "date_range.horizon.next_90d"
|
||||
| "date_range.horizon.next_all"
|
||||
| "date_range.horizon.past_14d"
|
||||
| "date_range.horizon.past_1d"
|
||||
| "date_range.horizon.past_30d"
|
||||
| "date_range.horizon.past_7d"
|
||||
| "date_range.horizon.past_90d"
|
||||
| "date_range.horizon.past_all"
|
||||
| "deadlines.action.reopen"
|
||||
| "deadlines.adjusted"
|
||||
| "deadlines.adjusted.holiday"
|
||||
@@ -1145,10 +1190,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"
|
||||
@@ -1177,11 +1224,15 @@ export type I18nKey =
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
| "deadlines.complete.action"
|
||||
| "deadlines.complete.confirm"
|
||||
| "deadlines.conditional.depends_on"
|
||||
| "deadlines.conditional.unset"
|
||||
| "deadlines.court.indirect"
|
||||
| "deadlines.court.label"
|
||||
| "deadlines.court.set"
|
||||
@@ -1219,6 +1270,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"
|
||||
@@ -1407,10 +1459,13 @@ export type I18nKey =
|
||||
| "deadlines.search.placeholder"
|
||||
| "deadlines.search.results.count"
|
||||
| "deadlines.search.results.count_one"
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.from_project"
|
||||
| "deadlines.side.hint"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.side.override"
|
||||
| "deadlines.side.undefined"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
@@ -1441,6 +1496,7 @@ export type I18nKey =
|
||||
| "deadlines.step2.happened.desc"
|
||||
| "deadlines.step2.happened.title"
|
||||
| "deadlines.step2.heading"
|
||||
| "deadlines.step2.perspective"
|
||||
| "deadlines.step3"
|
||||
| "deadlines.step3a.back"
|
||||
| "deadlines.step3a.draft.desc"
|
||||
@@ -1467,6 +1523,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"
|
||||
@@ -1577,6 +1634,7 @@ export type I18nKey =
|
||||
| "event.title.appointment_deleted"
|
||||
| "event.title.appointment_project_changed"
|
||||
| "event.title.appointment_updated"
|
||||
| "event.title.approval_decided"
|
||||
| "event.title.checklist_created"
|
||||
| "event.title.checklist_deleted"
|
||||
| "event.title.checklist_linked"
|
||||
@@ -1595,6 +1653,7 @@ export type I18nKey =
|
||||
| "event.title.deadline_reopened"
|
||||
| "event.title.deadline_updated"
|
||||
| "event.title.deadlines_imported"
|
||||
| "event.title.member_role_changed"
|
||||
| "event.title.note_created"
|
||||
| "event.title.our_side_changed"
|
||||
| "event.title.project_archived"
|
||||
@@ -1928,7 +1987,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"
|
||||
@@ -2557,15 +2615,28 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "state.hidden.tooltip"
|
||||
| "state.optional.tooltip"
|
||||
| "submissions.draft.action.delete"
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.base.hint"
|
||||
| "submissions.draft.base.label"
|
||||
| "submissions.draft.import.button"
|
||||
| "submissions.draft.language"
|
||||
| "submissions.draft.language.de"
|
||||
| "submissions.draft.language.en"
|
||||
| "submissions.draft.language.fallback_notice"
|
||||
| "submissions.draft.loading"
|
||||
| "submissions.draft.name.placeholder"
|
||||
| "submissions.draft.notfound"
|
||||
| "submissions.draft.parties.hint"
|
||||
| "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"
|
||||
@@ -2726,16 +2797,6 @@ export type I18nKey =
|
||||
| "views.bar.shape.list"
|
||||
| "views.bar.sort.date_asc"
|
||||
| "views.bar.sort.date_desc"
|
||||
| "views.bar.time.all"
|
||||
| "views.bar.time.any"
|
||||
| "views.bar.time.custom"
|
||||
| "views.bar.time.custom.coming_soon"
|
||||
| "views.bar.time.next_30d"
|
||||
| "views.bar.time.next_7d"
|
||||
| "views.bar.time.next_90d"
|
||||
| "views.bar.time.past_30d"
|
||||
| "views.bar.time.past_7d"
|
||||
| "views.bar.time.past_90d"
|
||||
| "views.bar.timeline_status.court_set"
|
||||
| "views.bar.timeline_status.done"
|
||||
| "views.bar.timeline_status.macro.future"
|
||||
@@ -2810,11 +2871,18 @@ export type I18nKey =
|
||||
| "views.horizon.all"
|
||||
| "views.horizon.any"
|
||||
| "views.horizon.custom"
|
||||
| "views.horizon.next_14d"
|
||||
| "views.horizon.next_1d"
|
||||
| "views.horizon.next_30d"
|
||||
| "views.horizon.next_7d"
|
||||
| "views.horizon.next_90d"
|
||||
| "views.horizon.next_all"
|
||||
| "views.horizon.past_14d"
|
||||
| "views.horizon.past_1d"
|
||||
| "views.horizon.past_30d"
|
||||
| "views.horizon.past_7d"
|
||||
| "views.horizon.past_90d"
|
||||
| "views.horizon.past_all"
|
||||
| "views.kind.appointment"
|
||||
| "views.kind.approval_request"
|
||||
| "views.kind.deadline"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,11 +109,143 @@ 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. */}
|
||||
<div
|
||||
className="submission-draft-language-row"
|
||||
id="submission-draft-language-row"
|
||||
role="radiogroup"
|
||||
aria-labelledby="submission-draft-language-label">
|
||||
<span
|
||||
id="submission-draft-language-label"
|
||||
className="submission-draft-language-label"
|
||||
data-i18n="submissions.draft.language">
|
||||
Sprache
|
||||
</span>
|
||||
<label className="submission-draft-language-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="submission-draft-language"
|
||||
value="de"
|
||||
id="submission-draft-language-de"
|
||||
/>
|
||||
<span data-i18n="submissions.draft.language.de">DE</span>
|
||||
</label>
|
||||
<label className="submission-draft-language-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="submission-draft-language"
|
||||
value="en"
|
||||
id="submission-draft-language-en"
|
||||
/>
|
||||
<span data-i18n="submissions.draft.language.en">EN</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
className="submission-draft-language-fallback"
|
||||
id="submission-draft-language-fallback"
|
||||
style="display:none"
|
||||
data-i18n="submissions.draft.language.fallback_notice">
|
||||
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
|
||||
</p>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
|
||||
{/* t-paliad-277: "Aus Projekt importieren" + last-
|
||||
imported-at timestamp. Only visible when the
|
||||
draft has a project_id attached. */}
|
||||
<div
|
||||
id="submission-draft-import-row"
|
||||
className="submission-draft-import-row"
|
||||
style="display:none">
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-import-btn"
|
||||
className="btn-small btn-secondary"
|
||||
data-i18n="submissions.draft.import.button">
|
||||
Aus Projekt importieren
|
||||
</button>
|
||||
<span
|
||||
id="submission-draft-import-stamp"
|
||||
className="submission-draft-import-stamp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-277 / t-paliad-287: multi-select party
|
||||
picker plus per-side Add-Party affordance.
|
||||
Populated from view.available_parties; checkbox
|
||||
per party, grouped by role. Hidden when no
|
||||
project is attached; visible even on empty
|
||||
rosters so the lawyer can use Add Party to
|
||||
populate. */}
|
||||
<div
|
||||
id="submission-draft-parties"
|
||||
className="submission-draft-parties"
|
||||
style="display:none">
|
||||
<h3
|
||||
className="submission-draft-var-group-title"
|
||||
data-i18n="submissions.draft.parties.title">
|
||||
Parteien
|
||||
</h3>
|
||||
<p
|
||||
className="submission-draft-parties-hint"
|
||||
data-i18n="submissions.draft.parties.hint">
|
||||
Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.
|
||||
</p>
|
||||
<div
|
||||
id="submission-draft-parties-list"
|
||||
className="submission-draft-parties-list"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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
|
||||
@@ -158,9 +162,114 @@ export function renderVerfahrensablauf(): string {
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
|
||||
<span data-i18n="deadlines.step2.perspective">Perspektive und Datum</span>
|
||||
</h3>
|
||||
|
||||
{/* Perspective strip (t-paliad-250 / m/paliad#81, reordered
|
||||
in t-paliad-279 / m/paliad#111). Side defines whose
|
||||
perspective the columns project; appellant collapses
|
||||
party=both rows for role-swap proceedings (Appeal etc.).
|
||||
Moved above .date-input-group because party-side is the
|
||||
most-defining input after proceeding-type — without
|
||||
side, the column labels can't pick "your filings". Both
|
||||
selectors are URL-driven (?side= + ?appellant=) so the
|
||||
perspective survives reload and is shareable.
|
||||
|
||||
When the page is opened with ?project=<id> and that
|
||||
project's our_side is set, side-row renders as a
|
||||
read-only chip with an "Andere Seite wählen" override
|
||||
link — see client/verfahrensablauf.ts. */}
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="side-radio-cluster" id="side-radio-cluster">
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
|
||||
</label>
|
||||
</div>
|
||||
{/* Prompt shown while the user hasn't picked a side
|
||||
(m/paliad#120). Hidden by client when side is
|
||||
claimant or defendant. Both columns still
|
||||
render every rule in this state — picking a
|
||||
side just focuses the user's column. */}
|
||||
<span className="side-hint" id="side-hint"
|
||||
data-i18n="deadlines.side.hint">
|
||||
Wählen Sie eine Seite, um die Spalten zu fokussieren.
|
||||
</span>
|
||||
</div>
|
||||
{/* Auto-fill chip — populated by the client when a
|
||||
?project=<id> URL resolves a project with our_side
|
||||
set. Hidden by default; the radio cluster above is
|
||||
hidden whenever this chip is shown. */}
|
||||
<div className="side-chip" id="side-chip" style="display:none">
|
||||
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
|
||||
<strong className="side-chip-value" id="side-chip-value">—</strong>
|
||||
<button type="button" className="side-chip-override" id="side-chip-override"
|
||||
data-i18n="deadlines.side.override">
|
||||
Andere Seite wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Appeal-target chip row (Slice B1 / m/paliad#124 §18.1).
|
||||
Shown only when the unified upc.apl Berufung tile is
|
||||
selected; lets the user narrow the timeline to the
|
||||
rules whose applies_to_target contains the picked
|
||||
decision kind. URL state ?target=<slug>. */}
|
||||
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="endentscheidung" checked />
|
||||
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="kostenentscheidung" />
|
||||
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="anordnung" />
|
||||
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="schadensbemessung" />
|
||||
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="bucheinsicht" />
|
||||
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
|
||||
Re-surfaces optional cards the user has previously
|
||||
marked "Überspringen" via the per-card popover.
|
||||
The row hides itself when the projection has no
|
||||
hidden cards (handled in client/verfahrensablauf.ts).
|
||||
Default OFF; URL state ?show_hidden=1. */}
|
||||
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
|
||||
<label className="fristen-view-option">
|
||||
<input type="checkbox" id="show-hidden-toggle" />
|
||||
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
|
||||
</label>
|
||||
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite"> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual divider — keeps the perspective block (most-
|
||||
defining inputs after proceeding-type) optically
|
||||
separate from the date / court / flag knobs below. */}
|
||||
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
@@ -210,53 +319,6 @@ export function renderVerfahrensablauf(): string {
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Perspective strip (t-paliad-250 / m/paliad#81). Side
|
||||
swaps the column LABELS so the user's own side is
|
||||
proactive (= "your filings"). Appellant collapses
|
||||
party=both rows to a single column when set — only
|
||||
relevant for role-swap proceedings (Appeal etc.);
|
||||
the row hides itself when the picked proceeding has
|
||||
no appellant axis (see hasAppellantAxis() in the
|
||||
client). Both selectors are URL-driven (?side= +
|
||||
?appellant=) so the perspective survives reload
|
||||
and is shareable. */}
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.both">Beide</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
@@ -279,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">
|
||||
|
||||
@@ -116,6 +116,57 @@ func TestMigrations_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrations_NoDuplicateSlot is a free-standing pre-flight check that
|
||||
// scanEmbeddedMigrations refuses to walk a tree where two *.up.sql files
|
||||
// claim the same NNN slot. This is the brunel-slot-collision class of
|
||||
// outage (m/paliad#114, 2026-05-25 ~13:20): a worker writes a migration
|
||||
// at slot N while another shipped slot N from a separate branch, both
|
||||
// merge, both end up in the embed.FS, and the runner refuses to start.
|
||||
//
|
||||
// Catching this at CI time (no DB needed) lets the second PR fail before
|
||||
// it merges, instead of breaking prod at the next deploy. Pure unit test;
|
||||
// runs even on developer laptops that don't set TEST_DATABASE_URL.
|
||||
func TestMigrations_NoDuplicateSlot(t *testing.T) {
|
||||
if _, err := scanEmbeddedMigrations(); err != nil {
|
||||
t.Fatalf("scanEmbeddedMigrations: %v "+
|
||||
"(two migrations share the same NNN slot — coordinate with head "+
|
||||
"and rename one of them before merging)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrations_EndToEndAsAppRole applies every embedded migration in
|
||||
// numeric order against a scratch DB connected as a NON-SUPERUSER role.
|
||||
// This is the prod-shape smoke that the per-mig BEGIN/ROLLBACK dry-run
|
||||
// (TestMigrations_DryRun) cannot deliver: the dry-run runs each
|
||||
// statement in isolation and rolls back, so it cannot reproduce the
|
||||
// mig-129-class outage (m/paliad#114, 2026-05-25 ~14:56 — pq: must be
|
||||
// owner of table project_event_choices, SQLSTATE 42501) where a
|
||||
// migration assumes ownership the deploy role doesn't have.
|
||||
//
|
||||
// Requires TEST_APP_DATABASE_URL — a Postgres URL whose role is NOT a
|
||||
// superuser and does NOT own the `paliad` schema (m's Q11.2 pick:
|
||||
// generic two-role model, see docs/design-cicd-pre-deploy-gate-2026-05-25.md
|
||||
// §6.2(a)). The CI workflow creates the role + schema split before
|
||||
// invoking the test; a developer who wants to reproduce the gate locally
|
||||
// runs the same SQL preamble (see Makefile target `verify-migrations`).
|
||||
//
|
||||
// Skipped without TEST_APP_DATABASE_URL — keeps `go test ./...` green
|
||||
// on machines that haven't set up the role split.
|
||||
func TestMigrations_EndToEndAsAppRole(t *testing.T) {
|
||||
url := os.Getenv("TEST_APP_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_APP_DATABASE_URL not set — skipping role-split end-to-end migration smoke")
|
||||
}
|
||||
if err := ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("ApplyMigrations as app role failed: %v "+
|
||||
"(a migration assumes more privilege than the deploy role has — "+
|
||||
"common cases: ALTER TABLE on a schema-owner table, CREATE EXTENSION "+
|
||||
"without grants, SET ROLE without permission. Fix the migration to "+
|
||||
"work as the deploy role, or arrange for the schema to be owned by "+
|
||||
"the deploy role)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// readAppliedVersions returns the set of versions present in
|
||||
// paliad.applied_migrations on the scratch DB. Missing table → empty set
|
||||
// (fresh-DB path; the table only exists after the runner has been called).
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -26,24 +26,24 @@ DO $$ BEGIN ALTER TABLE paliad.department_members RENAME COLUMN dezernat_id TO d
|
||||
-- Constraints (primary key + foreign keys + check). Renaming a pkey
|
||||
-- constraint also renames the underlying index of the same name.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_pkey TO departments_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_lead_user_id_fkey TO departments_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_office_check TO departments_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_pkey TO department_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_dezernat_id_fkey TO department_members_department_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_user_id_fkey TO department_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_pkey TO departments_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_lead_user_id_fkey TO departments_lead_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_office_check TO departments_office_check; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_pkey TO department_members_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_dezernat_id_fkey TO department_members_department_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_user_id_fkey TO department_members_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Standalone indexes (non-pkey).
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_office_idx RENAME TO departments_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_lead_idx RENAME TO departments_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernat_mitglieder_user_idx RENAME TO department_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_office_idx RENAME TO departments_office_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_lead_idx RENAME TO departments_lead_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernat_mitglieder_user_idx RENAME TO department_members_user_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- RLS policies
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER POLICY dezernate_select ON paliad.departments RENAME TO departments_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernate_write ON paliad.departments RENAME TO departments_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_select ON paliad.department_members RENAME TO department_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_write ON paliad.department_members RENAME TO department_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernate_select ON paliad.departments RENAME TO departments_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernate_write ON paliad.departments RENAME TO departments_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_select ON paliad.department_members RENAME TO department_members_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_write ON paliad.department_members RENAME TO department_members_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
@@ -63,27 +63,27 @@ ALTER TABLE paliad.partner_unit_members RENAME COLUMN department_id TO partner_u
|
||||
-- 5. Rename constraints. Postgres auto-renames the underlying index for
|
||||
-- pkey/uniq constraints; standalone indexes are renamed in step 6.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. Rename non-pkey indexes.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. Rename RLS policies.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 8. Audit table for partner-unit events. Mutations on partner_units +
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Revert t-paliad-264 / m/paliad#95.
|
||||
-- Restores Replik and Duplik to parent_id = NULL with the pre-fix
|
||||
-- "Frist vom Gericht bestimmt" placeholder note. The pre-fix rows
|
||||
-- carried legal_source = NULL and is_court_set = false; both
|
||||
-- placeholder durations (4 weeks) are left untouched (the .up
|
||||
-- migration did not modify them).
|
||||
--
|
||||
-- audit_reason set_config required for the mig 079 trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 124 revert: unwind de.inf.lg Replik/Duplik sequencing back to pre-#95 placeholder state',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
is_court_set = false,
|
||||
legal_source = NULL,
|
||||
deadline_notes = 'Frist vom Gericht bestimmt',
|
||||
deadline_notes_en = NULL
|
||||
WHERE submission_code = 'de.inf.lg.replik'
|
||||
AND is_active = true;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
is_court_set = false,
|
||||
legal_source = NULL,
|
||||
deadline_notes = 'Frist vom Gericht bestimmt',
|
||||
deadline_notes_en = NULL
|
||||
WHERE submission_code = 'de.inf.lg.duplik'
|
||||
AND is_active = true;
|
||||
@@ -0,0 +1,94 @@
|
||||
-- t-paliad-264 / m/paliad#95 — Fix de.inf.lg Replik + Duplik sequencing.
|
||||
--
|
||||
-- BEFORE this migration, the de.inf.lg rules for Replik and Duplik
|
||||
-- had parent_id = NULL with duration_value = 4 weeks each. The
|
||||
-- projection therefore anchored both off the proceeding's trigger
|
||||
-- date (Klageerhebung) and added 4 weeks → both rows rendered at the
|
||||
-- same calendar date, BEFORE Klageerwiderung (which sits at
|
||||
-- Klageerhebung + 6 weeks per § 276 Abs. 1 S. 2 ZPO).
|
||||
--
|
||||
-- Correct ZPO sequence for first-instance infringement before the
|
||||
-- Landgericht is:
|
||||
--
|
||||
-- Klageerhebung (§ 253 ZPO)
|
||||
-- → Anzeige der Verteidigungsbereitschaft (§ 276 Abs. 1 S. 1 ZPO,
|
||||
-- 2 Wochen ab Zustellung der Klage)
|
||||
-- → Klageerwiderung (§ 276 Abs. 1 S. 2 + § 277 ZPO; vom Gericht
|
||||
-- gesetzte Frist von mindestens 2 Wochen, in der Praxis 6 Wochen)
|
||||
-- → Replik (vom Gericht gesetzte Frist; Anordnungskompetenz aus
|
||||
-- § 273 ZPO, prozessuale Förderungspflicht der Parteien aus
|
||||
-- § 282 ZPO; in der Praxis ~ 4 Wochen ab Zustellung der
|
||||
-- Klageerwiderung)
|
||||
-- → Duplik (vom Gericht gesetzte Frist; § 273, § 282 ZPO; in der
|
||||
-- Praxis ~ 4 Wochen ab Zustellung der Replik)
|
||||
--
|
||||
-- Replik and Duplik have NO statutory period — the Landgericht fixes
|
||||
-- the period in its prozessleitende Verfügung. We model them as
|
||||
-- is_court_set = true with a placeholder 4-week duration anchored on
|
||||
-- the immediately preceding filing so the timeline (a) renders them
|
||||
-- in strict chronological order and (b) gives the lawyer a sane
|
||||
-- notional date that can be overridden via "Datum setzen" once the
|
||||
-- court issues the actual period.
|
||||
--
|
||||
-- legal_source set to DE.ZPO.273 (Vorbereitung des Termins —
|
||||
-- court's case-management power that authorises setting Replik /
|
||||
-- Duplik periods). The full citation chain (§§ 273, 282 ZPO) lives
|
||||
-- in deadline_notes so the rendered card explains the source.
|
||||
--
|
||||
-- Scope strictly de.inf.lg / cfi per the t-paliad-264 brief. Other
|
||||
-- jurisdictions are out of scope and will be addressed via curie's
|
||||
-- m/paliad#94 audit follow-ups (Wave 0+).
|
||||
--
|
||||
-- Slot note: this migration originally landed as 123 in an earlier
|
||||
-- iteration; cronus's t-paliad-246 Backup-Mode migration won slot
|
||||
-- 123 in parallel-merge order, so this one shifted to 124.
|
||||
--
|
||||
-- Idempotency: each UPDATE is guarded by a WHERE clause that only
|
||||
-- matches the pre-fix row state (parent_id IS NULL on Replik /
|
||||
-- Duplik, since that was the load-bearing bug). A re-apply against
|
||||
-- a DB that already carries the fix matches zero rows and no-ops —
|
||||
-- no duplicate audit-log rows in paliad.deadline_rule_audit, no
|
||||
-- redundant writes. Mig 095 convention.
|
||||
--
|
||||
-- audit_reason set_config required at the top — the mig 079 trigger
|
||||
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
|
||||
-- on any UPDATE without it.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 124: t-paliad-264 / m/paliad#95 — anchor de.inf.lg Replik on Klageerwiderung and Duplik on Replik, mark both is_court_set per § 273 ZPO',
|
||||
true);
|
||||
|
||||
-- Replik anchors on Klageerwiderung (de.inf.lg.erwidg).
|
||||
-- Guard: parent_id IS NULL — only fires against the pre-fix shape.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (
|
||||
SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.erwidg'
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
),
|
||||
is_court_set = true,
|
||||
legal_source = 'DE.ZPO.273',
|
||||
deadline_notes = 'Frist vom Gericht in der prozessleitenden Verfügung bestimmt (§ 273 ZPO, prozessuale Förderungspflicht § 282 ZPO). In der Praxis ca. 4 Wochen ab Zustellung der Klageerwiderung; mit "Datum setzen" überschreiben, sobald die gerichtliche Verfügung vorliegt.',
|
||||
deadline_notes_en = 'Period set by the court in its case-management order (§ 273 ZPO; parties'' duty to file timely under § 282 ZPO). Typically ca. 4 weeks after service of the Statement of Defence; use "Set date" to override once the court issues the actual period.'
|
||||
WHERE submission_code = 'de.inf.lg.replik'
|
||||
AND is_active = true
|
||||
AND parent_id IS NULL;
|
||||
|
||||
-- Duplik anchors on Replik (de.inf.lg.replik).
|
||||
-- Guard: parent_id IS NULL — only fires against the pre-fix shape.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (
|
||||
SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.replik'
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
),
|
||||
is_court_set = true,
|
||||
legal_source = 'DE.ZPO.273',
|
||||
deadline_notes = 'Frist vom Gericht in der prozessleitenden Verfügung bestimmt (§ 273 ZPO, prozessuale Förderungspflicht § 282 ZPO). In der Praxis ca. 4 Wochen ab Zustellung der Replik; mit "Datum setzen" überschreiben, sobald die gerichtliche Verfügung vorliegt.',
|
||||
deadline_notes_en = 'Period set by the court in its case-management order (§ 273 ZPO; parties'' duty to file timely under § 282 ZPO). Typically ca. 4 weeks after service of the Reply; use "Set date" to override once the court issues the actual period.'
|
||||
WHERE submission_code = 'de.inf.lg.duplik'
|
||||
AND is_active = true
|
||||
AND parent_id IS NULL;
|
||||
@@ -0,0 +1,103 @@
|
||||
-- Down migration for 125_cross_cutting_filter_legal_source.up.sql.
|
||||
--
|
||||
-- Rebuilds the mig 098 matview shape (NULL legal_source on trigger
|
||||
-- rows) and removes the trigger-207 backfill row. Two steps in
|
||||
-- forward-reverse order so the matview drop doesn't trip on the
|
||||
-- deadline_rules delete.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 125 down: revert cross-cutting filter legal_source (drop trigger-207 backfill + rebuild matview without LEFT JOIN to deadline_rules).',
|
||||
true);
|
||||
|
||||
-- 1. Drop the matview before pulling rows underneath it.
|
||||
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
|
||||
|
||||
-- 2. Delete the trigger 207 backfill row.
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id = 207
|
||||
AND sequence_order = 1207;
|
||||
|
||||
-- 3. Recreate the mig 098 matview verbatim (NULL legal_source on
|
||||
-- trigger rows).
|
||||
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||
SELECT
|
||||
'rule'::text AS kind,
|
||||
'r:' || 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 AS 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 AS legal_source,
|
||||
dr.rule_code AS rule_code,
|
||||
dr.duration_value,
|
||||
dr.duration_unit,
|
||||
dr.timing,
|
||||
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||
FROM paliad.deadline_rules 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'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'trigger'::text,
|
||||
't:' || te.id::text,
|
||||
dc.id,
|
||||
dc.slug,
|
||||
dc.name_de,
|
||||
dc.name_en,
|
||||
dc.description,
|
||||
dc.aliases,
|
||||
dc.party,
|
||||
dc.category,
|
||||
dc.sort_order,
|
||||
NULL::uuid,
|
||||
te.id,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
'cross-cutting'::text,
|
||||
9999::int AS proceeding_display_order,
|
||||
te.code,
|
||||
te.name_de,
|
||||
te.name,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::int,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
dc.party
|
||||
FROM paliad.trigger_events te
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||
WHERE te.is_active;
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,216 @@
|
||||
-- t-paliad-266 / m/paliad#97 — make cross-cutting trigger pills filter
|
||||
-- by court system in the event-type / Fristen search modal.
|
||||
--
|
||||
-- Two things land here:
|
||||
--
|
||||
-- 1. DATA — backfill the missing deadline_rules row for trigger 207
|
||||
-- (Wegfall des Hindernisses, UPC R.320). Mig 063 added the
|
||||
-- trigger_event but never seeded its event_deadlines counterpart;
|
||||
-- mig 092 then dropped event_deadlines after copying the four
|
||||
-- sibling Wiedereinsetzungen (ids 200..203) into deadline_rules,
|
||||
-- so trigger 207 stayed orphaned with no duration / legal_source.
|
||||
-- Adding the row makes UPC R.320 Wiedereinsetzung calculable on
|
||||
-- par with the four siblings (2 months from removal of obstacle,
|
||||
-- legal_source = 'UPC.RoP.320', party = 'both') and gives the
|
||||
-- matview a legal_source to surface for the UPC trigger pill.
|
||||
-- Pattern mirrors the four sibling rows mig 085 inserted.
|
||||
--
|
||||
-- 2. MATVIEW — rebuild paliad.deadline_search with a LEFT JOIN on
|
||||
-- paliad.deadline_rules for trigger pills, exposing the trigger's
|
||||
-- legal_source on the row. The cross-cutting concept card pills
|
||||
-- then carry a structured citation prefix (UPC.* / DE.ZPO.* /
|
||||
-- DE.PatG.* / EU.EPC* / EU.EPÜ.*) that the search service can
|
||||
-- match against the active forum-bucket filter — see
|
||||
-- DeadlineSearchService.translateForums + ForumToLegalSourcePrefixes
|
||||
-- (added in this same change). Without the matview surfacing
|
||||
-- legal_source for trigger rows, every cross-cutting sub-row
|
||||
-- ignored the court-system chip selection (the bug m reported).
|
||||
--
|
||||
-- The materialised view paliad.deadline_search refreshes on the next
|
||||
-- server boot via services.RefreshSearchView (cmd/server/main.go), so
|
||||
-- the new legal_source column for triggers becomes searchable as soon
|
||||
-- as the deploy restarts the process. No matview refresh from the
|
||||
-- migration itself.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 125: t-paliad-266 — backfill missing deadline_rules row for trigger 207 (UPC R.320 Wiedereinsetzung) and rebuild deadline_search matview so trigger pills carry legal_source (cross-cutting court-system filter, m/paliad#97).',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Backfill: deadline_rules row for trigger 207.
|
||||
--
|
||||
-- Idempotency: gated on NOT EXISTS by (trigger_event_id, name). Mirrors
|
||||
-- mig 085's guard so re-runs are no-ops once the row is present.
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO paliad.deadline_rules (
|
||||
id,
|
||||
proceeding_type_id,
|
||||
parent_id,
|
||||
trigger_event_id,
|
||||
spawn_proceeding_type_id,
|
||||
submission_code,
|
||||
name,
|
||||
name_en,
|
||||
primary_party,
|
||||
event_type,
|
||||
is_court_set,
|
||||
is_spawn,
|
||||
duration_value,
|
||||
duration_unit,
|
||||
timing,
|
||||
alt_duration_value,
|
||||
alt_duration_unit,
|
||||
combine_op,
|
||||
rule_code,
|
||||
deadline_notes,
|
||||
deadline_notes_en,
|
||||
legal_source,
|
||||
condition_expr,
|
||||
sequence_order,
|
||||
is_active,
|
||||
priority,
|
||||
lifecycle_state,
|
||||
draft_of,
|
||||
published_at,
|
||||
concept_id
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
NULL::integer,
|
||||
NULL::uuid,
|
||||
207,
|
||||
NULL::integer,
|
||||
NULL::text,
|
||||
'Wiedereinsetzungsantrag (UPC R.320)',
|
||||
'Petition for re-establishment of rights (UPC R.320)',
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
false,
|
||||
false,
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
NULL::integer,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
'Frist beträgt 2 Monate ab Wegfall des Hindernisses (R.320 RoP). Spätestens 12 Monate nach Ablauf der versäumten Frist.',
|
||||
'Period is 2 months from removal of the obstacle (UPC R.320 RoP). Latest 12 months after expiry of the missed deadline.',
|
||||
'UPC.RoP.320',
|
||||
NULL::jsonb,
|
||||
1207,
|
||||
true,
|
||||
'mandatory',
|
||||
'published',
|
||||
NULL::uuid,
|
||||
now(),
|
||||
(SELECT id FROM paliad.deadline_concepts WHERE slug = 'wiedereinsetzung')
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.trigger_event_id = 207
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Matview rebuild — LEFT JOIN deadline_rules on trigger_event_id so
|
||||
-- cross-cutting trigger pills carry legal_source. Indexes reproduced
|
||||
-- verbatim from mig 098 §5.
|
||||
--
|
||||
-- The trigger-row JOIN matches the Pipeline-C convention (mig 085 §2.5 /
|
||||
-- mig 092 §2): each cross-cutting trigger has a single deadline_rules
|
||||
-- row with proceeding_type_id IS NULL. A trigger event without that
|
||||
-- row leaves legal_source NULL and the trigger pill keeps its current
|
||||
-- "no jurisdiction filter match" semantics — same shape as before this
|
||||
-- migration, just structurally surfaceable.
|
||||
-- =============================================================================
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
|
||||
|
||||
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||
SELECT
|
||||
'rule'::text AS kind,
|
||||
'r:' || 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 AS 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 AS legal_source,
|
||||
dr.rule_code AS rule_code,
|
||||
dr.duration_value,
|
||||
dr.duration_unit,
|
||||
dr.timing,
|
||||
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||
FROM paliad.deadline_rules 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'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'trigger'::text,
|
||||
't:' || te.id::text,
|
||||
dc.id,
|
||||
dc.slug,
|
||||
dc.name_de,
|
||||
dc.name_en,
|
||||
dc.description,
|
||||
dc.aliases,
|
||||
dc.party,
|
||||
dc.category,
|
||||
dc.sort_order,
|
||||
NULL::uuid,
|
||||
te.id,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
'cross-cutting'::text,
|
||||
9999::int AS proceeding_display_order,
|
||||
te.code,
|
||||
te.name_de,
|
||||
te.name,
|
||||
dr_trig.legal_source AS legal_source,
|
||||
NULL::text,
|
||||
NULL::int,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
dc.party
|
||||
FROM paliad.trigger_events te
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||
LEFT JOIN paliad.deadline_rules 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'
|
||||
WHERE te.is_active;
|
||||
|
||||
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);
|
||||
146
internal/db/migrations/127_wave0_tier0_deadline_fixes.down.sql
Normal file
146
internal/db/migrations/127_wave0_tier0_deadline_fixes.down.sql
Normal file
@@ -0,0 +1,146 @@
|
||||
-- Revert t-paliad-263 Wave 0 + m/paliad#99.
|
||||
-- Restores each Tier 0 row to its pre-fix state per
|
||||
-- docs/research-deadlines-completeness-2026-05-25.md §10. T0.5 and
|
||||
-- T0.6 are NOT reverted here — they live in mig 124's down.
|
||||
--
|
||||
-- audit_reason set_config required for the mig 079 trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 127 revert: unwind Tier 0 deadline-rule corrections (Wave 0 + #99)',
|
||||
true);
|
||||
|
||||
-- T0.1 defence: 2mo + RoP.049.1 → 3mo + RoP.49.1
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 3,
|
||||
rule_code = 'RoP.49.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.defence'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.2 rejoin: 1mo + RoP.052/UPC.RoP.52 → 2mo + NULL/NULL
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 2,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.rejoin'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.3 response: 3mo + RoP.235.1 → 2mo + NULL
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 2,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.4 beruf_begr: parent_id NULL → de.inf.lg.berufung
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (
|
||||
SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.berufung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
LIMIT 1
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.inf.lg.beruf_begr'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.7 reply: clear citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.reply'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.9 notice: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.220.1',
|
||||
legal_source = 'UPC.RoP.220.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.notice'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.10 grounds: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.220.1',
|
||||
legal_source = 'UPC.RoP.220.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.grounds'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.12 dpma.opp erwiderung: restore court-set=false + §59 citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
rule_code = '§ 59 PatG',
|
||||
legal_source = 'DE.PatG.59.3',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.13 dpma.appeal.bpatg begründung: restore court-set=false + §75 citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
rule_code = '§ 75 PatG',
|
||||
legal_source = 'DE.PatG.75.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.14 bpatg erwidg: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 82 PatG',
|
||||
legal_source = 'DE.PatG.82.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bpatg.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.15 bgh begründung: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 111 PatG',
|
||||
legal_source = 'DE.PatG.111.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.16 bgh erwiderung: revert court-set + citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
rule_code = '§ 111 PatG',
|
||||
legal_source = 'DE.PatG.111.3',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.17 epa.opp opd erwidg: revert court-set
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'epa.opp.opd.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- #99 upc.inf.cfi.soc: clear citation backfill
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
477
internal/db/migrations/127_wave0_tier0_deadline_fixes.up.sql
Normal file
477
internal/db/migrations/127_wave0_tier0_deadline_fixes.up.sql
Normal file
@@ -0,0 +1,477 @@
|
||||
-- t-paliad-263 Wave 0 + m/paliad#99 — Tier 0 deadline-rule corrections.
|
||||
--
|
||||
-- Source: docs/research-deadlines-completeness-2026-05-25.md §10 Tier 0
|
||||
-- (curie's bulletproof completeness audit, merged 2026-05-25 as commit
|
||||
-- 94a9e7e). 16 distinct single-row UPDATEs across UPC + DE-LG + DPMA +
|
||||
-- EPA proceedings; T0.5 + T0.6 were shipped separately as mig 124
|
||||
-- (m/paliad#95, de.inf.lg Replik/Duplik sequencing) and are not
|
||||
-- repeated here. T0.8 (covered by T0.2) and T0.11 (covered by T0.1)
|
||||
-- are dedup'd out per the audit's own note.
|
||||
--
|
||||
-- Also folds in m/paliad#99 (UPC Statement of Claim missing legal
|
||||
-- citation): upc.inf.cfi.soc.rule_code / legal_source backfilled to
|
||||
-- UPC RoP R.13(1). Same migration file, separate UPDATE block with
|
||||
-- its own guard.
|
||||
--
|
||||
-- All fixes within the existing schema (no new columns). Each UPDATE
|
||||
-- is guarded by a WHERE clause that matches only the pre-fix row
|
||||
-- state (per mig 095 convention) — re-applying against a DB that
|
||||
-- already carries the fix matches zero rows and no-ops, so there are
|
||||
-- no duplicate deadline_rule_audit entries on idempotent re-runs.
|
||||
--
|
||||
-- Verification DO block at the end RAISEs EXCEPTION if any of the
|
||||
-- patched rows is left in an inconsistent shape (mixing pre-fix and
|
||||
-- post-fix state).
|
||||
--
|
||||
-- audit_reason set_config required at the top — the mig 079 trigger
|
||||
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
|
||||
-- on any UPDATE without it.
|
||||
--
|
||||
-- Slot 127 reserved per paliadin: sequence is 124 brunel #95 (done),
|
||||
-- 125 hermes #97, 126 icarus #80, 127 brunel Wave 0 + #99, 128+ next.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 127: t-paliad-263 Wave 0 + m/paliad#99 — Tier 0 deadline-rule corrections from curie''s audit (docs/research-deadlines-completeness-2026-05-25.md §10) plus UPC SoC R.13 citation',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.1 upc.rev.cfi.defence — duration 3mo → 2mo per RoP.049.1.
|
||||
-- Zero-pads the rule_code citation to canonical form. Audit §5
|
||||
-- (wrong period — every UPC_REV tracked in paliad today computes
|
||||
-- Defence at +3 months, statute says +2). Verbatim from
|
||||
-- UPCRoP.049.1: "The defendant shall lodge a Defence to revocation
|
||||
-- within two months of service of the Statement for revocation."
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 2,
|
||||
rule_code = 'RoP.049.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.defence'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 3
|
||||
AND rule_code = 'RoP.49.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.2 upc.rev.cfi.rejoin — duration 2mo → 1mo per RoP.052; add citation.
|
||||
-- Audit §5 (wrong period). Verbatim from UPCRoP.052: "Within one
|
||||
-- month of the service of the Reply the defendant may lodge a
|
||||
-- Rejoinder to the Reply to the Defence to revocation."
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 1,
|
||||
rule_code = 'RoP.052',
|
||||
legal_source = 'UPC.RoP.52',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.rejoin'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 2
|
||||
AND rule_code IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.3 upc.apl.merits.response — duration 2mo → 3mo per RoP.235.1.
|
||||
-- Audit §5 (wrong period — every main-track appellate respondent).
|
||||
-- Verbatim from UPCRoP.235.1: "Within three months of service of
|
||||
-- the Statement of grounds of appeal pursuant to Rule 224.2(a),
|
||||
-- any other party … may lodge a Statement of response."
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 3,
|
||||
rule_code = 'RoP.235.1',
|
||||
legal_source = 'UPC.RoP.235.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 2
|
||||
AND rule_code IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.4 de.inf.lg.beruf_begr — parent_id = NULL (was de.inf.lg.berufung).
|
||||
-- Audit §7.1 — every DE-LG-Verletzung appeal renders the
|
||||
-- Berufungsbegründung at trigger + 1mo (Berufung) + 2mo = 3 months
|
||||
-- from Urteil-service. Per ZPO §520(2) "die Frist für die
|
||||
-- Berufungsbegründung beträgt zwei Monate. Sie beginnt mit der
|
||||
-- Zustellung des in vollständiger Form abgefassten Urteils" → 2
|
||||
-- months from Urteil-service (parallel to, not chained off, the
|
||||
-- Berufungsfrist itself). NULL parent_id makes the rule anchor
|
||||
-- on the proceeding's trigger date — matches how the symmetric
|
||||
-- de.inf.olg.begruendung is modelled.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.inf.lg.beruf_begr'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND parent_id = (
|
||||
SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.berufung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.5 / T0.6 de.inf.lg.replik + de.inf.lg.duplik — already shipped
|
||||
-- as mig 124 (m/paliad#95). Not repeated here. Idempotency of the
|
||||
-- audit's Tier 0 sweep against a fresh DB is preserved because mig
|
||||
-- 124 runs before this one and is itself guarded.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.7 upc.rev.cfi.reply — backfill rule_code + legal_source per RoP.051.
|
||||
-- Audit §4.1 — duration (2mo) unchanged. Verbatim from UPCRoP.051:
|
||||
-- "Reply to Defence to revocation and Application to amend the
|
||||
-- patent. The claimant in the revocation action may, within two
|
||||
-- months of service of the Defence to revocation and the
|
||||
-- Application to amend the patent, if any, lodge a Reply…"
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.051',
|
||||
legal_source = 'UPC.RoP.51',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.reply'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code IS NULL
|
||||
AND legal_source IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.9 upc.apl.merits.notice — citation drift RoP.220.1 → RoP.224.1.a.
|
||||
-- Audit §4.1 — duration unchanged. R.220.1 is the umbrella ("an
|
||||
-- appeal may be brought"); R.224.1(a) carries the Notice-of-appeal
|
||||
-- 2-month period explicitly.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.224.1.a',
|
||||
legal_source = 'UPC.RoP.224.1.a',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.notice'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.220.1'
|
||||
AND legal_source = 'UPC.RoP.220.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.10 upc.apl.merits.grounds — citation drift RoP.220.1 → RoP.224.2.a.
|
||||
-- Audit §4.1 — duration unchanged. R.224.2(a) sets the Grounds
|
||||
-- 4-month period for decisions referred to in R.220.1(a) and (b).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.224.2.a',
|
||||
legal_source = 'UPC.RoP.224.2.a',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.grounds'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.220.1'
|
||||
AND legal_source = 'UPC.RoP.220.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.12 dpma.opp.dpma.erwiderung — flip is_court_set = true; drop the
|
||||
-- § 59(3) PatG citation. Audit §4.3 + §9.1: §59(3) addresses
|
||||
-- Anhörung, not a 4-month response period. No statutory
|
||||
-- Erwiderungsfrist exists in §59 — the 4-month figure is DPMA
|
||||
-- practice (DPMA-Richtlinien D-IV 5.2). Modelled court-set, the
|
||||
-- 4-month value remains the default-display heuristic the
|
||||
-- lawyer overrides via "Datum setzen".
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'DE.PatG.59.3';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.13 dpma.appeal.bpatg.begruendung — flip is_court_set = true; drop
|
||||
-- the § 75 PatG citation. Audit §4.3 + §9.1: §75 PatG addresses
|
||||
-- aufschiebende Wirkung only, not a Begründungsfrist. No fixed
|
||||
-- Begründungsfrist for BPatG-Beschwerde exists in PatG §§73-80 —
|
||||
-- the BPatG sets it in the individual case. 1-month default
|
||||
-- retained as display heuristic.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'DE.PatG.75.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.14 de.null.bpatg.erwidg — citation DE.PatG.82.1 → DE.PatG.82.3.
|
||||
-- Audit §4.4 — duration (2 months) is correct. §82(1) carries the
|
||||
-- 1-month Erklärungsfrist ("sich darüber zu erklären"); the full
|
||||
-- Klageerwiderung 2-month period lives in §82(3).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 82 Abs. 3 PatG',
|
||||
legal_source = 'DE.PatG.82.3',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bpatg.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.PatG.82.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.15 de.null.bgh.begruendung — citation DE.PatG.111.1 →
|
||||
-- DE.ZPO.520.2 (via PatG §117). Audit §4.4 — duration (3 months)
|
||||
-- is correct. §111 PatG defines the Grounds of Berufung
|
||||
-- (Verletzung des Bundesrechts), not a Begründungsfrist; the
|
||||
-- 3-month figure is supplied by §117 PatG → ZPO §520(2).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 520 Abs. 2 ZPO i.V.m. § 117 PatG',
|
||||
legal_source = 'DE.ZPO.520.2',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.PatG.111.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.16 de.null.bgh.erwiderung — flip is_court_set = true; recite as
|
||||
-- DE.ZPO.521.2 (via PatG §117). Audit §4.4 + §9.1 — §111 PatG
|
||||
-- has no Erwiderungsfrist clause. The actual Erwiderungsfrist
|
||||
-- for BGH-Nichtigkeitsberufung is set by the court per §117
|
||||
-- PatG → ZPO §521(2). 2-month default retained as display
|
||||
-- heuristic.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
rule_code = '§ 521 Abs. 2 ZPO i.V.m. § 117 PatG',
|
||||
legal_source = 'DE.ZPO.521.2',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'DE.PatG.111.3';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.17 epa.opp.opd.erwidg — flip is_court_set = true. Audit §4.5 +
|
||||
-- §9.1: R.79(1) EPÜ authorises the Opposition Division to set
|
||||
-- the period, but does not specify a fixed 4 months. The 4-month
|
||||
-- figure is administrative practice (EPO Guidelines D-IV 5.2).
|
||||
-- Citation retained as the rule-of-authority for the OD's
|
||||
-- discretion. 4-month default retained as display heuristic.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'epa.opp.opd.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'EU.EPC-R.79.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- m/paliad#99 upc.inf.cfi.soc — backfill UPC RoP R.13(1) citation.
|
||||
-- The Statement of Claim is defined in UPC RoP R.13 (R.13.1
|
||||
-- lists the required contents). The row carries no statutory
|
||||
-- deadline (duration_value = 0, parent_id IS NULL — the SoC is
|
||||
-- the originating filing that anchors the proceeding's trigger
|
||||
-- date), but the catalog UI surfaces the rule citation in
|
||||
-- result cards and the Type=Statement-of-Claim / Rule=Auto
|
||||
-- resolution; both render blank today because rule_code +
|
||||
-- legal_source are NULL. Backfill leaves duration / anchor /
|
||||
-- party untouched.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.013.1',
|
||||
legal_source = 'UPC.RoP.13.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code IS NULL
|
||||
AND legal_source IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- Hard assertions. Each touched row must end up in its post-fix
|
||||
-- shape. Re-running the migration after a successful first run is a
|
||||
-- no-op for the data but the assertions still pass because they
|
||||
-- check the post-fix state.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_count integer;
|
||||
BEGIN
|
||||
-- T0.1 defence: dur=2 + canonical zero-padded rule_code
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.rev.cfi.defence'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 2
|
||||
AND rule_code = 'RoP.049.1';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.1: upc.rev.cfi.defence not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.2 rejoin: dur=1
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.rev.cfi.rejoin'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 1
|
||||
AND rule_code = 'RoP.052';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.2: upc.rev.cfi.rejoin not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.3 response: dur=3
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.merits.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 3
|
||||
AND rule_code = 'RoP.235.1';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.3: upc.apl.merits.response not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.4 beruf_begr: parent_id IS NULL
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.beruf_begr'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND parent_id IS NULL;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.4: de.inf.lg.beruf_begr not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.7 reply: citation backfilled
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.rev.cfi.reply'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.051'
|
||||
AND legal_source = 'UPC.RoP.51';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.7: upc.rev.cfi.reply not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.9 notice: citation RoP.224.1.a
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.merits.notice'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.224.1.a'
|
||||
AND legal_source = 'UPC.RoP.224.1.a';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.9: upc.apl.merits.notice not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.10 grounds: citation RoP.224.2.a
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.merits.grounds'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.224.2.a'
|
||||
AND legal_source = 'UPC.RoP.224.2.a';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.10: upc.apl.merits.grounds not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.12 dpma.opp erwiderung: court-set, no citation
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND legal_source IS NULL
|
||||
AND rule_code IS NULL;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.12: dpma.opp.dpma.erwiderung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.13 dpma.appeal.bpatg begründung: court-set, no citation
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND legal_source IS NULL
|
||||
AND rule_code IS NULL;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.13: dpma.appeal.bpatg.begruendung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.14 bpatg erwidg: §82.3
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.null.bpatg.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.PatG.82.3';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.14: de.null.bpatg.erwidg not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.15 bgh begründung: ZPO §520.2
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.null.bgh.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.ZPO.520.2';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.15: de.null.bgh.begruendung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.16 bgh erwiderung: court-set, ZPO §521.2
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.null.bgh.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND legal_source = 'DE.ZPO.521.2';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.16: de.null.bgh.erwiderung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.17 epa.opp opd erwidg: court-set
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'epa.opp.opd.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.17: epa.opp.opd.erwidg not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- #99 upc.inf.cfi.soc: citation backfilled
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.013.1'
|
||||
AND legal_source = 'UPC.RoP.13.1';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 #99: upc.inf.cfi.soc not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Revert t-paliad-271 Wave 2 Tier-3 Slice A — drop duration_unit /
|
||||
-- alt_duration_unit CHECK constraints. Pre-mig-128 the columns accepted
|
||||
-- arbitrary text, so dropping the CHECKs restores that shape exactly.
|
||||
-- No data revert necessary — the constraint addition was purely
|
||||
-- additive and validated against live data before adding.
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;
|
||||
36
internal/db/migrations/128_deadline_rules_unit_check.up.sql
Normal file
36
internal/db/migrations/128_deadline_rules_unit_check.up.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- t-paliad-271 Wave 2 Tier-3 Slice A — duration_unit CHECK constraint with
|
||||
-- 'working_days' added to the allowed set.
|
||||
--
|
||||
-- Per docs/research-deadlines-completeness-2026-05-25.md Tier 3 Primitive 1
|
||||
-- (T3.1) — the calculator gains a business-day arithmetic path for UPC RoP
|
||||
-- R.198 / R.213 (and downstream for any rule that needs the 31d-OR-20wd
|
||||
-- combine-max pattern). The schema currently accepts free-text on
|
||||
-- duration_unit (no CHECK), which is why 'working_days' rows already exist
|
||||
-- in the DB but were silently dropped by the calculator. Adding the CHECK
|
||||
-- pins the contract and prevents typos.
|
||||
--
|
||||
-- alt_duration_unit gets the same constraint (NULL-tolerant) so the alt
|
||||
-- path stays in lockstep with the primary path.
|
||||
--
|
||||
-- Idempotent: DROP CONSTRAINT IF EXISTS before ADD. Existing data was
|
||||
-- audited via `SELECT DISTINCT duration_unit FROM paliad.deadline_rules`
|
||||
-- on 2026-05-25 (returned only days/weeks/months) plus the two live
|
||||
-- alt-unit rows already at 'working_days' — both shapes pass.
|
||||
--
|
||||
-- audit_reason set_config is NOT needed for DDL (mig 079 trigger fires on
|
||||
-- INSERT/UPDATE/DELETE on the rows, not on ALTER TABLE).
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_duration_unit_check
|
||||
CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days'));
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_alt_duration_unit_check
|
||||
CHECK (alt_duration_unit IS NULL
|
||||
OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days'));
|
||||
11
internal/db/migrations/129_project_event_choices.down.sql
Normal file
11
internal/db/migrations/129_project_event_choices.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- t-paliad-265 — drop per-event-card choices schema.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 129 down: drop project_event_choices + deadline_rules.choices_offered',
|
||||
true);
|
||||
|
||||
DROP TABLE IF EXISTS paliad.project_event_choices;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS choices_offered;
|
||||
116
internal/db/migrations/129_project_event_choices.up.sql
Normal file
116
internal/db/migrations/129_project_event_choices.up.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- t-paliad-265 / m/paliad#96 — per-event-card optional choices on the
|
||||
-- Verfahrensablauf timeline.
|
||||
--
|
||||
-- Design: docs/design-event-card-choices-2026-05-25.md
|
||||
-- Decisions: see §11 of the design doc.
|
||||
--
|
||||
-- Two schema changes:
|
||||
--
|
||||
-- 1. paliad.project_event_choices — new persistence table holding the
|
||||
-- user's per-card picks scoped to a project. One row per
|
||||
-- (project, submission_code, choice_kind). Re-picking is an UPDATE
|
||||
-- (UNIQUE constraint enforces idempotence).
|
||||
--
|
||||
-- 2. paliad.deadline_rules.choices_offered jsonb — opt-in declaration
|
||||
-- of which choice-kinds each rule offers. The projection engine
|
||||
-- reads this to decide whether to render the caret affordance on
|
||||
-- a card. Seeded for every event_type='decision' rule (appellant),
|
||||
-- every priority='optional' rule (skip), and the two Klageerwiderung
|
||||
-- rows (include_ccr).
|
||||
--
|
||||
-- NOTE on join key: the design doc named the join column "rule_code".
|
||||
-- Live verification (2026-05-25 SELECT against paliad.deadline_rules)
|
||||
-- showed `rule_code` is NULL on every decision row — it's the legal-
|
||||
-- source citation column, not a stable identifier. The
|
||||
-- AnchorOverrides plumbing in internal/services/fristenrechner.go
|
||||
-- already keys on `submission_code` (UIDeadline.Code populates from
|
||||
-- submission_code, lines 351-352), so we mirror that decision here:
|
||||
-- the join column is `submission_code`. Same intent, correct field.
|
||||
--
|
||||
-- Idempotent: CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS +
|
||||
-- UPDATEs guarded by WHERE choices_offered IS NULL so re-applying
|
||||
-- against an already-seeded DB no-ops.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 129: add paliad.project_event_choices + deadline_rules.choices_offered for per-event-card optional choices (t-paliad-265 / m/paliad#96)',
|
||||
true);
|
||||
|
||||
-- 1. The choice-storage table ----------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.project_event_choices (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
choice_kind text NOT NULL CHECK (choice_kind IN ('appellant', 'include_ccr', 'skip')),
|
||||
choice_value text NOT NULL,
|
||||
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (project_id, submission_code, choice_kind)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS project_event_choices_project_idx
|
||||
ON paliad.project_event_choices (project_id);
|
||||
|
||||
ALTER TABLE paliad.project_event_choices ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS project_event_choices_select ON paliad.project_event_choices;
|
||||
CREATE POLICY project_event_choices_select ON paliad.project_event_choices
|
||||
FOR SELECT USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS project_event_choices_mutate ON paliad.project_event_choices;
|
||||
CREATE POLICY project_event_choices_mutate ON paliad.project_event_choices
|
||||
FOR ALL
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
COMMENT ON TABLE paliad.project_event_choices IS
|
||||
'Per-event-card user picks scoped to a project. choice_kind ∈ {appellant, include_ccr, skip}. '
|
||||
'choice_value namespace per kind: appellant=claimant|defendant|both|none; include_ccr=true|false; '
|
||||
'skip=true|false. Join key submission_code matches paliad.deadline_rules.submission_code (the same key '
|
||||
'AnchorOverrides uses). UNIQUE(project,submission_code,kind) keeps re-picks idempotent. '
|
||||
'Audit-logged via paliad.system_audit_log (event_type=project_event_choice.set).';
|
||||
|
||||
-- 2. The choices_offered opt-in column ------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS choices_offered jsonb;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.choices_offered IS
|
||||
'Declares which per-card choice-kinds this rule offers on the Verfahrensablauf timeline. '
|
||||
'NULL = no caret affordance (default). Example shapes: '
|
||||
'{"appellant": ["claimant","defendant","both","none"]} on decision rules, '
|
||||
'{"skip": [true, false]} on optional rules, '
|
||||
'{"include_ccr": [true, false]} on Klageerwiderung rules. '
|
||||
'Engine and frontend read it; storing per-kind value lists keeps the contract self-describing.';
|
||||
|
||||
-- 3. Seed -----------------------------------------------------------------
|
||||
|
||||
-- 3a. Every published decision rule offers the appellant choice.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET choices_offered = '{"appellant": ["claimant", "defendant", "both", "none"]}'::jsonb
|
||||
WHERE event_type = 'decision'
|
||||
AND lifecycle_state = 'published'
|
||||
AND choices_offered IS NULL;
|
||||
|
||||
-- 3b. Every published optional rule offers the skip choice.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET choices_offered = '{"skip": [true, false]}'::jsonb
|
||||
WHERE priority = 'optional'
|
||||
AND lifecycle_state = 'published'
|
||||
AND choices_offered IS NULL;
|
||||
|
||||
-- 3c. Klageerwiderung rules offer the include_ccr choice. Two rows
|
||||
-- today (upc.inf.cfi.sod + de.inf.lg.erwidg) — verified live
|
||||
-- (2026-05-25 SELECT FROM paliad.deadline_rules WHERE name ILIKE
|
||||
-- 'Klageerwiderung'); the UPC INF Klageerwiderung is `sod` (Statement
|
||||
-- of Defence, R.24 RoP), not `def`. Slice B (Q4 bundle) is the
|
||||
-- user-visible feature.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET choices_offered = '{"include_ccr": [true, false]}'::jsonb
|
||||
WHERE submission_code IN ('upc.inf.cfi.sod', 'de.inf.lg.erwidg')
|
||||
AND lifecycle_state = 'published'
|
||||
AND choices_offered IS NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS language;
|
||||
17
internal/db/migrations/130_submission_drafts_language.up.sql
Normal file
17
internal/db/migrations/130_submission_drafts_language.up.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- t-paliad-276 / m/paliad#108: per-draft output language for the
|
||||
-- Submissions generator.
|
||||
--
|
||||
-- The submission editor lets the lawyer pick DE or EN per draft so the
|
||||
-- generator selects the matching template variant + resolves language-
|
||||
-- aware variables ({{procedural_event.name_de}} vs _en). Default is
|
||||
-- 'de' to match the primary-language convention in CLAUDE.md and to
|
||||
-- keep existing rows behaving exactly as before (every legacy draft
|
||||
-- was implicitly DE; the resolved bag for those drafts is unchanged
|
||||
-- under language='de').
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN IF NOT EXISTS language text NOT NULL DEFAULT 'de'
|
||||
CONSTRAINT submission_drafts_language_check CHECK (language IN ('de', 'en'));
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.language IS
|
||||
't-paliad-276: output language for the generated .docx. ''de'' or ''en''. Drives template variant selection ({code}.{lang}.docx fallback chain) and language-aware variable resolution.';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- t-paliad-277 rollback.
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS selected_parties,
|
||||
DROP COLUMN IF EXISTS last_imported_at;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- t-paliad-277 / m/paliad#109: per-draft party selection + import provenance.
|
||||
--
|
||||
-- Adds two columns to paliad.submission_drafts:
|
||||
--
|
||||
-- selected_parties uuid[] — IDs of paliad.parties rows the lawyer
|
||||
-- has chosen to mention in this specific submission. An empty
|
||||
-- array (the default) means "include every party on the project"
|
||||
-- so all existing drafts keep their current rendering. Non-empty
|
||||
-- restricts the variable bag to the chosen subset, grouped by
|
||||
-- role in SubmissionVarsService.
|
||||
--
|
||||
-- last_imported_at timestamptz — when the lawyer last clicked
|
||||
-- "Aus Projekt importieren" on the draft editor (or NULL if they
|
||||
-- never did). The frontend surfaces this timestamp next to the
|
||||
-- button so a stale draft is obvious at a glance.
|
||||
--
|
||||
-- Both columns are purely additive and nullable / default-bearing —
|
||||
-- the migration is safe to apply with active drafts in the table.
|
||||
-- No FK on selected_parties: paliad.parties is project-scoped and we
|
||||
-- prune stale references on read inside SubmissionVarsService rather
|
||||
-- than chasing FK cascades across two tables (the variable bag silently
|
||||
-- drops any uuid that no longer matches a row in paliad.parties).
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN IF NOT EXISTS selected_parties uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
ADD COLUMN IF NOT EXISTS last_imported_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.selected_parties IS
|
||||
't-paliad-277: party IDs (paliad.parties) the lawyer has chosen to mention in this submission. Empty array = include every party on the project (backward-compat default). Non-empty = restrict to subset, grouped by role.';
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.last_imported_at IS
|
||||
't-paliad-277: timestamp of the last "Aus Projekt importieren" click — surfaced next to the button so the lawyer can see staleness at a glance. NULL = never imported.';
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Rollback of mig 132 (t-paliad-284 Wave 1 + m/paliad#116).
|
||||
--
|
||||
-- Reverses §0 (R.104/R.105 citation backfill) + §1..§11 (11 Tier 1
|
||||
-- INSERTs) + §12 (T1.12 re-anchor of upc.pi.cfi.response).
|
||||
--
|
||||
-- Does NOT reverse §13b (Q6 archived-litigation cleanup) — those rows
|
||||
-- were already in lifecycle_state='archived' before deletion and are not
|
||||
-- surfaced by any product code path. Restoring them would require the
|
||||
-- pre-mig-132 backup. Leaving them gone is the correct rollback choice;
|
||||
-- emergency restore goes via mig 123 backup snapshot.
|
||||
--
|
||||
-- DOES restore §13a (re-add the deadline_rule_audit.rule_id FK) so the
|
||||
-- audit-table schema returns to its pre-mig-132 shape on rollback. Any
|
||||
-- orphan audit rows accumulated under mig 132 (rule_id pointing at
|
||||
-- now-deleted rules) would block the FK re-add; the rollback DELETE
|
||||
-- below removes them first.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 132 down: rollback Wave 1 Tier 1 rule additions + R.105 citation backfill + T1.12 re-anchor (t-paliad-284 / m/paliad#116)',
|
||||
true);
|
||||
|
||||
-- §12 down — un-re-anchor upc.pi.cfi.response back to its broken root state.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
is_court_set = false,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.pi.cfi.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND rule_code = 'RoP.211.2';
|
||||
|
||||
-- §1..§11 down — delete the 11 Tier 1 INSERTs by submission_code.
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code IN (
|
||||
'upc.inf.cfi.cmo_review',
|
||||
'upc.inf.cfi.confidentiality_response',
|
||||
'upc.apl.order.response_orders', -- delete child first (FK to grounds_orders)
|
||||
'upc.apl.order.grounds_orders',
|
||||
'upc.inf.cfi.cons_orders',
|
||||
'upc.inf.cfi.rectification',
|
||||
'upc.pi.cfi.deficiency',
|
||||
'upc.pi.cfi.merits_start',
|
||||
'upc.inf.cfi.translation_request',
|
||||
'upc.inf.cfi.interpreter_cost',
|
||||
'upc.inf.cfi.translations_lodge'
|
||||
)
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- §0 down — clear the R.104/R.105 citation on upc.inf.cfi.interim.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
rule_codes = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.104'
|
||||
AND legal_source = 'UPC.RoP.104';
|
||||
|
||||
-- §13a down — re-add the deadline_rule_audit.rule_id FK with the
|
||||
-- original ON DELETE CASCADE shape. Purge any orphan audit rows first
|
||||
-- (audit entries pointing at rule_ids that no longer exist in
|
||||
-- deadline_rules) so the FK re-add doesn't fail validation.
|
||||
DELETE FROM paliad.deadline_rule_audit a
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr WHERE dr.id = a.rule_id
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.deadline_rule_audit
|
||||
ADD CONSTRAINT deadline_rule_audit_rule_id_fkey
|
||||
FOREIGN KEY (rule_id) REFERENCES paliad.deadline_rules(id) ON DELETE CASCADE;
|
||||
659
internal/db/migrations/132_wave1_tier1_rule_additions.up.sql
Normal file
659
internal/db/migrations/132_wave1_tier1_rule_additions.up.sql
Normal file
@@ -0,0 +1,659 @@
|
||||
-- t-paliad-284 Wave 1 + m/paliad#116 — Tier 1 deadline-rule additions
|
||||
-- (12 high-frequency procedural events) + UPC RoP R.104/R.105 Interim
|
||||
-- Conference citation backfill + Q6 archived-litigation cleanup.
|
||||
--
|
||||
-- Source: docs/research-deadlines-completeness-2026-05-25.md
|
||||
-- • §10 Tier 1 table (T1.1 .. T1.12)
|
||||
-- • §3.1 missing-rules catalogue (per-rule statutory citations)
|
||||
-- • §9.7 / Q6 (drop the _archived_litigation.* rows — m's design ack
|
||||
-- locked in 2026-05-25)
|
||||
--
|
||||
-- m's report (2026-05-25 17:12) also explicitly named "Zwischenverfahren /
|
||||
-- Interim Conference 105" as missing a rule citation. The audit does not
|
||||
-- list R.105 as a Tier 1 item (the row upc.inf.cfi.interim already exists
|
||||
-- as a court-set anchor), so the fix is to BACKFILL rule_code/legal_source
|
||||
-- on that row rather than to insert a new rule. Done here as a separate
|
||||
-- §0 section, with both RoP.104 (Aims of the interim conference) and
|
||||
-- RoP.105 (Holding of the interim conference) cited via rule_codes[].
|
||||
--
|
||||
-- Wave 2 Slice A primitives (mig 128: working_days unit + combine_op +
|
||||
-- timing='before' backward snap in deadline_calculator.go) are used by:
|
||||
-- • T1.8 upc.pi.cfi.merits_start — 31d OR 20wd, combine_op=max
|
||||
-- • T1.9 upc.inf.cfi.translation_request — 1 month BEFORE oral hearing
|
||||
-- • T1.10 upc.inf.cfi.interpreter_cost — 2 weeks BEFORE oral hearing
|
||||
-- Wave 2 Slice A landed mig 128 (`deadline_rules_unit_check`) — these
|
||||
-- rules are no longer blocked.
|
||||
--
|
||||
-- Slot 132 reserved: 127 brunel Wave 0, 128 knuth W2-A, 129 demeter,
|
||||
-- 130 atlas, 131 artemis → 132 this migration.
|
||||
--
|
||||
-- Idempotency:
|
||||
-- • INSERTs guarded with `WHERE NOT EXISTS (... submission_code = ...)`
|
||||
-- so re-applying matches zero rows on the second run.
|
||||
-- • UPDATEs guarded with `WHERE` clauses that match the pre-fix row
|
||||
-- state only (mig 095 convention).
|
||||
-- • DELETE guarded by lifecycle_state='archived' AND prefix — repeats
|
||||
-- match zero rows after first run.
|
||||
--
|
||||
-- audit_reason set_config is required at the top (mig 079 trigger on
|
||||
-- paliad.deadline_rules raises EXCEPTION 'audit reason required' for
|
||||
-- any INSERT / UPDATE / DELETE without it).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 132: t-paliad-284 Wave 1 + m/paliad#116 — Tier 1 deadline-rule additions (12 rules) from curie''s audit §10 + UPC RoP R.104/105 Interim Conference citation backfill (m''s 2026-05-25 17:12 report) + Q6 archived-litigation cleanup (audit §9.7)',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- §0 R.104/R.105 — Backfill citation on the existing Interim Conference row.
|
||||
-- m's report flagged that `upc.inf.cfi.interim` (Zwischenverfahren) renders
|
||||
-- with no rule reference at /admin/rules. The row exists as a court-set
|
||||
-- anchor (duration=0, parent_id=NULL, primary_party='court'). The
|
||||
-- governing UPC Rules of Procedure are:
|
||||
-- • R.104 — Aims of the interim conference (the substantive rule)
|
||||
-- • R.105 — Holding of the interim conference (procedural)
|
||||
-- Both cited via the rule_codes[] array; rule_code/legal_source carry
|
||||
-- the primary citation (R.104 — Aims).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.104',
|
||||
legal_source = 'UPC.RoP.104',
|
||||
rule_codes = ARRAY['RoP.104', 'RoP.105'],
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code IS NULL
|
||||
AND legal_source IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- §1 T1.1 upc.inf.cfi.cmo_review — Review of case-management order.
|
||||
-- 15 days from CMO service. UPC RoP R.333.2: "Any party adversely
|
||||
-- affected by a case management order may within 15 days of service
|
||||
-- of the order apply to the panel for a review." Routine in busy LDs
|
||||
-- (Munich CMO traffic ~weekly). Anchor: the Interim Conference row,
|
||||
-- which is where CMOs are typically issued.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8, -- upc.inf.cfi
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.cmo_review',
|
||||
'Überprüfung Verfahrensanordnung',
|
||||
'Review of Case-Management Order',
|
||||
'both', 15, 'days', 'after', 'RoP.333.2', 'UPC.RoP.333.2',
|
||||
'optional', false, 'published', true, 42,
|
||||
'Frist 15 Tage ab Zustellung der Verfahrensanordnung (R.333.2). Jede beschwerte Partei kann beim Spruchkörper Überprüfung beantragen.',
|
||||
'15-day period from service of the case-management order (R.333.2). Any adversely-affected party may apply to the panel for a review.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cmo_review'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §2 T1.2 upc.inf.cfi.confidentiality_response — Response to opposing
|
||||
-- party's confidentiality application. 14 days from receipt of the
|
||||
-- opposing party's R.262.2 application: "Within 14 days of service
|
||||
-- … the other party may lodge an Application to the contrary."
|
||||
-- Trigger event 25 (paliad.trigger_events) maps 1:1 to this rule.
|
||||
-- Daily occurrence in HLC infringement work. Anchor: Statement of
|
||||
-- Claim row as proceeding root — actual trigger date supplied via
|
||||
-- 'Datum setzen' when the opp party files, since the confidentiality
|
||||
-- app is not itself modelled as a deadline_rules row.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, trigger_event_id, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.confidentiality_response',
|
||||
'Erwiderung auf Vertraulichkeitsantrag',
|
||||
'Response to Confidentiality Application',
|
||||
'both', 14, 'days', 'after', 'RoP.262.2', 'UPC.RoP.262.2',
|
||||
'optional', false, 'published', true, 8,
|
||||
25,
|
||||
'Frist 14 Tage ab Zustellung des Vertraulichkeitsantrags der Gegenseite (R.262.2). Datum bei Eingang des Antrags manuell setzen.',
|
||||
'14-day period from service of the opposing party''s confidentiality application (R.262.2). Set trigger date manually on receipt of the application.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.confidentiality_response'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §3 T1.3 upc.apl.order.grounds_orders — Statement of Grounds on the
|
||||
-- orders-track appeal. 15 days from service of the appealed
|
||||
-- order/decision. UPC RoP R.224.2(b): "A Statement of grounds of
|
||||
-- appeal shall be lodged … within 15 days of service of the
|
||||
-- decision/order in cases referred to in Rule 220.1(c), Rule 220.2
|
||||
-- and Rule 221.3." Existing upc.apl.order tree has the with_leave
|
||||
-- notice but no separate grounds row — adding it.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 20, -- upc.apl.order
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.order'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.apl.order.grounds_orders',
|
||||
'Berufungsbegründung (Orders Track)',
|
||||
'Statement of Grounds (Orders Track)',
|
||||
'both', 15, 'days', 'after', 'RoP.224.2.b', 'UPC.RoP.224.2.b',
|
||||
'mandatory', false, 'published', true, 2,
|
||||
'Frist 15 Tage ab Zustellung der angegriffenen Anordnung/Entscheidung (R.224.2(b)).',
|
||||
'15-day period from service of the appealed order/decision (R.224.2(b)).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.grounds_orders'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §4 T1.4 upc.apl.order.response_orders — Statement of Response on the
|
||||
-- orders-track appeal. 15 days from service of the grounds. UPC RoP
|
||||
-- R.235.2: "Within 15 days of service of grounds of appeal pursuant
|
||||
-- to Rule 224.2(b), any other party … may lodge a Statement of
|
||||
-- response." Parent: the grounds_orders row inserted in §3, looked
|
||||
-- up by submission_code so this INSERT works either against a fresh
|
||||
-- DB or a partially-applied state.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 20,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.grounds_orders'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.apl.order.response_orders',
|
||||
'Berufungserwiderung (Orders Track)',
|
||||
'Statement of Response (Orders Track)',
|
||||
'both', 15, 'days', 'after', 'RoP.235.2', 'UPC.RoP.235.2',
|
||||
'optional', false, 'published', true, 3,
|
||||
'Frist 15 Tage ab Zustellung der Berufungsbegründung (R.235.2).',
|
||||
'15-day period from service of the Statement of grounds of appeal (R.235.2).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.response_orders'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §5 T1.5 upc.inf.cfi.cons_orders — Application for orders consequential
|
||||
-- on validity. 2 months from service of the validity decision. UPC
|
||||
-- RoP R.118.4: "The Court may, upon a reasoned request by one of
|
||||
-- the parties, … give a decision granting consequential orders.
|
||||
-- The application … shall be made within two months of service of
|
||||
-- the decision …". Common after central-division revocation in
|
||||
-- bifurcated UPC matters.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.decision'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.cons_orders',
|
||||
'Antrag auf Folgeentscheidungen',
|
||||
'Application for Consequential Orders',
|
||||
'both', 2, 'months', 'after', 'RoP.118.4', 'UPC.RoP.118.4',
|
||||
'optional', false, 'published', true, 60,
|
||||
'Frist 2 Monate ab Zustellung der Validitätsentscheidung (R.118.4). Antrag auf Folgeentscheidungen (z.B. nach Zentralkammer-Nichtigerklärung).',
|
||||
'2-month period from service of the validity decision (R.118.4). Application for orders consequential on validity (e.g. after central-division revocation).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cons_orders'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §6 T1.6 upc.inf.cfi.rectification — Application for rectification of a
|
||||
-- decision/order. 1 month from delivery of the decision. UPC RoP
|
||||
-- R.353: "Clerical mistakes, errors arising from any accidental
|
||||
-- slip or omission and obvious errors in a decision or order of
|
||||
-- the Court may be corrected by the Court of its own motion or on
|
||||
-- the application of a party. The application shall be made within
|
||||
-- one month of the decision or order being notified."
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.decision'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.rectification',
|
||||
'Antrag auf Berichtigung',
|
||||
'Application for Rectification',
|
||||
'both', 1, 'months', 'after', 'RoP.353', 'UPC.RoP.353',
|
||||
'optional', false, 'published', true, 70,
|
||||
'Frist 1 Monat ab Zustellung der Entscheidung/Anordnung (R.353). Berichtigung von Schreib-, Rechen- oder ähnlichen Versehen.',
|
||||
'1-month period from notification of the decision/order (R.353). Rectification of clerical mistakes, accidental slips or obvious errors.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.rectification'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §7 T1.7 upc.pi.cfi.deficiency — Cure of PI-application deficiency.
|
||||
-- 14 days from notification of the deficiency. UPC RoP R.207.6(a):
|
||||
-- "The Registry shall as soon as practicable examine the
|
||||
-- Application … and notify any deficiencies to the applicant. The
|
||||
-- applicant shall be invited to correct the deficiencies … within
|
||||
-- 14 days." Failure to cure leads to deemed-withdrawal.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 10, -- upc.pi.cfi
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.app'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.pi.cfi.deficiency',
|
||||
'Mängelbeseitigung Antrag',
|
||||
'Cure of Application Deficiency',
|
||||
'claimant', 14, 'days', 'after', 'RoP.207.6.a', 'UPC.RoP.207.6.a',
|
||||
'mandatory', false, 'published', true, 2,
|
||||
'Frist 14 Tage ab Mängelmitteilung durch die Geschäftsstelle (R.207.6(a)). Bei Nichtbehebung gilt der Antrag als zurückgenommen.',
|
||||
'14-day period from notification of deficiency by the Registry (R.207.6(a)). Failure to cure leads to deemed withdrawal of the application.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.deficiency'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §8 T1.8 upc.pi.cfi.merits_start — Start proceedings on the merits.
|
||||
-- 31 calendar days OR 20 working days, whichever is the longer,
|
||||
-- from grant of the PI. UPC RoP R.213.1 → R.198.1: "the applicant
|
||||
-- shall start proceedings leading to a decision on the merits of
|
||||
-- the case … within a period not exceeding 31 calendar days or
|
||||
-- 20 working days, whichever is the longer." Combine-max wiring
|
||||
-- via Wave 2 Slice A primitives (mig 128: working_days unit +
|
||||
-- combine_op). Failure to commence on time → PI lapses (R.213.2).
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
combine_op, timing, rule_code, legal_source, priority,
|
||||
is_court_set, lifecycle_state, is_active, sequence_order,
|
||||
deadline_notes, deadline_notes_en)
|
||||
SELECT 10,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.order'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.pi.cfi.merits_start',
|
||||
'Klage in der Hauptsache erheben',
|
||||
'Start Proceedings on the Merits',
|
||||
'claimant', 31, 'days',
|
||||
20, 'working_days', 'RoP.198.1',
|
||||
'max', 'after', 'RoP.213', 'UPC.RoP.213',
|
||||
'mandatory', false, 'published', true, 3,
|
||||
'Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme (R.213 i.V.m. R.198.1). Bei Versäumnis erlischt die einstweilige Maßnahme.',
|
||||
'31 calendar days OR 20 working days, whichever is the longer, from grant of the provisional measure (R.213 referring to R.198.1). Failure to commence within the period causes the provisional measure to lapse.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.merits_start'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §9 T1.9 upc.inf.cfi.translation_request — Request for simultaneous
|
||||
-- translation at the oral hearing. 1 month BEFORE the oral hearing.
|
||||
-- UPC RoP R.109.1: "A party requiring simultaneous interpretation
|
||||
-- of the oral hearing into a language other than the language of
|
||||
-- proceedings shall, no later than one month before the date of
|
||||
-- the oral hearing, lodge a request with the Court." timing='before'
|
||||
-- uses the backward-snap path in deadline_calculator.go (mig 128).
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.oral'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.translation_request',
|
||||
'Antrag auf Simultanübersetzung',
|
||||
'Request for Simultaneous Translation',
|
||||
'both', 1, 'months', 'before', 'RoP.109.1', 'UPC.RoP.109.1',
|
||||
'optional', false, 'published', true, 45,
|
||||
'Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung in eine andere Sprache als die Verfahrenssprache.',
|
||||
'1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation into a language other than the language of proceedings.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translation_request'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §10 T1.10 upc.inf.cfi.interpreter_cost — Notification of interpreter
|
||||
-- cost-bearing. 2 weeks BEFORE the oral hearing. UPC RoP R.109.4:
|
||||
-- "Where … the party which made the request for interpretation is
|
||||
-- not the party who has chosen the language of the proceedings,
|
||||
-- the costs of the interpretation … shall be borne by the
|
||||
-- requesting party, unless the Court orders otherwise. The party
|
||||
-- shall be notified at least two weeks before the oral hearing."
|
||||
-- timing='before' as in §9.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.oral'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.interpreter_cost',
|
||||
'Mitteilung Dolmetscherkosten',
|
||||
'Notification of Interpreter Costs',
|
||||
'court', 2, 'weeks', 'before', 'RoP.109.4', 'UPC.RoP.109.4',
|
||||
'mandatory', false, 'published', true, 46,
|
||||
'Frist 2 Wochen VOR der mündlichen Verhandlung (R.109.4). Mitteilung, dass die antragstellende Partei die Dolmetscherkosten zu tragen hat.',
|
||||
'2 weeks BEFORE the oral hearing (R.109.4). Notification to the requesting party that it shall bear the interpreter costs.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interpreter_cost'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §11 T1.11 upc.inf.cfi.translations_lodge — Lodging of translations on
|
||||
-- judge-rapporteur order. 2 weeks AFTER the JR's order. UPC RoP
|
||||
-- R.109.5: "If the judge-rapporteur orders, the parties shall lodge
|
||||
-- a translation of any pleading or other document into the language
|
||||
-- of the proceedings within two weeks." trigger_event_id=113 maps
|
||||
-- to the JR translation order. Anchor: Interim Conference row, where
|
||||
-- such JR orders are typically issued.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, trigger_event_id, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.translations_lodge',
|
||||
'Übersetzungen einreichen',
|
||||
'Lodging of Translations',
|
||||
'both', 2, 'weeks', 'after', 'RoP.109.5', 'UPC.RoP.109.5',
|
||||
'mandatory', false, 'published', true, 47,
|
||||
113,
|
||||
'Frist 2 Wochen ab Anordnung des Berichterstatters, Übersetzungen einzureichen (R.109.5).',
|
||||
'2-week period from the judge-rapporteur''s order to lodge translations (R.109.5).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translations_lodge'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §12 T1.12 upc.pi.cfi.response — RE-ANCHOR of the existing PI Response
|
||||
-- row. Currently broken: parent_id=NULL with is_court_set=false and
|
||||
-- duration=0 makes the calculator treat this as a root anchor. UPC
|
||||
-- RoP R.211.2 — judge sets the inter-partes hearing date and the
|
||||
-- deadline for the response. Fix: set is_court_set=true and chain
|
||||
-- parent_id on upc.pi.cfi.app (the proceeding root). Duration
|
||||
-- remains 0 (court-set placeholder); the lawyer fills in the actual
|
||||
-- date via 'Datum setzen'.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.app'
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
LIMIT 1),
|
||||
is_court_set = true,
|
||||
rule_code = 'RoP.211.2',
|
||||
legal_source = 'UPC.RoP.211.2',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.pi.cfi.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND parent_id IS NULL
|
||||
AND is_court_set = false;
|
||||
|
||||
-- =============================================================================
|
||||
-- §13a Pre-requisite for §13b — drop the deadline_rule_audit.rule_id FK.
|
||||
-- The audit trigger (mig 079) tries to INSERT an audit row on AFTER
|
||||
-- DELETE pointing at OLD.id, but the existing FK constraint
|
||||
-- `deadline_rule_audit_rule_id_fkey` (FOREIGN KEY rule_id REFERENCES
|
||||
-- paliad.deadline_rules(id) ON DELETE CASCADE) makes that INSERT fail
|
||||
-- because by the time the trigger fires the parent row is gone. As a
|
||||
-- result no DELETE on paliad.deadline_rules has ever succeeded in
|
||||
-- production (`SELECT count(*) FROM paliad.deadline_rule_audit
|
||||
-- WHERE action='delete'` returns 0). The trigger's DELETE branch was
|
||||
-- dead code.
|
||||
--
|
||||
-- Standard audit-table design: the audit log is append-only history
|
||||
-- and should NOT FK-constrain on the live entity table — before_json
|
||||
-- captures the full row state at the time of the change, which is
|
||||
-- all the audit trail needs. Dropping the FK fixes the latent bug
|
||||
-- and unblocks legitimate cleanup work (here: §13b, plus any future
|
||||
-- hard-delete migrations against deadline_rules).
|
||||
--
|
||||
-- Idempotent: DROP CONSTRAINT IF EXISTS no-ops on re-run.
|
||||
-- =============================================================================
|
||||
ALTER TABLE paliad.deadline_rule_audit
|
||||
DROP CONSTRAINT IF EXISTS deadline_rule_audit_rule_id_fkey;
|
||||
|
||||
-- =============================================================================
|
||||
-- §13b Q6 cleanup — drop the _archived_litigation.* deadline rules.
|
||||
-- 40 rows at audit §9.7 flagged as obsolete Pipeline-A residue
|
||||
-- (proceeding_type id=32 '_archived_litigation' — kept for FK
|
||||
-- parity but the rules are no longer surfaced anywhere in the
|
||||
-- product). m's Q6 design ack 2026-05-25 locked in their removal.
|
||||
-- Idempotent: prefix + lifecycle_state='archived' match zero rows
|
||||
-- after first run. The proceeding_type row itself is left in place
|
||||
-- (referenced by historical deadline_rule_audit before_json blobs).
|
||||
-- =============================================================================
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code LIKE '_archived_litigation.%' ESCAPE '\'
|
||||
AND lifecycle_state = 'archived';
|
||||
|
||||
-- =============================================================================
|
||||
-- Hard assertions. Each new/changed row must end up in its post-fix
|
||||
-- shape. Re-running the migration is a no-op for the data but the
|
||||
-- assertions still pass because they check the post-fix state.
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_count integer;
|
||||
BEGIN
|
||||
-- §0 R.105 interim conference backfilled
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.104'
|
||||
AND legal_source = 'UPC.RoP.104'
|
||||
AND 'RoP.105' = ANY(rule_codes);
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 §0: upc.inf.cfi.interim citation backfill not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §1 T1.1 cmo_review present
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cmo_review'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.333.2' AND duration_value = 15
|
||||
AND duration_unit = 'days' AND timing = 'after';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.1: upc.inf.cfi.cmo_review missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §2 T1.2 confidentiality_response
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.confidentiality_response'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.262.2' AND duration_value = 14
|
||||
AND duration_unit = 'days' AND trigger_event_id = 25;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.2: upc.inf.cfi.confidentiality_response missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §3 T1.3 grounds_orders
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.grounds_orders'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.224.2.b' AND duration_value = 15
|
||||
AND duration_unit = 'days';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.3: upc.apl.order.grounds_orders missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §4 T1.4 response_orders chained on §3
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.deadline_rules p ON p.id = dr.parent_id
|
||||
WHERE dr.submission_code = 'upc.apl.order.response_orders'
|
||||
AND dr.is_active = true AND dr.lifecycle_state = 'published'
|
||||
AND dr.rule_code = 'RoP.235.2' AND dr.duration_value = 15
|
||||
AND p.submission_code = 'upc.apl.order.grounds_orders';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.4: upc.apl.order.response_orders missing or wrong parent chain (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §5 T1.5 cons_orders
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cons_orders'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.118.4' AND duration_value = 2
|
||||
AND duration_unit = 'months';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.5: upc.inf.cfi.cons_orders missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §6 T1.6 rectification
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.rectification'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.353' AND duration_value = 1
|
||||
AND duration_unit = 'months';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.6: upc.inf.cfi.rectification missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §7 T1.7 pi.deficiency
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.deficiency'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.207.6.a' AND duration_value = 14
|
||||
AND duration_unit = 'days';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.7: upc.pi.cfi.deficiency missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §8 T1.8 pi.merits_start — combine-max wiring
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.merits_start'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.213' AND duration_value = 31
|
||||
AND duration_unit = 'days'
|
||||
AND alt_duration_value = 20 AND alt_duration_unit = 'working_days'
|
||||
AND alt_rule_code = 'RoP.198.1' AND combine_op = 'max';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.8: upc.pi.cfi.merits_start missing or wrong combine-max shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §9 T1.9 translation_request — timing='before'
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translation_request'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.109.1' AND duration_value = 1
|
||||
AND duration_unit = 'months' AND timing = 'before';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.9: upc.inf.cfi.translation_request missing or wrong timing (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §10 T1.10 interpreter_cost — timing='before'
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interpreter_cost'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.109.4' AND duration_value = 2
|
||||
AND duration_unit = 'weeks' AND timing = 'before';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.10: upc.inf.cfi.interpreter_cost missing or wrong timing (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §11 T1.11 translations_lodge
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translations_lodge'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.109.5' AND duration_value = 2
|
||||
AND duration_unit = 'weeks' AND trigger_event_id = 113;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.11: upc.inf.cfi.translations_lodge missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §12 T1.12 pi.response re-anchor
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.deadline_rules p ON p.id = dr.parent_id
|
||||
WHERE dr.submission_code = 'upc.pi.cfi.response'
|
||||
AND dr.is_active = true AND dr.lifecycle_state = 'published'
|
||||
AND dr.is_court_set = true
|
||||
AND p.submission_code = 'upc.pi.cfi.app';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.12: upc.pi.cfi.response not re-anchored on app (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §13 Q6 cleanup — no archived _archived_litigation rules left
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code LIKE '_archived_litigation.%' ESCAPE '\'
|
||||
AND lifecycle_state = 'archived';
|
||||
IF v_count <> 0 THEN
|
||||
RAISE EXCEPTION 'mig 132 §13: % archived _archived_litigation.* rules still present after cleanup', v_count;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Reverses mig 133. Removes the 5 new rules:
|
||||
-- * upc.dmgs.cfi.interim
|
||||
-- * upc.dmgs.cfi.oral
|
||||
-- * upc.dmgs.cfi.decision
|
||||
-- * upc.dmgs.cfi.appeal_spawn
|
||||
-- * upc.pi.cfi.appeal_spawn
|
||||
--
|
||||
-- The audit_reason is required by the mig 079 trigger for DELETE;
|
||||
-- set_config at top supplies it.
|
||||
--
|
||||
-- Idempotent — if a rule is already missing the DELETE matches zero
|
||||
-- rows and the audit log records nothing extra.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 133 (down): revert UPC Damages tree-end rows and UPC PI appeal-spawn (t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118)',
|
||||
true);
|
||||
|
||||
-- Delete the spawn rows first so the parent_id reference goes away
|
||||
-- before the parent decision row is removed.
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code IN (
|
||||
'upc.dmgs.cfi.appeal_spawn',
|
||||
'upc.pi.cfi.appeal_spawn')
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code IN (
|
||||
'upc.dmgs.cfi.interim',
|
||||
'upc.dmgs.cfi.oral',
|
||||
'upc.dmgs.cfi.decision')
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published';
|
||||
405
internal/db/migrations/133_upc_dmgs_pi_court_followup.up.sql
Normal file
405
internal/db/migrations/133_upc_dmgs_pi_court_followup.up.sql
Normal file
@@ -0,0 +1,405 @@
|
||||
-- t-paliad-285 (m/paliad#117) + t-paliad-286 (m/paliad#118) —
|
||||
-- post-submission court followup for UPC Damages and appeal route
|
||||
-- for UPC Provisional Measures.
|
||||
--
|
||||
-- m's 2026-05-25 report: the upc.dmgs.cfi proceeding stops at the
|
||||
-- last party submission (rejoin) — no interim conference, no oral
|
||||
-- hearing, no decision row, no appeal-spawn. The upc.pi.cfi
|
||||
-- proceeding has its decision row (`pi.order`) but no spawn into
|
||||
-- the appeal tree. Both gaps prevent the Verfahrensablauf timeline
|
||||
-- from rendering the court phase plus any downstream appeal sub-
|
||||
-- tree that atlas's #96 spawn-rendering mechanism is otherwise
|
||||
-- ready to surface.
|
||||
--
|
||||
-- Two sections in one migration (slot 133 — knuth on 132, paliadin
|
||||
-- coordinated):
|
||||
--
|
||||
-- A. UPC Damages tree-end rows (#117)
|
||||
-- A1 upc.dmgs.cfi.interim UPC RoP R.105 court-set hearing
|
||||
-- A2 upc.dmgs.cfi.oral UPC RoP R.118 / R.250 court-set hearing
|
||||
-- A3 upc.dmgs.cfi.decision UPC RoP R.118 / R.144 court-set decision
|
||||
-- A4 upc.dmgs.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
|
||||
--
|
||||
-- B. UPC Provisional Measures appeal route (#118)
|
||||
-- B1 upc.pi.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
|
||||
--
|
||||
-- Source citations:
|
||||
-- * docs/research-deadlines-completeness-2026-05-25.md
|
||||
-- — §2.1 (upc.dmgs.cfi has only 4 rules: R.131.2 / R.137.2 / R.139)
|
||||
-- — §D Damages table (R.144 tree-end row missing — listed
|
||||
-- in Tier 4 as "cosmetic", upgraded to Tier-0 by m's
|
||||
-- report once the wider follow-up gap was understood)
|
||||
-- * docs/audit-upc-rop-deadlines-2026-05-08.md §D row R.144,
|
||||
-- §F R.220.1(a) / R.224.1(a) (verified verbatim in youpc DB
|
||||
-- under law_type=UPCRoP).
|
||||
-- * UPC Rules of Procedure (consolidated):
|
||||
-- R.105 — Interim conference (court fixes after written
|
||||
-- procedure closes; same structural shape as the inf
|
||||
-- interim conference, already modelled as `upc.inf.cfi.interim`).
|
||||
-- R.118 — Decision after oral hearing; general rule for
|
||||
-- deciding panels.
|
||||
-- R.250 — Determination of damages decision; damages-
|
||||
-- specific decision rule (chains R.144 indication →
|
||||
-- damages award).
|
||||
-- R.144 — Final decision on damages quantum (tree-end
|
||||
-- anchor for §A3).
|
||||
-- R.220.1(a) — Appeal lies from any final decision /
|
||||
-- decision disposing of the case at first instance.
|
||||
-- A PI order under R.211 disposes of the urgent question
|
||||
-- and is therefore appealable on the main 2-month track
|
||||
-- (not the 15-day order track of R.220.1(c), which covers
|
||||
-- case-management and procedural orders requiring leave).
|
||||
-- Curie's §F table confirms the main-track wiring for
|
||||
-- decisions on merits / disposing orders.
|
||||
-- R.224.1(a) — Statement of Appeal within 2 months of
|
||||
-- service of the final decision; the deadline-notes text
|
||||
-- mirrors mig 095's inf.appeal_spawn / rev.appeal_spawn.
|
||||
-- R.224.2(a) — Statement of grounds within 4 months
|
||||
-- (separate deadline in the spawned upc.apl.merits
|
||||
-- proceeding; already present as upc.apl.merits.grounds).
|
||||
--
|
||||
-- Shape decisions (mirroring mig 012 / mig 095 conventions):
|
||||
-- * Court-set rows (interim / oral / decision) carry
|
||||
-- primary_party='court', event_type='hearing'|'decision',
|
||||
-- duration_value=0, is_court_set=true, parent_id=NULL,
|
||||
-- concept_id reuses the shared concepts already wired for
|
||||
-- upc.inf.cfi (interim-conference / oral-hearing / decision).
|
||||
-- * Spawn rows carry primary_party='both', is_spawn=true,
|
||||
-- spawn_proceeding_type_id=11 (upc.apl.merits), spawn_label
|
||||
-- identical to the merits spawn already in production. The
|
||||
-- spawn row's parent_id is the spawning decision/order row
|
||||
-- (so the audit log carries the trigger link).
|
||||
-- * No condition_expr — m's F2.3 decision recorded in mig 095
|
||||
-- §3: "the appeal deadline should always be triggered by a
|
||||
-- decision … appeal is always a possibility." Visibility
|
||||
-- filtering on the frontend hides appeals on projects where
|
||||
-- no appeal is contemplated.
|
||||
-- * sequence_order numbering follows the inf convention
|
||||
-- (40=interim, 50=oral, 60=decision, 80=appeal_spawn) so the
|
||||
-- Verfahrensablauf timeline orders consistently across
|
||||
-- proceedings. For PI the existing pi.order sits at
|
||||
-- sequence_order=3; the appeal_spawn lands at 10 (clear of
|
||||
-- the writ phase, room for future court-phase rows).
|
||||
--
|
||||
-- Idempotency: every INSERT is gated by `WHERE NOT EXISTS (… same
|
||||
-- submission_code, proceeding_type_id, lifecycle_state)`. Re-apply
|
||||
-- against an already-migrated DB inserts zero rows and the audit
|
||||
-- log carries no duplicate entries.
|
||||
--
|
||||
-- audit_reason set_config required at the top — the mig 079 trigger
|
||||
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
|
||||
-- on INSERT/UPDATE/DELETE without it.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 133: t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118 — UPC Damages tree-end rows (interim conference R.105, oral hearing R.118/R.250, decision R.118/R.144, appeal-spawn R.220.1(a)) and UPC Provisional Measures appeal-spawn R.220.1(a); see docs/research-deadlines-completeness-2026-05-25.md §D and docs/audit-upc-rop-deadlines-2026-05-08.md §D/§F',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- A. UPC Damages — court-phase tree end (m/paliad#117)
|
||||
-- =============================================================================
|
||||
|
||||
-- A1. upc.dmgs.cfi.interim — Interim conference (UPC RoP R.105).
|
||||
-- Court-set hearing fixed by the judge-rapporteur once the
|
||||
-- written procedure closes. Identical shape to
|
||||
-- upc.inf.cfi.interim; reuses the shared interim-conference
|
||||
-- concept node.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(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,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.interim',
|
||||
'Zwischenverfahren',
|
||||
'Interim Conference',
|
||||
NULL,
|
||||
'court',
|
||||
'hearing',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
'Termin vom Gericht bestimmt',
|
||||
'Date set by the court',
|
||||
40,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'e5071152-d408-4455-b644-9e79d86fd538'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.interim'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A2. upc.dmgs.cfi.oral — Oral hearing (UPC RoP R.118 / R.250).
|
||||
-- Court-set hearing after the interim conference / close of
|
||||
-- written procedure. Same shape as upc.inf.cfi.oral; reuses
|
||||
-- the shared oral-hearing concept node.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(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,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.oral',
|
||||
'Mündliche Verhandlung',
|
||||
'Oral Hearing',
|
||||
NULL,
|
||||
'court',
|
||||
'hearing',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
50,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'd6e5b793-dcf1-4d83-81ff-34f42dbb3693'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.oral'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A3. upc.dmgs.cfi.decision — Damages decision (UPC RoP R.118 /
|
||||
-- R.144 / R.250). Court-set decision delivered after oral
|
||||
-- hearing; closes the §3.1 audit gap (R.144 tree-end). Same
|
||||
-- shape as upc.inf.cfi.decision; reuses the shared decision
|
||||
-- concept node.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(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,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.decision',
|
||||
'Entscheidung',
|
||||
'Decision',
|
||||
NULL,
|
||||
'court',
|
||||
'decision',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
60,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'472fc32d-cc4f-4aa4-8ace-e422031812de'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.decision'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A4. upc.dmgs.cfi.appeal_spawn — Appeal against damages decision
|
||||
-- (UPC RoP R.220.1(a), 2-month main track; grounds R.224.2(a)
|
||||
-- run as a separate deadline in the spawned upc.apl.merits
|
||||
-- proceeding). Parent points at the freshly-inserted
|
||||
-- upc.dmgs.cfi.decision; the SELECT subquery resolves it
|
||||
-- after A3 lands. Same shape as the mig 095 inf.appeal_spawn.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(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,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state)
|
||||
SELECT
|
||||
17,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.decision'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'upc.dmgs.cfi.appeal_spawn',
|
||||
'Berufung gegen Schadensentscheidung',
|
||||
'Appeal against damages decision',
|
||||
'Berufung gegen die Entscheidung über die Schadensbemessung (R.118 / R.144). Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren).',
|
||||
'both',
|
||||
'filing',
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.220.1.a',
|
||||
'Innerhalb von 2 Monaten ab Zustellung der Schadensentscheidung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
|
||||
'Within 2 months of service of the damages decision lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
|
||||
80,
|
||||
true,
|
||||
11,
|
||||
'Berufungsverfahren öffnen',
|
||||
true,
|
||||
'UPC.RoP.220.1',
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.appeal_spawn'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- =============================================================================
|
||||
-- B. UPC Provisional Measures — appeal route (m/paliad#118)
|
||||
-- =============================================================================
|
||||
|
||||
-- B1. upc.pi.cfi.appeal_spawn — Appeal against PI order (UPC RoP
|
||||
-- R.220.1(a), 2-month main track). PI orders under R.211
|
||||
-- dispose of the urgent question and are appealable on the
|
||||
-- main 2-month track (R.220.1(a)/R.224.1(a)); the 15-day
|
||||
-- order track of R.220.1(c) is for case-management /
|
||||
-- procedural orders requiring leave and does not apply to
|
||||
-- PI dispositions. Parent points at the existing
|
||||
-- upc.pi.cfi.order (sequence_order=3) so the spawn fires
|
||||
-- once the order is anchored on a project's timeline.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(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,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state)
|
||||
SELECT
|
||||
10,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.order'
|
||||
AND proceeding_type_id = 10
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'upc.pi.cfi.appeal_spawn',
|
||||
'Berufung gegen Anordnung',
|
||||
'Appeal against PI order',
|
||||
'Berufung gegen die einstweilige Anordnung nach R.211. Eine PI-Anordnung erledigt die einstweilige Streitfrage und wird wie eine Endentscheidung im Hauptverfahren behandelt: statutarische Frist von 2 Monaten ab Zustellung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren). Die 15-Tage-Spur nach R.220.1(c) / R.220.2 gilt für Verfahrensanordnungen mit Zulassung und ist hier nicht einschlägig.',
|
||||
'both',
|
||||
'filing',
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.220.1.a',
|
||||
'Innerhalb von 2 Monaten ab Zustellung der PI-Anordnung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
|
||||
'Within 2 months of service of the PI order lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
|
||||
10,
|
||||
true,
|
||||
11,
|
||||
'Berufungsverfahren öffnen',
|
||||
true,
|
||||
'UPC.RoP.220.1',
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.appeal_spawn'
|
||||
AND proceeding_type_id = 10
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- =============================================================================
|
||||
-- C. Post-insert verification — raise if any expected row is missing
|
||||
-- (matches the mig 095 / 127 convention; protects against a future
|
||||
-- re-shape of the table that silently drops one of the WHERE NOT
|
||||
-- EXISTS predicates).
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_missing text;
|
||||
BEGIN
|
||||
SELECT string_agg(expected, ', ' ORDER BY expected)
|
||||
INTO v_missing
|
||||
FROM (VALUES
|
||||
('upc.dmgs.cfi.interim'),
|
||||
('upc.dmgs.cfi.oral'),
|
||||
('upc.dmgs.cfi.decision'),
|
||||
('upc.dmgs.cfi.appeal_spawn'),
|
||||
('upc.pi.cfi.appeal_spawn')
|
||||
) AS t(expected)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = t.expected
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.is_active = true);
|
||||
|
||||
IF v_missing IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: expected published rules missing after insert: %', v_missing;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = 'upc.dmgs.cfi.appeal_spawn'
|
||||
AND dr.proceeding_type_id = 17
|
||||
AND dr.spawn_proceeding_type_id = 11
|
||||
AND dr.is_spawn = true
|
||||
AND dr.parent_id IS NOT NULL
|
||||
AND dr.lifecycle_state = 'published'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: upc.dmgs.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = 'upc.pi.cfi.appeal_spawn'
|
||||
AND dr.proceeding_type_id = 10
|
||||
AND dr.spawn_proceeding_type_id = 11
|
||||
AND dr.is_spawn = true
|
||||
AND dr.parent_id IS NOT NULL
|
||||
AND dr.lifecycle_state = 'published'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: upc.pi.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
|
||||
END IF;
|
||||
END $$;
|
||||
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.';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user