Compare commits
65 Commits
mai/hermes
...
mai/artemi
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 3d985ef0c2 | |||
| f72e8a7b85 | |||
| 013facb9db | |||
| ff503ffc43 | |||
| 05f7ea2af5 | |||
| df2a1275cb | |||
| 3700d68c68 |
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
|
||||
72
Makefile
72
Makefile
@@ -21,18 +21,24 @@
|
||||
# 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
|
||||
|
||||
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 ""
|
||||
@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 +77,67 @@ 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
|
||||
|
||||
@@ -165,6 +165,7 @@ func main() {
|
||||
sysAuditSvc := services.NewSystemAuditLogService(pool)
|
||||
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
|
||||
svcBundle = &handlers.Services{
|
||||
Pool: pool,
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
@@ -218,6 +219,8 @@ 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),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -46,7 +46,6 @@ import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
|
||||
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
|
||||
import { renderAdminRulesList } from "./src/admin-rules-list";
|
||||
import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderAdminBackups } from "./src/admin-backups";
|
||||
@@ -284,7 +283,6 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-list.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-export.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
// t-paliad-161 — inline Paliadin widget. Loaded via the
|
||||
// PaliadinWidget component on every authenticated page, so the
|
||||
@@ -416,7 +414,6 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList());
|
||||
await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit());
|
||||
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules/export — Slice 11b (t-paliad-192). Surfaces the
|
||||
// GET /admin/api/rules/export-migrations endpoint as a SQL preview the
|
||||
// editor can copy or download. Optional ?since=<audit-id> query lets
|
||||
// the editor scope the export to a particular audit window — empty =
|
||||
// every un-exported audit row.
|
||||
export function renderAdminRulesExport(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.export.title">Regel-Migrations exportieren — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/rules" data-i18n="admin.rules.export.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 data-i18n="admin.rules.export.heading">Regel-Migrations exportieren</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.export.subtitle">
|
||||
Generiert ein <code>*.up.sql</code>-Blob mit allen unsynchronisierten Audit-Veränderungen.
|
||||
Manuell in <code>internal/db/migrations/</code> einchecken.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-export-controls">
|
||||
<div className="form-field">
|
||||
<label htmlFor="export-since" data-i18n="admin.rules.export.field.since">Startend ab Audit-ID (optional)</label>
|
||||
<input type="text" id="export-since" className="admin-rules-input" placeholder="UUID, leer = alle un-exportierten" />
|
||||
</div>
|
||||
<button type="button" id="export-run" className="btn-primary" data-i18n="admin.rules.export.run">
|
||||
Export generieren
|
||||
</button>
|
||||
<button type="button" id="export-download" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.download">
|
||||
Als Datei herunterladen
|
||||
</button>
|
||||
<button type="button" id="export-copy" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.copy">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="export-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-rules-export-summary" id="export-summary" style="display:none">
|
||||
<span id="export-summary-count" />
|
||||
<span id="export-summary-latest" />
|
||||
</div>
|
||||
|
||||
<pre id="export-output" className="admin-rules-export-pre" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-rules-export.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -39,9 +39,6 @@ export function renderAdminRulesList(): string {
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-rules-header-actions">
|
||||
<a href="/admin/rules/export" className="btn-secondary" data-i18n="admin.rules.list.export">
|
||||
Migrations exportieren
|
||||
</a>
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
|
||||
+ Neue Regel
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -169,7 +172,8 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,25 +191,37 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle {
|
||||
function renderPanel(): void {
|
||||
panel.replaceChildren();
|
||||
|
||||
// Three groups in a single row: past fan / ALLES centre / next fan.
|
||||
const row = document.createElement("div");
|
||||
row.className = "date-range-row";
|
||||
// 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";
|
||||
|
||||
const pastGroup = renderFan(
|
||||
PAST_HORIZONS.filter((h) => presets.includes(h)),
|
||||
// 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 centerGroup = renderCenter();
|
||||
const nextGroup = renderFan(
|
||||
NEXT_HORIZONS.filter((h) => presets.includes(h)),
|
||||
"next",
|
||||
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 (pastGroup) row.appendChild(pastGroup);
|
||||
if (centerGroup) row.appendChild(centerGroup);
|
||||
if (nextGroup) row.appendChild(nextGroup);
|
||||
if (pastCol) grid.appendChild(pastCol);
|
||||
if (nowCol) grid.appendChild(nowCol);
|
||||
if (futureCol) grid.appendChild(futureCol);
|
||||
|
||||
panel.appendChild(row);
|
||||
panel.appendChild(grid);
|
||||
|
||||
// Custom-range section ("Anpassen"). Toggle button + collapsible
|
||||
// date-pair editor below.
|
||||
@@ -218,49 +230,57 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle {
|
||||
}
|
||||
}
|
||||
|
||||
function renderFan(horizons: readonly TimeHorizon[], side: "past" | "next"): HTMLElement | null {
|
||||
function renderColumn(
|
||||
side: "past" | "future",
|
||||
heading: string,
|
||||
horizons: readonly TimeHorizon[],
|
||||
): HTMLElement | null {
|
||||
if (horizons.length === 0) return null;
|
||||
const group = document.createElement("div");
|
||||
group.className = `date-range-fan date-range-fan--${side}`;
|
||||
group.setAttribute("role", "group");
|
||||
group.setAttribute("aria-label", side === "past"
|
||||
? t("date_range.fan.past.label")
|
||||
: t("date_range.fan.future.label"));
|
||||
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) {
|
||||
group.appendChild(makeChip(h));
|
||||
col.appendChild(makeChip(h));
|
||||
}
|
||||
return group;
|
||||
return col;
|
||||
}
|
||||
|
||||
function renderCenter(): HTMLElement | null {
|
||||
if (!presets.includes("any")) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "date-range-center";
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "date-range-center-btn";
|
||||
if (value.horizon === "any" || value.horizon === "all") {
|
||||
btn.classList.add("date-range-center-btn--active");
|
||||
}
|
||||
btn.setAttribute("aria-pressed", String(value.horizon === "any" || value.horizon === "all"));
|
||||
btn.dataset.testid = `${opts.surface}.date-range-chip.any`;
|
||||
function renderNowColumn(): HTMLElement | null {
|
||||
const showHeute = presets.includes("next_1d");
|
||||
const showAlles = presets.includes("any");
|
||||
if (!showHeute && !showAlles) return null;
|
||||
|
||||
const glyph = document.createElement("span");
|
||||
glyph.className = "date-range-center-glyph";
|
||||
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
|
||||
const label = document.createElement("span");
|
||||
label.className = "date-range-center-label";
|
||||
label.textContent = t("date_range.center.label");
|
||||
btn.appendChild(glyph);
|
||||
btn.appendChild(label);
|
||||
col.appendChild(glyph);
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
commit({ horizon: "any" }, /*closeAfter*/ true);
|
||||
});
|
||||
|
||||
wrap.appendChild(btn);
|
||||
return wrap;
|
||||
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 {
|
||||
|
||||
@@ -73,13 +73,16 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
|
||||
|
||||
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
|
||||
|
||||
// Default chip set when the surface doesn't override. Matches the
|
||||
// forward-leaning bias of the legacy filter-bar default (the universal
|
||||
// substrate is more often used for "what's coming up" than "what just
|
||||
// happened") but now covers the full symmetric fan plus past_30d for
|
||||
// quick recent-history lookups.
|
||||
// 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[] = [
|
||||
"past_30d", "past_7d", "any", "next_7d", "next_30d", "next_90d", "custom",
|
||||
"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 {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -253,6 +254,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",
|
||||
@@ -306,6 +309,32 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
"deadlines.col.both": "Beide Parteien",
|
||||
// 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,7 +449,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Seite:",
|
||||
"deadlines.side.claimant": "Klägerseite",
|
||||
"deadlines.side.defendant": "Beklagtenseite",
|
||||
"deadlines.side.both": "Beide",
|
||||
"deadlines.side.undefined": "Nicht festgelegt",
|
||||
"deadlines.side.from_project": "Aus Akte:",
|
||||
"deadlines.side.override": "Andere Seite wählen",
|
||||
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
@@ -1117,6 +1149,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 +1505,15 @@ 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-240 — global Schriftsätze drafts index page.
|
||||
"submissions.index.title": "Schriftsätze — Paliad",
|
||||
"submissions.index.heading": "Schriftsätze",
|
||||
@@ -2841,21 +2886,24 @@ 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".
|
||||
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
|
||||
// (URL change is Slice B.6); the visible labels rename. Canonical
|
||||
// `admin.procedural_events.*` aliases live after the EN block — they
|
||||
// pin the contract for when .tsx files rebind in Slice B (B.5).
|
||||
"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",
|
||||
@@ -2866,7 +2914,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",
|
||||
@@ -2896,8 +2944,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",
|
||||
@@ -2912,12 +2960,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",
|
||||
@@ -2930,14 +2978,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",
|
||||
@@ -2949,7 +2997,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",
|
||||
@@ -3012,30 +3060,13 @@ 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": "Morgen",
|
||||
"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",
|
||||
@@ -3060,6 +3091,21 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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: {
|
||||
@@ -3250,6 +3296,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",
|
||||
@@ -3296,6 +3343,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",
|
||||
@@ -3349,6 +3398,32 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
"deadlines.col.both": "Both parties",
|
||||
// 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",
|
||||
@@ -3470,7 +3545,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Side:",
|
||||
"deadlines.side.claimant": "Claimant",
|
||||
"deadlines.side.defendant": "Defendant",
|
||||
"deadlines.side.both": "Both",
|
||||
"deadlines.side.undefined": "Undefined",
|
||||
"deadlines.side.from_project": "From case:",
|
||||
"deadlines.side.override": "Choose other side",
|
||||
"deadlines.side.hint": "Pick a side to focus the columns.",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
@@ -4143,6 +4221,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",
|
||||
@@ -4492,7 +4574,16 @@ 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-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
@@ -5854,21 +5945,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",
|
||||
@@ -5879,7 +5969,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",
|
||||
@@ -5909,8 +5999,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",
|
||||
@@ -5925,12 +6015,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",
|
||||
@@ -5943,14 +6033,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",
|
||||
@@ -5962,7 +6052,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",
|
||||
@@ -6025,27 +6115,10 @@ 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": "Tomorrow",
|
||||
"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",
|
||||
@@ -6070,6 +6143,19 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,13 @@ import {
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
import {
|
||||
attachEventCardChoices,
|
||||
reseedChips,
|
||||
currentChoices,
|
||||
type EventChoice,
|
||||
type ChoiceKind,
|
||||
} from "./views/event-card-choices";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
@@ -38,6 +45,13 @@ let lastResponse: DeadlineResponse | null = null;
|
||||
let currentSide: Side = null;
|
||||
let currentAppellant: Side = null;
|
||||
|
||||
// Project-driven auto-fill state (t-paliad-279 / m/paliad#111). When the
|
||||
// page is opened with ?project=<id> and that project has our_side set,
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
@@ -98,6 +112,56 @@ 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), so persistence is URL-only via `?event_choices=`.
|
||||
// Format: comma-separated `submission_code:kind=value` tuples. Same
|
||||
// idiom as `?side=` + `?appellant=`.
|
||||
let perCardChoices: EventChoice[] = [];
|
||||
|
||||
function readChoicesFromURL(): EventChoice[] {
|
||||
const raw = new URLSearchParams(window.location.search).get("event_choices");
|
||||
if (!raw) return [];
|
||||
const out: EventChoice[] = [];
|
||||
for (const tuple of raw.split(",")) {
|
||||
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const kind = m[2] as ChoiceKind;
|
||||
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
||||
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeChoicesToURL(choices: EventChoice[]) {
|
||||
const url = new URL(window.location.href);
|
||||
if (choices.length === 0) {
|
||||
url.searchParams.delete("event_choices");
|
||||
} else {
|
||||
const enc = choices.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`).join(",");
|
||||
url.searchParams.set("event_choices", enc);
|
||||
}
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
|
||||
// calculator re-surfaces cards whose submission_code is in the active
|
||||
// skipRules set; they render faded with a "Wieder einblenden" chip.
|
||||
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
|
||||
// the visibility. Default OFF — m's not asking to see hidden by
|
||||
// default, just to be able to.
|
||||
function readShowHiddenFromURL(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
|
||||
}
|
||||
|
||||
function writeShowHiddenToURL(on: boolean) {
|
||||
const url = new URL(window.location.href);
|
||||
if (on) url.searchParams.set("show_hidden", "1");
|
||||
else url.searchParams.delete("show_hidden");
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
let showHidden = readShowHiddenFromURL();
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
@@ -210,14 +274,34 @@ async function doCalc() {
|
||||
flags: readFlags(),
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
perCardChoices,
|
||||
includeHidden: showHidden,
|
||||
});
|
||||
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:
|
||||
//
|
||||
@@ -302,6 +386,11 @@ function renderResults(data: DeadlineResponse) {
|
||||
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) {
|
||||
@@ -388,6 +477,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 (readSideFromURL() !== null) return;
|
||||
const next = ourSideToSide(os);
|
||||
if (next === null) return;
|
||||
currentSide = next;
|
||||
writeSideToURL(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
|
||||
@@ -436,6 +657,7 @@ function initPerspectiveControls() {
|
||||
currentAppellant = readAppellantFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
syncSideHintVisibility();
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
@@ -443,6 +665,7 @@ function initPerspectiveControls() {
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
syncSideHintVisibility();
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
@@ -526,9 +749,61 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
|
||||
// to URL + recalc (the backend reshapes the response — we can't just
|
||||
// re-render lastResponse since the hidden rows aren't in it when the
|
||||
// toggle was OFF).
|
||||
const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null;
|
||||
if (showHiddenCb) {
|
||||
showHiddenCb.checked = showHidden;
|
||||
showHiddenCb.addEventListener("change", () => {
|
||||
showHidden = showHiddenCb.checked;
|
||||
writeShowHiddenToURL(showHidden);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
// t-paliad-265 — per-event-card choices. Unbound surface, so commits
|
||||
// mutate the in-memory list + URL, then trigger a recalc. The
|
||||
// popover module owns the popover lifecycle; this page owns the
|
||||
// recalc + URL plumbing.
|
||||
perCardChoices = readChoicesFromURL();
|
||||
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);
|
||||
writeChoicesToURL(perCardChoices);
|
||||
scheduleCalc(0);
|
||||
},
|
||||
remove: (submissionCode, kind) => {
|
||||
perCardChoices = perCardChoices.filter(
|
||||
(c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
|
||||
);
|
||||
writeChoicesToURL(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,6 +814,12 @@ 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();
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -67,6 +67,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
|
||||
@@ -191,6 +338,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", ""),
|
||||
|
||||
@@ -61,6 +61,40 @@ 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;
|
||||
}
|
||||
|
||||
// priorityRendering returns the per-priority UX hints the save-modal
|
||||
@@ -120,6 +154,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 +180,21 @@ 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;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -154,10 +210,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 {
|
||||
@@ -258,18 +324,78 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
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>`;
|
||||
// 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;
|
||||
@@ -310,12 +436,22 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
</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}
|
||||
@@ -406,8 +542,20 @@ export function wireDateEditClicks(
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
const itemClasses = [
|
||||
"timeline-item",
|
||||
dl.isRootEvent ? "timeline-root" : "",
|
||||
// t-paliad-290: re-surfaced hidden cards render faded via the
|
||||
// shared timeline-item--hidden modifier (same modifier the columns
|
||||
// view uses; see fr-col-item--hidden below).
|
||||
dl.isHidden ? "timeline-item--hidden" : "",
|
||||
// t-paliad-289: dotted-border + faded styling for conditional rows
|
||||
// so the "abhängig von <parent>" state is visually distinct from
|
||||
// both anchored deadlines and direct court-set rows.
|
||||
dl.isConditional ? "timeline-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
html += `
|
||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||||
<div class="${itemClasses}">
|
||||
<div class="timeline-dot-col">
|
||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||
<div class="timeline-line"></div>
|
||||
@@ -532,7 +680,15 @@ 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 (appellantColumn !== null) {
|
||||
// Role-swap collapse: appellant initiated → both → one row
|
||||
// in appellant's column. Mirror suppressed.
|
||||
row[appellantColumn].push(dl);
|
||||
@@ -578,7 +734,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>`;
|
||||
@@ -625,6 +791,11 @@ 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,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -205,7 +205,6 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
|
||||
@@ -290,6 +290,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 +401,6 @@ export type I18nKey =
|
||||
| "admin.rules.edit.title"
|
||||
| "admin.rules.empty"
|
||||
| "admin.rules.error.load"
|
||||
| "admin.rules.export.breadcrumb"
|
||||
| "admin.rules.export.copied"
|
||||
| "admin.rules.export.copy"
|
||||
| "admin.rules.export.copy_failed"
|
||||
| "admin.rules.export.count"
|
||||
| "admin.rules.export.download"
|
||||
| "admin.rules.export.error"
|
||||
| "admin.rules.export.field.since"
|
||||
| "admin.rules.export.heading"
|
||||
| "admin.rules.export.latest"
|
||||
| "admin.rules.export.no_pending"
|
||||
| "admin.rules.export.ok"
|
||||
| "admin.rules.export.run"
|
||||
| "admin.rules.export.running"
|
||||
| "admin.rules.export.subtitle"
|
||||
| "admin.rules.export.title"
|
||||
| "admin.rules.filter.lifecycle"
|
||||
| "admin.rules.filter.lifecycle.any"
|
||||
| "admin.rules.filter.proceeding"
|
||||
@@ -419,7 +412,6 @@ export type I18nKey =
|
||||
| "admin.rules.lifecycle.archived"
|
||||
| "admin.rules.lifecycle.draft"
|
||||
| "admin.rules.lifecycle.published"
|
||||
| "admin.rules.list.export"
|
||||
| "admin.rules.list.heading"
|
||||
| "admin.rules.list.new"
|
||||
| "admin.rules.list.subtitle"
|
||||
@@ -999,6 +991,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"
|
||||
@@ -1209,6 +1221,8 @@ export type I18nKey =
|
||||
| "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"
|
||||
@@ -1434,10 +1448,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"
|
||||
@@ -1468,6 +1485,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"
|
||||
@@ -1604,6 +1622,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"
|
||||
@@ -1622,6 +1641,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"
|
||||
@@ -1955,7 +1975,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"
|
||||
@@ -2584,13 +2603,22 @@ 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.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.switcher.label"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,8 +109,96 @@ export function renderSubmissionDraft(): string {
|
||||
</button>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -158,9 +158,101 @@ 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>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
|
||||
Re-surfaces optional cards the user has previously
|
||||
marked "Überspringen" via the per-card popover.
|
||||
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 +302,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">
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -55,8 +55,6 @@ INSERT INTO paliad.deadline_rules (
|
||||
name_en,
|
||||
primary_party,
|
||||
event_type,
|
||||
is_mandatory,
|
||||
is_optional,
|
||||
is_court_set,
|
||||
is_spawn,
|
||||
duration_value,
|
||||
@@ -70,7 +68,6 @@ INSERT INTO paliad.deadline_rules (
|
||||
deadline_notes_en,
|
||||
legal_source,
|
||||
condition_expr,
|
||||
condition_flag,
|
||||
sequence_order,
|
||||
is_active,
|
||||
priority,
|
||||
@@ -90,8 +87,6 @@ SELECT
|
||||
'Petition for re-establishment of rights (UPC R.320)',
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
2,
|
||||
@@ -105,7 +100,6 @@ SELECT
|
||||
'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,
|
||||
NULL::text[],
|
||||
1207,
|
||||
true,
|
||||
'mandatory',
|
||||
|
||||
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 $$;
|
||||
69
internal/db/testdata/README.md
vendored
Normal file
69
internal/db/testdata/README.md
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
# `internal/db/testdata/` — CI snapshot
|
||||
|
||||
## `prod-snapshot.sql`
|
||||
|
||||
Schema-only `pg_dump` of paliad's prod DB (youpc-supabase paliad schema)
|
||||
plus the rows of `paliad.applied_migrations` that match this branch's
|
||||
on-disk migration set.
|
||||
|
||||
**Purpose.** Lets CI's migration smoke (`.gitea/workflows/test.yaml`)
|
||||
restore a Postgres scratch DB to "paliad at HEAD-of-snapshot" without
|
||||
having to replay 131 migrations from scratch. ApplyMigrations on the
|
||||
restored DB sees the applied set and only runs whatever NEW migrations
|
||||
this PR adds — exactly the integration shape we want to test, and the
|
||||
same shape prod sees on every deploy.
|
||||
|
||||
**Why a snapshot at all.** Running ApplyMigrations from scratch against a
|
||||
fresh `supabase/postgres:15.8.1.060` surfaces multiple fresh-DB
|
||||
idempotence bugs in historical migrations (raw `COMMIT;` in mig 051,
|
||||
missing `CREATE EXTENSION pg_trgm` for mig 037, ALTER POLICY
|
||||
exception-handler gaps in mig 024/027 — the last is fixed in this PR).
|
||||
Fixing them all is a separate cleanup. The snapshot sidesteps them by
|
||||
starting CI from a state where every historical migration is already
|
||||
applied as it was in prod.
|
||||
|
||||
**Schema scope.** `--schema=paliad` only. Auth schema comes baked into
|
||||
`supabase/postgres`; CI's setup step installs `pg_trgm` before restoring.
|
||||
|
||||
**Ownership.** `--no-owner --no-privileges` keeps the dump portable
|
||||
across role topologies (CI's supabase_admin / postgres / authenticated /
|
||||
anon don't have to match prod's exact role layout). The role-split smoke
|
||||
relies on `postgres` being a non-superuser, which is true on
|
||||
supabase/postgres by default.
|
||||
|
||||
**Refresh.** Run `make refresh-snapshot` with `PALIAD_PROD_DATABASE_URL`
|
||||
set to a Postgres URL with `pg_dump` rights on youpc-supabase. The
|
||||
target appends data rows for `paliad.applied_migrations`, strips
|
||||
`\restrict` / `\unrestrict` commands (pg 16 dump → pg 15 restore), and
|
||||
filters out applied-migrations rows for versions beyond the branch's
|
||||
local max. The CI workflow consumes the resulting file verbatim.
|
||||
|
||||
**Verify a refresh.** Boot a local scratch:
|
||||
|
||||
```bash
|
||||
docker run -d --rm --name paliad-snap \
|
||||
-e POSTGRES_PASSWORD=ci -e POSTGRES_DB=paliad_scratch \
|
||||
-p 15433:5432 supabase/postgres:15.8.1.060
|
||||
sleep 5
|
||||
docker exec -e PGPASSWORD=ci paliad-snap psql -h localhost -U supabase_admin -d paliad_scratch \
|
||||
-c "GRANT CREATE ON DATABASE paliad_scratch TO postgres;" \
|
||||
-c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
|
||||
cat internal/db/testdata/prod-snapshot.sql | docker exec -i -e PGPASSWORD=ci paliad-snap \
|
||||
psql -h localhost -U postgres -d paliad_scratch -v ON_ERROR_STOP=1
|
||||
TEST_DATABASE_URL="postgres://postgres:ci@localhost:15433/paliad_scratch?sslmode=disable" \
|
||||
TEST_APP_DATABASE_URL="postgres://postgres:ci@localhost:15433/paliad_scratch?sslmode=disable" \
|
||||
go test -count=1 -run 'TestMigrations|TestBootSmoke|TestHealthReady_Live' ./internal/db/ ./cmd/server/
|
||||
docker stop paliad-snap
|
||||
```
|
||||
|
||||
All four named tests must pass. If any fails after a refresh,
|
||||
investigate before merging — usually because a new migration was added
|
||||
to prod that this branch doesn't have on disk yet.
|
||||
|
||||
**Why is the snapshot not gzipped?** Small enough (~200 KB) that the
|
||||
diff stays human-readable in `git diff` reviews. If it crosses ~1 MB,
|
||||
gzip + decompress-on-restore in CI.
|
||||
|
||||
**Privacy.** Schema-only dump, no row data from any paliad table (except
|
||||
`paliad.applied_migrations`, which contains migration filenames +
|
||||
checksums — public info already in the repo).
|
||||
6278
internal/db/testdata/prod-snapshot.sql
vendored
Normal file
6278
internal/db/testdata/prod-snapshot.sql
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -299,21 +299,6 @@ func handleAdminPreviewRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/export-migrations?since=<audit_id>
|
||||
func handleAdminExportRuleMigrations(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
since := r.URL.Query().Get("since")
|
||||
out, err := dbSvc.ruleEditor.ExportMigrationsSince(r.Context(), since)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page handlers — serve the static SPA shells. Auth + admin gate live
|
||||
// at the route registration in handlers.go.
|
||||
@@ -327,10 +312,6 @@ func handleAdminRulesEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-edit.html")
|
||||
}
|
||||
|
||||
func handleAdminRulesExportPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-export.html")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// helpers
|
||||
// =============================================================================
|
||||
|
||||
113
internal/handlers/event_choices.go
Normal file
113
internal/handlers/event_choices.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
// HTTP handlers for paliad.project_event_choices (t-paliad-265 / m/paliad#96).
|
||||
//
|
||||
// Three endpoints:
|
||||
// GET /api/projects/{id}/event-choices → list
|
||||
// PUT /api/projects/{id}/event-choices → upsert one
|
||||
// DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}
|
||||
//
|
||||
// All three gated by visibility on the project (paliad.can_see_project)
|
||||
// via EventChoiceService.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/projects/{id}/event-choices
|
||||
func handleListProjectEventChoices(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.eventChoice == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.eventChoice.ListForProject(r.Context(), uid, projectID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// PUT /api/projects/{id}/event-choices — upsert one row.
|
||||
func handlePutProjectEventChoice(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.eventChoice == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
var input services.UpsertEventChoiceInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.eventChoice.Upsert(r.Context(), uid, projectID, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}
|
||||
func handleDeleteProjectEventChoice(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.eventChoice == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
submissionCode := r.PathValue("submission_code")
|
||||
choiceKind := r.PathValue("choice_kind")
|
||||
if err := dbSvc.eventChoice.Delete(r.Context(), uid, projectID, submissionCode, choiceKind); err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -79,6 +79,38 @@ var fileRegistry = map[string]fileEntry{
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
},
|
||||
// Firm-formatted skeleton (t-paliad-275). Carries the same 48-key
|
||||
// placeholder bag as the universal _skeleton.docx, but additionally
|
||||
// preserves every HL paragraph + character style from the HL Patents
|
||||
// Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
|
||||
// HLpat-Table-Recitals-*, HLpat-Signature, …) and the firm letterhead
|
||||
// (header logo + firm-address footer). Slotted ahead of the universal
|
||||
// skeleton in the fallback chain so any submission_code without a
|
||||
// dedicated per-code template still renders as a real firm-branded
|
||||
// Schriftsatz with variables substituted, rather than a plain skeleton.
|
||||
// Generated via scripts/gen-hl-skeleton-template against the .dotm.
|
||||
firmSkeletonSubmissionSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
|
||||
DownloadName: branding.Name + " — Firm Schriftsatz-Skelett.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
|
||||
},
|
||||
// English skeleton variant (t-paliad-276). Sibling of
|
||||
// `_skeleton.docx`; used when a draft's language='en' and no
|
||||
// per-code EN template exists. If the file isn't authored yet in
|
||||
// mWorkRepo, the Gitea fetch fails and resolveSubmissionTemplate
|
||||
// falls through to the DE skeleton — visible to the user as the
|
||||
// "Fallback: universelles Skelett" notice on the draft editor.
|
||||
skeletonSubmissionENSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
|
||||
DownloadName: branding.Name + " — Submission skeleton.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
|
||||
},
|
||||
}
|
||||
|
||||
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||
@@ -87,6 +119,19 @@ var fileRegistry = map[string]fileEntry{
|
||||
// the same string the registry uses.
|
||||
const skeletonSubmissionSlug = "submission/_skeleton.docx"
|
||||
|
||||
// firmSkeletonSubmissionSlug names the firm-formatted skeleton template
|
||||
// inside the shared fileRegistry cache (t-paliad-275). Same placeholder
|
||||
// surface as skeletonSubmissionSlug; carries HL paragraph + character
|
||||
// styles from the source .dotm on top. Sits between the per-code
|
||||
// template and the generic universal skeleton in the fallback chain so
|
||||
// codes without a dedicated template still render with firm branding.
|
||||
const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx"
|
||||
|
||||
// skeletonSubmissionENSlug names the English skeleton variant used when
|
||||
// a draft's language='en' and no per-code EN template exists
|
||||
// (t-paliad-276). Same role as skeletonSubmissionSlug but in EN.
|
||||
const skeletonSubmissionENSlug = "submission/_skeleton.en.docx"
|
||||
|
||||
// submissionTemplateRegistry maps a deadline-rule submission_code to a
|
||||
// fileRegistry slug. Lookup order matches the cronus design fallback
|
||||
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
|
||||
@@ -96,14 +141,32 @@ const skeletonSubmissionSlug = "submission/_skeleton.docx"
|
||||
// the file itself lives in mWorkRepo and is served through the shared
|
||||
// Gitea proxy cache so refreshes are visible to all consumers in one
|
||||
// place.
|
||||
//
|
||||
// t-paliad-276: codes that ship an EN sibling
|
||||
// (e.g. `de.inf.lg.erwidg.en.docx`) also register it in
|
||||
// submissionTemplateENRegistry; the language-aware lookup
|
||||
// (resolveSubmissionTemplate(ctx, code, lang)) prefers the language-
|
||||
// suffixed slug and falls back to the unsuffixed one when no per-firm
|
||||
// EN variant exists.
|
||||
var submissionTemplateRegistry = map[string]string{
|
||||
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
|
||||
}
|
||||
|
||||
// submissionTemplateENRegistry maps a submission_code to the EN
|
||||
// variant slug. Empty when no EN template has been authored — the
|
||||
// lookup falls through to the unsuffixed (DE-baked) template and the
|
||||
// editor surfaces the "Fallback: universelles Skelett" notice when
|
||||
// even the skeleton has no EN sibling.
|
||||
var submissionTemplateENRegistry = map[string]string{}
|
||||
|
||||
// fetchSubmissionTemplateBytes returns the per-submission_code template
|
||||
// bytes (and provenance SHA) when one is registered. The bool result
|
||||
// distinguishes "no per-code template registered" (callers fall back to
|
||||
// HL Patents Style) from an upstream fetch error.
|
||||
//
|
||||
// Language-suffixed variants (t-paliad-276) are served via
|
||||
// fetchSubmissionTemplateBytesForLang — this base function returns the
|
||||
// unsuffixed registry entry only (the legacy DE-baked template).
|
||||
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
|
||||
slug, ok := submissionTemplateRegistry[submissionCode]
|
||||
if !ok {
|
||||
@@ -209,6 +272,113 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
|
||||
}
|
||||
|
||||
// fetchSubmissionTemplateBytesForLang returns the per-(code, lang)
|
||||
// template bytes when a language-suffixed variant is registered. Used
|
||||
// only for the EN variant today; DE goes through the unsuffixed
|
||||
// fetchSubmissionTemplateBytes (which is the legacy / authoritative
|
||||
// DE registry). t-paliad-276.
|
||||
//
|
||||
// Returned bool = "variant registered AND fetched OK". A registered
|
||||
// variant whose file 404s on Gitea returns (nil, "", false, nil) so
|
||||
// the caller falls through to the unsuffixed template, mirroring the
|
||||
// behaviour for unregistered codes.
|
||||
func fetchSubmissionTemplateBytesForLang(ctx context.Context, submissionCode, lang string) ([]byte, string, bool, error) {
|
||||
if lang != "en" {
|
||||
// Only EN has a separate registry today. DE goes through the
|
||||
// unsuffixed path which is the authoritative DE template.
|
||||
return nil, "", false, nil
|
||||
}
|
||||
slug, ok := submissionTemplateENRegistry[submissionCode]
|
||||
if !ok {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
entry, ok := fileRegistry[slug]
|
||||
if !ok {
|
||||
return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
|
||||
}
|
||||
ce := getCacheEntry(slug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
// Treat upstream miss as "variant unavailable" so the
|
||||
// resolver falls through to the DE template instead of
|
||||
// surfacing a 502.
|
||||
log.Printf("file proxy: EN variant fetch failed for %s (%s): %v — falling through", submissionCode, slug, err)
|
||||
return nil, "", false, nil
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, true, nil
|
||||
}
|
||||
|
||||
// fetchSubmissionSkeletonBytesForLang returns the cached skeleton
|
||||
// template bytes for the requested language. EN falls back to DE when
|
||||
// the EN skeleton hasn't been authored yet (t-paliad-276). Returned
|
||||
// bool flags whether the bytes match the requested language — false
|
||||
// means the resolver should communicate "fallback" to the UI.
|
||||
func fetchSubmissionSkeletonBytesForLang(ctx context.Context, lang string) ([]byte, string, bool, error) {
|
||||
if lang == "en" {
|
||||
entry, ok := fileRegistry[skeletonSubmissionENSlug]
|
||||
if ok {
|
||||
ce := getCacheEntry(skeletonSubmissionENSlug)
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err == nil {
|
||||
ce.mu.RLock()
|
||||
if len(ce.data) > 0 {
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
sha := ce.sha
|
||||
ce.mu.RUnlock()
|
||||
return out, sha, true, nil
|
||||
}
|
||||
ce.mu.RUnlock()
|
||||
} else {
|
||||
log.Printf("file proxy: EN skeleton fetch failed (%s): %v — falling back to DE", skeletonSubmissionENSlug, err)
|
||||
}
|
||||
} else {
|
||||
if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
ce.mu.RLock()
|
||||
if len(ce.data) > 0 {
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
sha := ce.sha
|
||||
ce.mu.RUnlock()
|
||||
return out, sha, true, nil
|
||||
}
|
||||
ce.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall through to the DE skeleton; bool=false flags that the
|
||||
// returned bytes don't carry the requested language.
|
||||
bytes, sha, err := fetchSubmissionSkeletonBytes(ctx)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
return bytes, sha, lang == "de", nil
|
||||
}
|
||||
|
||||
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
|
||||
// template bytes plus its provenance SHA. Sits between the per-firm
|
||||
// per-submission_code template (fetchSubmissionTemplateBytes) and the
|
||||
@@ -219,11 +389,28 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
// call warms the cache synchronously from mWorkRepo via Gitea; later
|
||||
// calls return immediately while a background refresh runs.
|
||||
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
entry, ok := fileRegistry[skeletonSubmissionSlug]
|
||||
return fetchSubmissionTemplateSlug(ctx, skeletonSubmissionSlug)
|
||||
}
|
||||
|
||||
// fetchFirmSkeletonBytes returns the cached firm-formatted skeleton
|
||||
// template bytes (HL paragraph/character styles + 48-key placeholder
|
||||
// bag) plus its provenance SHA. Sits between the per-code template and
|
||||
// the generic universal skeleton in resolveSubmissionTemplate's
|
||||
// fallback chain (t-paliad-275). Same stale-while-revalidate caching
|
||||
// as the other Gitea-backed template parts.
|
||||
func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
|
||||
}
|
||||
|
||||
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
|
||||
// the firm-skeleton and universal-skeleton accessors. Factored out so
|
||||
// the two paths can't drift apart on caching semantics.
|
||||
func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, string, error) {
|
||||
entry, ok := fileRegistry[slug]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
|
||||
return nil, "", fmt.Errorf("file proxy: %s not registered", slug)
|
||||
}
|
||||
ce := getCacheEntry(skeletonSubmissionSlug)
|
||||
ce := getCacheEntry(slug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
@@ -241,7 +428,7 @@ func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
|
||||
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", slug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -51,6 +54,21 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
AnchorOverrides map[string]string `json:"anchorOverrides,omitempty"`
|
||||
CourtID string `json:"courtId,omitempty"`
|
||||
// t-paliad-265: per-event-card choices. Two parallel inputs:
|
||||
// - ProjectID lets the server pull persisted choices from
|
||||
// paliad.project_event_choices (project-bound /tools/fristenrechner).
|
||||
// - PerCardChoices lets the unbound /tools/verfahrensablauf
|
||||
// send an inline-CSV-decoded list straight off the URL
|
||||
// without persisting. When both are present the inline list
|
||||
// wins (what-if exploration overrides the saved state).
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,omitempty"`
|
||||
// t-paliad-290 (m/paliad#122): re-surface previously-hidden
|
||||
// optional cards. When true the calculator marks skipped rows
|
||||
// with UIDeadline.IsHidden instead of dropping them; descendants
|
||||
// stay in the result list. Default false preserves the legacy
|
||||
// suppression. HiddenCount on the response is independent.
|
||||
IncludeHidden bool `json:"includeHidden,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -61,11 +79,43 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fold per-card choices into the CalcOptions addendum. The inline
|
||||
// PerCardChoices wins over the persisted ProjectID lookup when both
|
||||
// are non-empty.
|
||||
var addendum services.CalcOptionsAddendum
|
||||
if len(req.PerCardChoices) > 0 {
|
||||
choices := make([]models.ProjectEventChoice, 0, len(req.PerCardChoices))
|
||||
for _, c := range req.PerCardChoices {
|
||||
choices = append(choices, models.ProjectEventChoice{
|
||||
SubmissionCode: c.SubmissionCode,
|
||||
ChoiceKind: c.ChoiceKind,
|
||||
ChoiceValue: c.ChoiceValue,
|
||||
})
|
||||
}
|
||||
addendum = services.ToCalcOptionsAddendum(choices)
|
||||
} else if req.ProjectID != "" && dbSvc.eventChoice != nil {
|
||||
if pid, err := uuid.Parse(req.ProjectID); err == nil {
|
||||
if uid, ok := requireUser(w, r); ok {
|
||||
if choices, err := dbSvc.eventChoice.ListForProject(r.Context(), uid, pid); err == nil {
|
||||
addendum = services.ToCalcOptionsAddendum(choices)
|
||||
}
|
||||
// Visibility-filtered lookup: a non-visible project
|
||||
// returns ErrNotVisible from ListForProject; in that
|
||||
// case we project without per-card overlays rather
|
||||
// than 404 — the timeline itself is non-PII data.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
@@ -50,6 +54,12 @@ func noCachePages(h http.Handler) http.Handler {
|
||||
// Services bundles the Phase B + C database-backed services. Pass nil if
|
||||
// DATABASE_URL was unset; the matter-management endpoints will return 503.
|
||||
type Services struct {
|
||||
// Pool is the raw connection pool. Held so the readiness probe
|
||||
// (/health/ready) can ping it without going through any individual
|
||||
// service. nil when DATABASE_URL was unset — in that case
|
||||
// /health/ready returns 503.
|
||||
Pool *sqlx.DB
|
||||
|
||||
Project *services.ProjectService
|
||||
Team *services.TeamService
|
||||
PartnerUnit *services.PartnerUnitService
|
||||
@@ -106,6 +116,10 @@ type Services struct {
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||
SubmissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
|
||||
// the Verfahrensablauf timeline.
|
||||
EventChoice *services.EventChoiceService
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
// (remote → mRiver via SSH) or local tmux availability. Stays nil
|
||||
@@ -169,6 +183,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
export: svc.Export,
|
||||
backup: svc.Backup,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
eventChoice: svc.EventChoice,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +198,38 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
// Readiness probe. Public, no auth. Distinct from /healthz: this
|
||||
// returns 200 only when the DB pool is reachable. Reaching Register
|
||||
// at all implies db.ApplyMigrations succeeded (cmd/server/main.go
|
||||
// calls it before constructing svc), so a 200 here means "migrations
|
||||
// applied AND pool responsive" — the contract Dokploy / Traefik should
|
||||
// gate on, not the bind-and-serve check that /healthz answers.
|
||||
//
|
||||
// Three outcomes:
|
||||
// - svc == nil OR svc.Pool == nil → 503 (DB-less knowledge-platform
|
||||
// deployments report not-ready so an external orchestrator can
|
||||
// distinguish them from a full prod boot).
|
||||
// - PingContext fails within 2 s → 503 (pool unreachable).
|
||||
// - PingContext succeeds → 200 "ready".
|
||||
//
|
||||
// Used by docker-compose.yml's healthcheck (Slice B) and by the
|
||||
// post-deploy verification step in .gitea/workflows/test.yaml.
|
||||
mux.HandleFunc("GET /health/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if svc == nil || svc.Pool == nil {
|
||||
http.Error(w, "db not configured\n", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := svc.Pool.PingContext(ctx); err != nil {
|
||||
http.Error(w, "db unreachable\n", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("ready\n"))
|
||||
})
|
||||
|
||||
// API endpoints (JSON, public)
|
||||
mux.HandleFunc("POST /api/login", handleAPILogin)
|
||||
mux.HandleFunc("POST /api/register", handleAPIRegister)
|
||||
@@ -355,6 +402,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
|
||||
// t-paliad-277 / m/paliad#109 — refresh project-derived variables on
|
||||
// the draft. Strips overrides for project.* / parties.* / deadline.*
|
||||
// / procedural_event.* / rule.* prefixes and bumps last_imported_at.
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/import-from-project", handleImportFromProject)
|
||||
// /counterclaim creates a CCR sub-project linked via the new
|
||||
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
|
||||
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
|
||||
@@ -390,6 +441,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/projects/{id}/partner-units", handleAttachPartnerUnit)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/partner-units/{unit_id}", handleDetachPartnerUnit)
|
||||
|
||||
// t-paliad-265 — per-event-card choices on the Verfahrensablauf timeline.
|
||||
protected.HandleFunc("GET /api/projects/{id}/event-choices", handleListProjectEventChoices)
|
||||
protected.HandleFunc("PUT /api/projects/{id}/event-choices", handlePutProjectEventChoice)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}", handleDeleteProjectEventChoice)
|
||||
|
||||
// Partner units (structural partner-led units; legacy "Dezernate").
|
||||
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
|
||||
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)
|
||||
@@ -402,6 +458,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// t-paliad-139 — set unit_role on a member.
|
||||
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
|
||||
|
||||
protected.HandleFunc("GET /api/parties/search", handlePartiesSearch)
|
||||
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
|
||||
|
||||
// Phase F — Appointments (appointments)
|
||||
@@ -613,10 +670,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// t-paliad-191 Slice 11a — admin rule-editor API.
|
||||
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
|
||||
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
|
||||
protected.HandleFunc("GET /admin/rules/export", adminGate(users, gateOnboarded(handleAdminRulesExportPage)))
|
||||
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
|
||||
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
|
||||
protected.HandleFunc("GET /admin/api/rules/export-migrations", adminGate(users, handleAdminExportRuleMigrations))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
|
||||
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
|
||||
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
|
||||
|
||||
@@ -68,6 +68,9 @@ type dbServices struct {
|
||||
|
||||
// t-paliad-238 — submission draft editor.
|
||||
submissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-265 — per-event-card optional choices.
|
||||
eventChoice *services.EventChoiceService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
@@ -698,6 +701,31 @@ func handleCreateParty(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
// GET /api/parties/search?q=...
|
||||
//
|
||||
// Cross-project party picker for the submission-draft editor
|
||||
// (t-paliad-287). Returns up to 25 parties from every project the
|
||||
// caller can see, matched by case-insensitive substring on name or
|
||||
// representative. Empty q returns the 20 most-recently-updated rows so
|
||||
// the picker isn't blank on first open. Visibility is enforced in the
|
||||
// service layer via the same predicate every project-scoped read uses.
|
||||
func handlePartiesSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
hits, err := dbSvc.parties.Search(r.Context(), uid, q, 25)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": hits})
|
||||
}
|
||||
|
||||
// DELETE /api/parties/{id}
|
||||
func handleDeleteParty(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
|
||||
@@ -60,38 +60,65 @@ const submissionDraftExportTimeout = 30 * time.Second
|
||||
// raw row plus the resolved bag and the rule metadata the sidebar uses
|
||||
// to label each variable group.
|
||||
type submissionDraftView struct {
|
||||
Draft submissionDraftJSON `json:"draft"`
|
||||
Rule *submissionRuleSummary `json:"rule,omitempty"`
|
||||
ResolvedBag services.PlaceholderMap `json:"resolved_bag"`
|
||||
MergedBag services.PlaceholderMap `json:"merged_bag"`
|
||||
PreviewHTML string `json:"preview_html"`
|
||||
Lang string `json:"lang"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
TemplateMissing bool `json:"template_missing,omitempty"`
|
||||
Draft submissionDraftJSON `json:"draft"`
|
||||
Rule *submissionRuleSummary `json:"rule,omitempty"`
|
||||
ResolvedBag services.PlaceholderMap `json:"resolved_bag"`
|
||||
MergedBag services.PlaceholderMap `json:"merged_bag"`
|
||||
PreviewHTML string `json:"preview_html"`
|
||||
Lang string `json:"lang"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
TemplateMissing bool `json:"template_missing,omitempty"`
|
||||
// TemplateTier identifies which tier of resolveSubmissionTemplate
|
||||
// produced the bytes — one of per_code_lang, per_code, skeleton_lang,
|
||||
// skeleton, letterhead. Lets the editor distinguish a perfect
|
||||
// per-firm match from a skeleton fallback. t-paliad-276.
|
||||
TemplateTier string `json:"template_tier,omitempty"`
|
||||
// LanguageFallback is true when the requested draft.language has no
|
||||
// per-firm per-code template (e.g. EN draft falls back to the DE
|
||||
// per-code template, or to the universal skeleton). UI surfaces a
|
||||
// notice so the lawyer knows the rendered body lacks language-
|
||||
// matched code-specific prose. t-paliad-276.
|
||||
LanguageFallback bool `json:"language_fallback,omitempty"`
|
||||
// AvailableParties is the project's full party roster (t-paliad-277)
|
||||
// so the frontend can render the multi-select picker in one round-
|
||||
// trip. Empty when the draft has no project attached.
|
||||
AvailableParties []submissionDraftPartyJSON `json:"available_parties"`
|
||||
}
|
||||
|
||||
// submissionDraftPartyJSON is the minimal party row the editor sidebar
|
||||
// needs to render a checkbox + role chip per party.
|
||||
type submissionDraftPartyJSON struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Representative string `json:"representative,omitempty"`
|
||||
}
|
||||
|
||||
type submissionDraftJSON struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID *uuid.UUID `json:"project_id"`
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Variables services.PlaceholderMap `json:"variables"`
|
||||
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID *uuid.UUID `json:"project_id"`
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
Variables services.PlaceholderMap `json:"variables"`
|
||||
SelectedParties []uuid.UUID `json:"selected_parties"`
|
||||
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
|
||||
LastImportedAt *time.Time `json:"last_imported_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type submissionRuleSummary struct {
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
LegalSourcePretty string `json:"legal_source_pretty,omitempty"`
|
||||
LegalSourcePrettyEN string `json:"legal_source_pretty_en,omitempty"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
LegalSourcePretty string `json:"legal_source_pretty,omitempty"`
|
||||
LegalSourcePrettyEN string `json:"legal_source_pretty_en,omitempty"`
|
||||
}
|
||||
|
||||
type submissionDraftListResponse struct {
|
||||
@@ -101,8 +128,10 @@ type submissionDraftListResponse struct {
|
||||
}
|
||||
|
||||
type submissionDraftPatchInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -337,7 +366,12 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables}
|
||||
patch := services.DraftPatch{
|
||||
Name: input.Name,
|
||||
Variables: input.Variables,
|
||||
SelectedParties: input.SelectedParties,
|
||||
Language: input.Language,
|
||||
}
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
@@ -418,7 +452,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
|
||||
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
@@ -467,7 +501,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
|
||||
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
@@ -670,18 +704,24 @@ func handleGetGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
type globalDraftPatchInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
// projectIDProvided is true when the JSON included the "project_id"
|
||||
// key (regardless of value); needed to distinguish "no change" from
|
||||
// "set to null". Set by the custom UnmarshalJSON below.
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
projectIDProvided bool
|
||||
// SelectedParties: present-but-empty array resets to "all parties",
|
||||
// present non-empty array restricts to subset, absent = no change.
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
}
|
||||
|
||||
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
type alias struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
@@ -689,7 +729,9 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
g.Name = a.Name
|
||||
g.Variables = a.Variables
|
||||
g.Language = a.Language
|
||||
g.ProjectID = a.ProjectID
|
||||
g.SelectedParties = a.SelectedParties
|
||||
// Detect whether "project_id" was present in the JSON object.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
@@ -726,7 +768,12 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables}
|
||||
patch := services.DraftPatch{
|
||||
Name: in.Name,
|
||||
Variables: in.Variables,
|
||||
SelectedParties: in.SelectedParties,
|
||||
Language: in.Language,
|
||||
}
|
||||
if in.projectIDProvided {
|
||||
pid := in.ProjectID // may be nil → detach
|
||||
patch.ProjectID = &pid
|
||||
@@ -748,6 +795,48 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// handleImportFromProject re-pulls every project-derived variable on
|
||||
// the draft and bumps last_imported_at (t-paliad-277). The service-
|
||||
// layer call strips overrides for project.* / parties.* / deadline.* /
|
||||
// procedural_event.* / rule.* prefixes; firm.* / today.* / user.*
|
||||
// overrides survive because those values aren't sourced from the
|
||||
// project record.
|
||||
//
|
||||
// Idempotent on repeat clicks. Returns the full editor view so the
|
||||
// frontend can refresh in one round-trip.
|
||||
func handleImportFromProject(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
d, err := dbSvc.submissionDraft.ImportFromProject(r.Context(), uid, draftID)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
user, _ := dbSvc.users.GetByID(r.Context(), uid)
|
||||
lang := userLang(user)
|
||||
view, err := buildSubmissionDraftView(r.Context(), d, lang)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: build view after import (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// handleGlobalDeleteSubmissionDraft removes a draft by id.
|
||||
func handleGlobalDeleteSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
@@ -801,7 +890,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
|
||||
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
@@ -859,9 +948,10 @@ func serveSubmissionDraftNotFound(w http.ResponseWriter) {
|
||||
// per-rule heading.
|
||||
func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft, lang string) (*submissionDraftView, error) {
|
||||
view := &submissionDraftView{
|
||||
Draft: draftToJSON(d),
|
||||
Lang: lang,
|
||||
HasTemplate: true,
|
||||
Draft: draftToJSON(d),
|
||||
Lang: lang,
|
||||
HasTemplate: true,
|
||||
AvailableParties: []submissionDraftPartyJSON{},
|
||||
}
|
||||
|
||||
merged, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
|
||||
@@ -873,20 +963,33 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
if resolved.Lang != "" {
|
||||
view.Lang = resolved.Lang
|
||||
}
|
||||
if len(resolved.Parties) > 0 {
|
||||
view.AvailableParties = make([]submissionDraftPartyJSON, 0, len(resolved.Parties))
|
||||
for _, p := range resolved.Parties {
|
||||
row := submissionDraftPartyJSON{ID: p.ID, Name: p.Name}
|
||||
if p.Role != nil {
|
||||
row.Role = *p.Role
|
||||
}
|
||||
if p.Representative != nil {
|
||||
row.Representative = *p.Representative
|
||||
}
|
||||
view.AvailableParties = append(view.AvailableParties, row)
|
||||
}
|
||||
}
|
||||
if resolved.Rule != nil {
|
||||
view.Rule = &submissionRuleSummary{
|
||||
Name: derefStringHandler(resolved.Rule.SubmissionCode),
|
||||
SubmissionCode: derefStringHandler(resolved.Rule.SubmissionCode),
|
||||
NameEN: resolved.Rule.NameEN,
|
||||
PrimaryParty: derefStringHandler(resolved.Rule.PrimaryParty),
|
||||
EventType: derefStringHandler(resolved.Rule.EventType),
|
||||
LegalSource: derefStringHandler(resolved.Rule.LegalSource),
|
||||
Name: derefStringHandler(resolved.Rule.SubmissionCode),
|
||||
SubmissionCode: derefStringHandler(resolved.Rule.SubmissionCode),
|
||||
NameEN: resolved.Rule.NameEN,
|
||||
PrimaryParty: derefStringHandler(resolved.Rule.PrimaryParty),
|
||||
EventType: derefStringHandler(resolved.Rule.EventType),
|
||||
LegalSource: derefStringHandler(resolved.Rule.LegalSource),
|
||||
}
|
||||
view.Rule.Name = resolved.Rule.Name
|
||||
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
|
||||
}
|
||||
|
||||
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
|
||||
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
|
||||
view.TemplateMissing = true
|
||||
@@ -894,6 +997,12 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
view.PreviewHTML = `<p class="preview-error">Vorlage konnte nicht geladen werden.</p>`
|
||||
return view, nil
|
||||
}
|
||||
view.TemplateTier = string(tier)
|
||||
// LanguageFallback signals "no per-firm template in the requested
|
||||
// language" — the editor surfaces a notice so the lawyer knows the
|
||||
// rendered body lacks code-specific prose. The per-code DE template
|
||||
// counts as a fallback when the requested language is EN.
|
||||
view.LanguageFallback = languageFallback(d.Language, tier)
|
||||
html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -902,41 +1011,101 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// submissionTemplateTier enumerates which tier of the template
|
||||
// fallback chain produced the bytes returned by resolveSubmissionTemplate.
|
||||
// Used by the editor to surface "Fallback: universelles Skelett" when
|
||||
// the requested (code, lang) didn't have a dedicated template.
|
||||
type submissionTemplateTier string
|
||||
|
||||
const (
|
||||
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
|
||||
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
|
||||
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
|
||||
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
|
||||
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
|
||||
)
|
||||
|
||||
// resolveSubmissionTemplate returns the .docx bytes for the given
|
||||
// submission code. Lookup order matches the cronus design fallback chain
|
||||
// §8 plus the t-paliad-259 universal-skeleton slot:
|
||||
// (submission_code, language). Merges t-paliad-275 (firm-skeleton tier)
|
||||
// and t-paliad-276 (language-selector + EN skeleton tier). Lookup order:
|
||||
//
|
||||
// 1. per-firm per-submission_code template registered in
|
||||
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
|
||||
// specific structure plus the full variable bag.
|
||||
// 2. universal _skeleton.docx — same variable bag, no submission_code-
|
||||
// specific prose. Catches every code without a dedicated template
|
||||
// so the editor preview / generate flow still has variables to
|
||||
// substitute instead of falling through to the bare letterhead.
|
||||
// 3. universal HL Patents Style .dotm — macro-only letterhead, no
|
||||
// placeholders. Final fallback when even the skeleton is unreachable
|
||||
// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
|
||||
// for resilience.
|
||||
// 1. per-firm per-(code, lang) template — most specific. e.g.
|
||||
// `de.inf.lg.erwidg.en.docx` for EN drafts. t-paliad-276.
|
||||
// 2. per-firm per-code (unsuffixed) template — DE-baked baseline. The
|
||||
// legacy registry shape from before the language selector landed.
|
||||
// 3. universal language-matched skeleton — `_skeleton.en.docx` for EN
|
||||
// drafts. Skipped for DE drafts (steps 4+5 already cover DE).
|
||||
// 4. firm-formatted skeleton — `_firm-skeleton.docx` (t-paliad-275).
|
||||
// HL paragraph + character styles + letterhead, full placeholder
|
||||
// bag. DE-flavored: counts as language_fallback=true for EN drafts.
|
||||
// 5. universal _skeleton.docx — plain DE skeleton, no firm styles.
|
||||
// Backstop when the firm skeleton is unreachable.
|
||||
// 6. universal HL Patents Style .dotm — macro-only letterhead, no
|
||||
// placeholders. Last-ditch when every skeleton tier is unreachable.
|
||||
//
|
||||
// The returned SHA is the cache entry's commit SHA so the export audit
|
||||
// row can record provenance.
|
||||
func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) {
|
||||
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
|
||||
return nil, "", err
|
||||
// The returned SHA pins the audit row's template provenance. The tier
|
||||
// tells the editor whether the result language-matches the request so
|
||||
// it can surface a "Fallback: universelles Skelett" notice.
|
||||
func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string) ([]byte, string, submissionTemplateTier, error) {
|
||||
if lang != "de" && lang != "en" {
|
||||
lang = "de"
|
||||
}
|
||||
// 1. per-(code, lang)
|
||||
if data, sha, found, err := fetchSubmissionTemplateBytesForLang(ctx, submissionCode, lang); err != nil {
|
||||
return nil, "", "", err
|
||||
} else if found {
|
||||
return data, sha, nil
|
||||
return data, sha, tplTierPerCodeLang, nil
|
||||
}
|
||||
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
|
||||
return data, sha, nil
|
||||
// 2. per-code (unsuffixed)
|
||||
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
|
||||
return nil, "", "", err
|
||||
} else if found {
|
||||
return data, sha, tplTierPerCode, nil
|
||||
}
|
||||
// 3. language-matched skeleton — only meaningful for EN drafts; DE
|
||||
// drafts fall through to the firm/universal DE skeletons below.
|
||||
if lang == "en" {
|
||||
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched {
|
||||
return data, sha, tplTierSkeletonLang, nil
|
||||
}
|
||||
}
|
||||
// 4. firm-formatted skeleton (HL styles, DE prose). For DE drafts
|
||||
// this is a first-class match; for EN drafts it counts as a
|
||||
// language fallback (handled by languageFallback()).
|
||||
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
|
||||
return data, sha, tplTierSkeleton, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
|
||||
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s lang=%s, falling back to universal skeleton: %v", submissionCode, lang, err)
|
||||
}
|
||||
// 5. universal plain DE skeleton.
|
||||
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
|
||||
return data, sha, tplTierSkeleton, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
|
||||
}
|
||||
// 6. HL Patents Style letterhead (no placeholders, last-ditch).
|
||||
bytes, err := fetchHLPatentsStyleBytes(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, "", "", err
|
||||
}
|
||||
sha := hlPatentsStyleSHA()
|
||||
return bytes, sha, nil
|
||||
return bytes, sha, tplTierLetterhead, nil
|
||||
}
|
||||
|
||||
// languageFallback reports whether the resolved template tier failed
|
||||
// to match the requested draft language. For an EN draft, anything
|
||||
// other than per_code_lang or skeleton_lang is a fallback (per_code is
|
||||
// the legacy DE-baked template, skeleton is the DE skeleton). For a DE
|
||||
// draft, only `letterhead` counts as a fallback — the DE skeleton and
|
||||
// per-code template are both first-class DE outputs. t-paliad-276.
|
||||
func languageFallback(lang string, tier submissionTemplateTier) bool {
|
||||
if tier == tplTierLetterhead {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(lang, "en") {
|
||||
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hlPatentsStyleSHA reads the current cache SHA for the universal
|
||||
@@ -958,15 +1127,26 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
if vars == nil {
|
||||
vars = services.PlaceholderMap{}
|
||||
}
|
||||
selected := d.SelectedParties
|
||||
if selected == nil {
|
||||
selected = []uuid.UUID{}
|
||||
}
|
||||
lang := d.Language
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
return submissionDraftJSON{
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
SubmissionCode: d.SubmissionCode,
|
||||
UserID: d.UserID,
|
||||
Name: d.Name,
|
||||
Language: lang,
|
||||
Variables: vars,
|
||||
SelectedParties: selected,
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
|
||||
43
internal/handlers/submission_template_lang_test.go
Normal file
43
internal/handlers/submission_template_lang_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package handlers
|
||||
|
||||
// Regression tests for the template-tier → language-fallback mapping
|
||||
// (t-paliad-276). The editor surfaces a "Fallback: universelles
|
||||
// Skelett" notice when the requested draft language has no per-firm
|
||||
// language-matched template — these tests pin which tier counts as a
|
||||
// fallback for each language so the UI signal stays stable.
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLanguageFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
lang string
|
||||
tier submissionTemplateTier
|
||||
want bool
|
||||
}{
|
||||
// DE drafts: every non-letterhead tier is a first-class match.
|
||||
{"de_per_code_lang", "de", tplTierPerCodeLang, false},
|
||||
{"de_per_code", "de", tplTierPerCode, false},
|
||||
{"de_skeleton_lang", "de", tplTierSkeletonLang, false},
|
||||
{"de_skeleton", "de", tplTierSkeleton, false},
|
||||
{"de_letterhead", "de", tplTierLetterhead, true},
|
||||
|
||||
// EN drafts: per_code (DE-baked) and skeleton (DE-baked) both
|
||||
// surface the fallback notice so the lawyer knows the rendered
|
||||
// body lacks EN prose.
|
||||
{"en_per_code_lang", "en", tplTierPerCodeLang, false},
|
||||
{"en_per_code", "en", tplTierPerCode, true},
|
||||
{"en_skeleton_lang", "en", tplTierSkeletonLang, false},
|
||||
{"en_skeleton", "en", tplTierSkeleton, true},
|
||||
{"en_letterhead", "en", tplTierLetterhead, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := languageFallback(c.lang, c.tier); got != c.want {
|
||||
t.Errorf("languageFallback(%q, %q) = %v, want %v", c.lang, c.tier, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -304,14 +304,23 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
|
||||
defer cancel()
|
||||
|
||||
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
|
||||
// One-shot /generate has no draft row to pull `language` from —
|
||||
// accept `?language=de|en` as an explicit override (t-paliad-276)
|
||||
// and otherwise fall back to the user's UI language.
|
||||
user, _ := dbSvc.users.GetByID(ctx, uid)
|
||||
lang := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("language")))
|
||||
if lang != "de" && lang != "en" {
|
||||
lang = userLang(user)
|
||||
}
|
||||
|
||||
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, submissionCode, lang)
|
||||
if err != nil {
|
||||
log.Printf("submissions: template fetch (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
return
|
||||
}
|
||||
|
||||
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
|
||||
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, lang, tplBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
|
||||
@@ -682,6 +682,13 @@ type DeadlineRule struct {
|
||||
// NULL while draft, set on publish, retained through archive.
|
||||
// Distinct from UpdatedAt (moves on every edit).
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
|
||||
// ChoicesOffered declares which per-event-card choice-kinds this
|
||||
// rule offers on the Verfahrensablauf timeline (mig 129,
|
||||
// t-paliad-265). NULL = no caret affordance (default). See the
|
||||
// COMMENT on paliad.deadline_rules.choices_offered for the value
|
||||
// shape. The engine and the frontend both read this column.
|
||||
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||
@@ -946,3 +953,24 @@ type ApprovalRequest struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// ProjectEventChoice is one per-event-card pick scoped to a project
|
||||
// (t-paliad-265 / m/paliad#96). The join key SubmissionCode matches
|
||||
// paliad.deadline_rules.submission_code — the same identifier the
|
||||
// AnchorOverrides plumbing in fristenrechner.go already uses.
|
||||
//
|
||||
// ChoiceKind ∈ {appellant, include_ccr, skip}. ChoiceValue namespace
|
||||
// per kind: appellant=claimant|defendant|both|none; include_ccr=true|false;
|
||||
// skip=true|false. UNIQUE(project_id, submission_code, choice_kind)
|
||||
// makes re-picks idempotent (Upsert path).
|
||||
type ProjectEventChoice struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
SubmissionCode string `db:"submission_code" json:"submission_code"`
|
||||
ChoiceKind string `db:"choice_kind" json:"choice_kind"`
|
||||
ChoiceValue string `db:"choice_value" json:"choice_value"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedBy *uuid.UUID `db:"updated_by" json:"updated_by,omitempty"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -27,33 +27,119 @@ func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator {
|
||||
}
|
||||
|
||||
// CalculateEndDate applies a single rule's duration + timing to the event date,
|
||||
// then bumps forward off non-working days for the given (country, regime).
|
||||
// Returns (adjusted, original, didAdjust).
|
||||
// then bumps off non-working days for the given (country, regime). For
|
||||
// rules with both a primary and an alt duration (alt_duration_value/_unit)
|
||||
// and a combine_op of 'max' or 'min', both legs are computed independently
|
||||
// and combined per the operator — this implements RoP R.198 / R.213
|
||||
// ("31 days OR 20 working days, whichever is longer") and the equivalent
|
||||
// shape under EPC. Returns (adjusted, original, didAdjust).
|
||||
//
|
||||
// Snap direction follows timing: 'after' snaps forward to the next
|
||||
// working day (RoP R.300.b — period extends to the next working day),
|
||||
// 'before' snaps *backward* to the preceding working day so the
|
||||
// statutory cut-off is not pushed past its hard limit.
|
||||
//
|
||||
// duration_unit='working_days' walks day-by-day via the holiday service
|
||||
// (skipping weekends + court holidays), so its result is always already a
|
||||
// working day — no post-arithmetic snap needed for that leg.
|
||||
//
|
||||
// Per Tier 3 Primitives §10 of docs/research-deadlines-completeness-2026-05-25.md
|
||||
// (m's 2026-05-25 15:29 steer: build the full primitives, no workarounds).
|
||||
func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule, country, regime string) (time.Time, time.Time, bool) {
|
||||
endDate := eventDate
|
||||
|
||||
timing := "after"
|
||||
if rule.Timing != nil {
|
||||
timing = *rule.Timing
|
||||
}
|
||||
|
||||
adjusted, raw, wasAdjusted := c.computeLeg(eventDate, rule.DurationValue, rule.DurationUnit, timing, country, regime)
|
||||
|
||||
// combine_op + alt_duration_*: compute the alt leg independently,
|
||||
// then pick the later (max) or earlier (min) of the two adjusted
|
||||
// end-dates. Live use case is UPC RoP R.198 / R.213 (31 calendar
|
||||
// days vs. 20 working days, whichever is longer).
|
||||
if rule.CombineOp != nil && rule.AltDurationValue != nil && rule.AltDurationUnit != nil {
|
||||
altAdj, altRaw, altWasAdj := c.computeLeg(eventDate, *rule.AltDurationValue, *rule.AltDurationUnit, timing, country, regime)
|
||||
switch *rule.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(adjusted) {
|
||||
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(adjusted) {
|
||||
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return adjusted, raw, wasAdjusted
|
||||
}
|
||||
|
||||
// computeLeg evaluates a single (value, unit) duration against the event
|
||||
// date in the given timing direction and snap-adjusts the result. Returns
|
||||
// the snap-adjusted end-date, the pre-snap end-date, and whether a snap
|
||||
// occurred. working_days arithmetic never needs a snap (the walker lands
|
||||
// on a working day by construction).
|
||||
func (c *DeadlineCalculator) computeLeg(eventDate time.Time, value int, unit string, timing string, country, regime string) (adjusted, raw time.Time, wasAdjusted bool) {
|
||||
sign := 1
|
||||
if timing == "before" {
|
||||
sign = -1
|
||||
}
|
||||
|
||||
switch rule.DurationUnit {
|
||||
case "days":
|
||||
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue)
|
||||
case "weeks":
|
||||
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue*7)
|
||||
case "months":
|
||||
endDate = endDate.AddDate(0, sign*rule.DurationValue, 0)
|
||||
raw = c.addDuration(eventDate, value, unit, sign, country, regime)
|
||||
if unit == "working_days" {
|
||||
return raw, raw, false
|
||||
}
|
||||
if timing == "before" {
|
||||
return c.holidays.AdjustForNonWorkingDaysBackward(raw, country, regime)
|
||||
}
|
||||
return c.holidays.AdjustForNonWorkingDays(raw, country, regime)
|
||||
}
|
||||
|
||||
original := endDate
|
||||
adjusted, _, wasAdjusted := c.holidays.AdjustForNonWorkingDays(endDate, country, regime)
|
||||
return adjusted, original, wasAdjusted
|
||||
// addDuration adds `sign * value` of the given unit to eventDate. For
|
||||
// 'working_days' it walks day-by-day skipping weekends and court
|
||||
// holidays via the holiday service.
|
||||
func (c *DeadlineCalculator) addDuration(eventDate time.Time, value int, unit string, sign int, country, regime string) time.Time {
|
||||
switch unit {
|
||||
case "days":
|
||||
return eventDate.AddDate(0, 0, sign*value)
|
||||
case "weeks":
|
||||
return eventDate.AddDate(0, 0, sign*value*7)
|
||||
case "months":
|
||||
return eventDate.AddDate(0, sign*value, 0)
|
||||
case "working_days":
|
||||
return c.addWorkingDays(eventDate, sign*value, country, regime)
|
||||
}
|
||||
return eventDate
|
||||
}
|
||||
|
||||
// addWorkingDays walks `n` business days from `date` (negative `n` walks
|
||||
// backward). The event day itself is never counted; we step first, then
|
||||
// skip past non-working days, repeated n times. Result is always a
|
||||
// working day for the given (country, regime). Matches UPC RoP R.300.b's
|
||||
// "the day on which the event happens shall not be counted" convention
|
||||
// applied to the business-day axis.
|
||||
//
|
||||
// Bound: each business-day step is bounded by a 60-day inner cap so a
|
||||
// misconfigured holiday table can never spin forever. The longest
|
||||
// real-world non-working run between adjacent business days is the
|
||||
// Christmas Eve → Neujahr window (~6 days), so 60 is over-provisioned.
|
||||
func (c *DeadlineCalculator) addWorkingDays(date time.Time, n int, country, regime string) time.Time {
|
||||
if n == 0 {
|
||||
return date
|
||||
}
|
||||
step := 1
|
||||
count := n
|
||||
if n < 0 {
|
||||
step = -1
|
||||
count = -n
|
||||
}
|
||||
cur := date
|
||||
for i := 0; i < count; i++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
for j := 0; j < 60 && c.holidays.IsNonWorkingDay(cur, country, regime); j++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// CalculateFromRules calculates deadlines for a slice of rules using the
|
||||
|
||||
@@ -93,7 +93,14 @@ func TestCalculateEndDate_Weeks_LandsOnHoliday(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateEndDate_BeforeTiming(t *testing.T) {
|
||||
// TestCalculateEndDate_BeforeTiming_SnapsBackward — Tier 3 Primitive 5
|
||||
// (m/paliad#103 Slice A). For timing='before' rules (R.109.1 / R.109.4
|
||||
// "no later than X before the oral hearing"), a computed cut-off that
|
||||
// lands on a weekend / holiday must snap *backward* to the preceding
|
||||
// working day. Forward snap would push the cut-off past the statutory
|
||||
// limit and miss the deadline. See
|
||||
// docs/research-deadlines-completeness-2026-05-25.md §10 T3.5.
|
||||
func TestCalculateEndDate_BeforeTiming_SnapsBackward(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
@@ -104,11 +111,322 @@ func TestCalculateEndDate_BeforeTiming(t *testing.T) {
|
||||
DurationUnit: "months",
|
||||
Timing: ptr("before"),
|
||||
}
|
||||
// "before" subtracts: 2026-04-15 - 1 month = 2026-03-15 (Sunday).
|
||||
// Adjust: Sunday → Monday 2026-03-16.
|
||||
// "before" subtracts: 2026-04-15 (Wed) - 1 month = 2026-03-15 (Sunday).
|
||||
// Backward snap: Sunday → Friday 2026-03-13 (Karfreitag is later
|
||||
// in 2026, so no extra holiday in this window).
|
||||
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
wantOrig := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
wantAdj := time.Date(2026, 3, 13, 0, 0, 0, 0, time.UTC)
|
||||
if !original.Equal(wantOrig) {
|
||||
t.Errorf("original: got %s, want %s", original, wantOrig)
|
||||
}
|
||||
if !adjusted.Equal(wantAdj) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, wantAdj)
|
||||
}
|
||||
if !wasAdjusted {
|
||||
t.Error("expected wasAdjusted=true (Sun → preceding Fri)")
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 5 — backward snap across Karfreitag / Ostermontag.
|
||||
// 2026 Ostern: Karfreitag = 2026-04-03 (Fri), Ostermontag = 2026-04-06 (Mon).
|
||||
// Anchor Tue 2026-05-05 minus 1 month = Sun 2026-04-05 → backward through
|
||||
// Sat → Karfreitag → Thu 2026-04-02.
|
||||
func TestCalculateEndDate_BeforeTiming_BackwardSkipsHolidayCluster(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "1-month before, Ostern cluster",
|
||||
DurationValue: 1,
|
||||
DurationUnit: "months",
|
||||
Timing: ptr("before"),
|
||||
}
|
||||
in := time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
if !wasAdjusted {
|
||||
t.Error("expected wasAdjusted=true (Sun→Karfreitag→Thu)")
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days arithmetic forward over a weekend.
|
||||
// Anchor Mon 2026-01-12 + 5 working days = Tue 13 (1), Wed 14 (2),
|
||||
// Thu 15 (3), Fri 16 (4), Mon 19 (5). Result = Mon 2026-01-19.
|
||||
func TestCalculateEndDate_WorkingDays_ForwardSkipsWeekend(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "5 working days",
|
||||
DurationValue: 5,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 19, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
// working_days arithmetic lands on a working day by construction, so the
|
||||
// "snap" reports no adjustment and original == adjusted.
|
||||
if !original.Equal(want) {
|
||||
t.Errorf("original: got %s, want %s", original, want)
|
||||
}
|
||||
if wasAdjusted {
|
||||
t.Error("working_days result should not report a snap adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days arithmetic with anchor on Friday;
|
||||
// 20 working days lands on the Friday four weeks later. Anchor Fri
|
||||
// 2026-01-09 → +20wd → Fri 2026-02-06. No DE federal holiday in
|
||||
// window. This exercises the R.198 / R.213 "20 working days" leg.
|
||||
func TestCalculateEndDate_WorkingDays_TwentyDays(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "20 working days",
|
||||
DurationValue: 20,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 2, 6, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days across Karfreitag/Ostermontag. Anchor
|
||||
// Thu 2026-04-02 + 3 working days: skip Karfreitag (Fri 04-03), weekend,
|
||||
// Ostermontag (Mon 04-06). Walk: Tue 04-07 (1), Wed 04-08 (2), Thu 04-09
|
||||
// (3). Result = Thu 2026-04-09.
|
||||
func TestCalculateEndDate_WorkingDays_AcrossEasterCluster(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "3 working days over Ostern",
|
||||
DurationValue: 3,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 4, 9, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days across year boundary. Anchor Mon
|
||||
// 2025-12-29 + 5 working days. Calendar: Tue 30 (1), Wed 31 (2),
|
||||
// Thu 2026-01-01 = Neujahr (skip), Fri 2026-01-02 (3), Mon 05 (4),
|
||||
// Tue 06 (5). Result = Tue 2026-01-06.
|
||||
func TestCalculateEndDate_WorkingDays_AcrossYearBoundary(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "5 working days over year-end",
|
||||
DurationValue: 5,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2025, 12, 29, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 6, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days backward (timing='before'). Anchor
|
||||
// Fri 2026-04-17 - 5 working days: Thu 16 (1), Wed 15 (2), Tue 14 (3),
|
||||
// Mon 13 (4), Fri 10 (5 — Mon 13 - 3 days skipping Sun/Sat). Result =
|
||||
// Fri 2026-04-10.
|
||||
func TestCalculateEndDate_WorkingDays_BackwardSkipsWeekend(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "5 working days before",
|
||||
DurationValue: 5,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("before"),
|
||||
}
|
||||
in := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days anchored on a Saturday (rare but
|
||||
// must not loop). +3 working days from Sat 2026-01-10: Mon 12 (1), Tue
|
||||
// 13 (2), Wed 14 (3). Result = Wed 2026-01-14.
|
||||
func TestCalculateEndDate_WorkingDays_AnchorOnWeekend(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "3 working days from Saturday",
|
||||
DurationValue: 3,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op='max' picks the LATER of two adjusted
|
||||
// end-dates. Matches UPC RoP R.198 / R.213 "31 calendar days OR 20
|
||||
// working days, whichever is longer". Anchor Mon 2026-01-12.
|
||||
// - Primary: 31 cal days → Sun 2026-02-12... wait, Mon Jan 12 + 31 =
|
||||
// Thu 2026-02-12 (verify: Jan has 31 days; 12 + 31 = day-43 of year
|
||||
// = Feb 12). Feb 12 2026 is Thursday → no snap, +31d.
|
||||
// - Alt: 20 working_days → Mon Jan 12 + 20wd: Tue 13 (1) ... walk
|
||||
// gives Mon 2026-02-09 (20 business days later, no DE holiday).
|
||||
//
|
||||
// max(Feb 12 Thu, Feb 09 Mon) = Feb 12 → primary wins.
|
||||
func TestCalculateEndDate_CombineMax_PrimaryWins(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "31d OR 20wd, max",
|
||||
DurationValue: 31,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
AltDurationValue: ptr(20),
|
||||
AltDurationUnit: ptr("working_days"),
|
||||
CombineOp: ptr("max"),
|
||||
}
|
||||
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op='max', alt wins. Anchor that makes the
|
||||
// 20-working-days leg longer than the 31-cal-day leg. Anchor Fri
|
||||
// 2026-01-09: +31 cal days = Mon 2026-02-09 (calendar weekday, no snap);
|
||||
// +20 working_days = Fri 2026-02-06 ... actually let's pick an anchor
|
||||
// where the working-days side overshoots. Anchor over a long-weekend
|
||||
// cluster: Wed 2026-12-23, +31cal = Sat 2027-01-23 → forward-snap to Mon
|
||||
// 2027-01-25 (DE has no holiday that day). +20wd = walk skipping Heilig
|
||||
// Abend, Christmas, Neujahr, weekends. Pick simpler: anchor where 31cal
|
||||
// + snap ≈ 20wd + cluster.
|
||||
//
|
||||
// Concrete: anchor Mon 2026-01-12, mock the 31d leg landing on Sun
|
||||
// 2026-02-15 (no — Jan 12 + 34 days = Feb 15, not 31). For deterministic
|
||||
// "alt wins", we use a configurable anchor and check the relative order
|
||||
// instead.
|
||||
func TestCalculateEndDate_CombineMax_AltWins(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
// Anchor Thu 2026-12-24 (Heilig Abend is not a DE federal holiday;
|
||||
// holiday service only has Neujahr/Easter/.../Weihnachtstag — Dec
|
||||
// 24 is a working day here). +14 calendar days = Thu 2027-01-07.
|
||||
// +20 working_days walks Fri 12-25 (1. Weihnachtstag — skip), ...
|
||||
// arrives much later. Use 14 days vs 20 working_days to make alt
|
||||
// reliably win on this stretch.
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "14d OR 20wd, max",
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
AltDurationValue: ptr(20),
|
||||
AltDurationUnit: ptr("working_days"),
|
||||
CombineOp: ptr("max"),
|
||||
}
|
||||
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
// Primary 14 cal days: Dec 24 (Thu) + 14 = Jan 7 2027 (Thu), working
|
||||
// day → no snap. Alt 20 working_days walks past Christmas + Neujahr:
|
||||
// Fri 12-25 (1.W) skip, Sat/Sun 12-26/27 skip (Sat counts as
|
||||
// non-working; 2.W on 26 also skips), Mon 12-28 (1), Tue 12-29 (2),
|
||||
// Wed 12-30 (3), Thu 12-31 (4), Fri 01-01-2027 Neujahr skip, Mon
|
||||
// 01-04 (5), Tue 01-05 (6), Wed 01-06 (7), Thu 01-07 (8), Fri 01-08
|
||||
// (9), Mon 01-11 (10), Tue 01-12 (11), Wed 01-13 (12), Thu 01-14
|
||||
// (13), Fri 01-15 (14), Mon 01-18 (15), Tue 01-19 (16), Wed 01-20
|
||||
// (17), Thu 01-21 (18), Fri 01-22 (19), Mon 01-25 (20). Result =
|
||||
// Mon 2027-01-25. After max(Jan 7, Jan 25) → Jan 25.
|
||||
want := time.Date(2027, 1, 25, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op='min' picks the EARLIER end-date.
|
||||
// Same shape as the max test but inverted. Same Dec 24 2026 anchor,
|
||||
// 14d vs 20wd: min = Jan 7 2027 (the primary leg).
|
||||
func TestCalculateEndDate_CombineMin_PrimaryWins(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "14d OR 20wd, min",
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
AltDurationValue: ptr(20),
|
||||
AltDurationUnit: ptr("working_days"),
|
||||
CombineOp: ptr("min"),
|
||||
}
|
||||
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2027, 1, 7, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op with NULL alt fields short-circuits to
|
||||
// the primary-only result (defensive: drift in seed data shouldn't crash
|
||||
// the calculator). Same as the basic days test but with combine_op set
|
||||
// and alt fields nil.
|
||||
func TestCalculateEndDate_CombineOp_AltNil_FallsBackToPrimary(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "Primary only, stray combine_op",
|
||||
DurationValue: 10,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
CombineOp: ptr("max"),
|
||||
}
|
||||
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 23, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
@@ -168,4 +486,3 @@ func TestAdjustForNonWorkingDays_WalksPastSummerVacation(t *testing.T) {
|
||||
// PR-3 ("SoD 3mo from 2026-04-30 → adjusted Mon 2026-08-31, not Sat
|
||||
// 2026-08-29") locks the live behaviour.
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
|
||||
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`
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
||||
choices_offered`
|
||||
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active`
|
||||
@@ -206,6 +207,44 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for the
|
||||
// given id set, keyed by id. Returns nil, nil for an empty input set so
|
||||
// callers can blindly forward whatever they accumulated. Inactive rows
|
||||
// are included — the conditional-label resolution in fristenrechner.go
|
||||
// surfaces the trigger event's display name even when the catalog row
|
||||
// has been retired, which is preferable to silently falling back to
|
||||
// the (wrong) parent_id name.
|
||||
//
|
||||
// Used by FristenrechnerService.Calculate to redirect a conditional
|
||||
// rule's "abhängig von …" chip from parent_id to trigger_event_id —
|
||||
// the actual semantic anchor for rules whose data-model parent is the
|
||||
// proceeding root but whose real trigger sits in the trigger_events
|
||||
// catalog (e.g. R.262(2) Erwiderung auf Vertraulichkeitsantrag → the
|
||||
// opposing party's confidentiality application). See m/paliad#126.
|
||||
func (s *DeadlineRuleService) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT id, code, name, name_de, description, is_active, created_at
|
||||
FROM paliad.trigger_events
|
||||
WHERE id IN (?)`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build trigger_events IN query: %w", err)
|
||||
}
|
||||
query = s.db.Rebind(query)
|
||||
|
||||
var rows []models.TriggerEvent
|
||||
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("load trigger_events by ids %v: %w", ids, err)
|
||||
}
|
||||
out := make(map[int64]models.TriggerEvent, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.ID] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListByTriggerEvent returns active rules scoped to a single trigger
|
||||
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
|
||||
// These rules carry proceeding_type_id IS NULL (event-rooted) and have
|
||||
|
||||
272
internal/services/event_choice_service.go
Normal file
272
internal/services/event_choice_service.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// EventChoiceService reads and writes paliad.project_event_choices —
|
||||
// per-event-card user picks scoped to a project (t-paliad-265 /
|
||||
// m/paliad#96). Three choice kinds today:
|
||||
//
|
||||
// appellant — claimant | defendant | both | none
|
||||
// include_ccr — true | false
|
||||
// skip — true | false
|
||||
//
|
||||
// Visibility follows paliad.can_see_project (via ProjectService.CanSee).
|
||||
// Audits via paliad.system_audit_log with event_type=project_event_choice.set
|
||||
// (insert/update) or .deleted (delete).
|
||||
//
|
||||
// The CRUD surface is intentionally tight: List for a project (one read),
|
||||
// Upsert one (idempotent re-pick), Delete one (kind-scoped). The
|
||||
// projection engine receives the choices via ToCalcOptionsAddendum,
|
||||
// which folds them into CalcOptions before Calculate runs.
|
||||
type EventChoiceService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
func NewEventChoiceService(db *sqlx.DB, projects *ProjectService, users *UserService) *EventChoiceService {
|
||||
return &EventChoiceService{db: db, projects: projects, users: users}
|
||||
}
|
||||
|
||||
// Allowed choice kinds + per-kind value namespaces. Validated server-side
|
||||
// before any write; the DB CHECK constraint catches the same shape but
|
||||
// the early validation gives a friendlier error and short-circuits the
|
||||
// transaction.
|
||||
var (
|
||||
allowedChoiceKinds = map[string]map[string]struct{}{
|
||||
"appellant": {"claimant": {}, "defendant": {}, "both": {}, "none": {}},
|
||||
"include_ccr": {"true": {}, "false": {}},
|
||||
"skip": {"true": {}, "false": {}},
|
||||
}
|
||||
)
|
||||
|
||||
func validateChoice(kind, value string) error {
|
||||
values, ok := allowedChoiceKinds[kind]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, kind)
|
||||
}
|
||||
if _, ok := values[value]; !ok {
|
||||
return fmt.Errorf("%w: invalid choice_value %q for kind %q", ErrInvalidInput, value, kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListForProject returns every choice row for the given project. Caller
|
||||
// must hold visibility on the project.
|
||||
func (s *EventChoiceService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.ProjectEventChoice, error) {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []models.ProjectEventChoice{}
|
||||
err := s.db.SelectContext(ctx, &out,
|
||||
`SELECT id, project_id, submission_code, choice_kind, choice_value,
|
||||
created_by, created_at, updated_by, updated_at
|
||||
FROM paliad.project_event_choices
|
||||
WHERE project_id = $1
|
||||
ORDER BY submission_code, choice_kind`, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list event choices: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpsertInput is the body shape for an upsert.
|
||||
type UpsertEventChoiceInput struct {
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
ChoiceKind string `json:"choice_kind"`
|
||||
ChoiceValue string `json:"choice_value"`
|
||||
}
|
||||
|
||||
// Upsert inserts or updates one (project, submission_code, choice_kind)
|
||||
// row. Audit-log row written in the same tx.
|
||||
func (s *EventChoiceService) Upsert(ctx context.Context, userID, projectID uuid.UUID, input UpsertEventChoiceInput) (*models.ProjectEventChoice, error) {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.SubmissionCode == "" {
|
||||
return nil, fmt.Errorf("%w: submission_code required", ErrInvalidInput)
|
||||
}
|
||||
if err := validateChoice(input.ChoiceKind, input.ChoiceValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actorEmail, err := s.actorEmail(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason',
|
||||
'project_event_choice.set ('||$1||','||$2||','||$3||')', true)`,
|
||||
input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
|
||||
return nil, fmt.Errorf("set audit reason: %w", err)
|
||||
}
|
||||
|
||||
var row models.ProjectEventChoice
|
||||
err = tx.GetContext(ctx, &row,
|
||||
`INSERT INTO paliad.project_event_choices
|
||||
(project_id, submission_code, choice_kind, choice_value, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $5)
|
||||
ON CONFLICT (project_id, submission_code, choice_kind)
|
||||
DO UPDATE SET choice_value = EXCLUDED.choice_value,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = now()
|
||||
RETURNING id, project_id, submission_code, choice_kind, choice_value,
|
||||
created_by, created_at, updated_by, updated_at`,
|
||||
projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert event choice: %w", err)
|
||||
}
|
||||
|
||||
if err := writeChoiceAudit(ctx, tx, "project_event_choice.set", userID, actorEmail, projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit upsert: %w", err)
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// Delete removes the (project, submission_code, choice_kind) row.
|
||||
// Returns ErrNotVisible if the project isn't visible OR the row didn't
|
||||
// exist (no leak between the two).
|
||||
func (s *EventChoiceService) Delete(ctx context.Context, userID, projectID uuid.UUID, submissionCode, choiceKind string) error {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if submissionCode == "" || choiceKind == "" {
|
||||
return fmt.Errorf("%w: submission_code + choice_kind required", ErrInvalidInput)
|
||||
}
|
||||
if _, ok := allowedChoiceKinds[choiceKind]; !ok {
|
||||
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, choiceKind)
|
||||
}
|
||||
|
||||
actorEmail, err := s.actorEmail(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason',
|
||||
'project_event_choice.deleted ('||$1||','||$2||')', true)`,
|
||||
submissionCode, choiceKind); err != nil {
|
||||
return fmt.Errorf("set audit reason: %w", err)
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.project_event_choices
|
||||
WHERE project_id = $1 AND submission_code = $2 AND choice_kind = $3`,
|
||||
projectID, submissionCode, choiceKind)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete event choice: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotVisible
|
||||
}
|
||||
if err := writeChoiceAudit(ctx, tx, "project_event_choice.deleted", userID, actorEmail, projectID, submissionCode, choiceKind, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CalcOptionsAddendum is the per-card slice of CalcOptions, built from
|
||||
// the persisted choices. ProjectionService folds these into the parent
|
||||
// CalcOptions before Calculate runs.
|
||||
type CalcOptionsAddendum struct {
|
||||
PerCardAppellant map[string]string // submission_code → appellant value
|
||||
SkipRules map[string]struct{} // set of submission_code
|
||||
IncludeCCRFor map[string]struct{} // set of submission_code
|
||||
}
|
||||
|
||||
// ToCalcOptionsAddendum converts a list of choices into the calc-options
|
||||
// shape. Empty input yields an addendum whose maps are non-nil but empty
|
||||
// so callers can use map indexing without nil checks.
|
||||
func ToCalcOptionsAddendum(choices []models.ProjectEventChoice) CalcOptionsAddendum {
|
||||
out := CalcOptionsAddendum{
|
||||
PerCardAppellant: map[string]string{},
|
||||
SkipRules: map[string]struct{}{},
|
||||
IncludeCCRFor: map[string]struct{}{},
|
||||
}
|
||||
for _, c := range choices {
|
||||
switch c.ChoiceKind {
|
||||
case "appellant":
|
||||
out.PerCardAppellant[c.SubmissionCode] = c.ChoiceValue
|
||||
case "skip":
|
||||
if c.ChoiceValue == "true" {
|
||||
out.SkipRules[c.SubmissionCode] = struct{}{}
|
||||
}
|
||||
case "include_ccr":
|
||||
if c.ChoiceValue == "true" {
|
||||
out.IncludeCCRFor[c.SubmissionCode] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeChoiceAudit inserts a project-scoped row into paliad.system_audit_log
|
||||
// with the choice details in metadata. Same shape as the data-export +
|
||||
// checklist audit writers.
|
||||
func writeChoiceAudit(ctx context.Context, tx *sqlx.Tx, eventType string, actorID uuid.UUID, actorEmail string, projectID uuid.UUID, submissionCode, choiceKind, choiceValue string) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.system_audit_log
|
||||
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||
VALUES ($1, $2, $3, 'project', $4,
|
||||
jsonb_build_object(
|
||||
'submission_code', $5::text,
|
||||
'choice_kind', $6::text,
|
||||
'choice_value', $7::text
|
||||
))`,
|
||||
eventType, actorID, actorEmail, projectID, submissionCode, choiceKind, choiceValue); err != nil {
|
||||
return fmt.Errorf("audit insert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EventChoiceService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
var email string
|
||||
err := s.db.GetContext(ctx, &email,
|
||||
`SELECT email FROM paliad.users WHERE id = $1`, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotVisible
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lookup actor: %w", err)
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (s *EventChoiceService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
|
||||
visible, err := s.projects.CanSee(ctx, userID, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visible {
|
||||
return ErrNotVisible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
108
internal/services/event_choice_service_test.go
Normal file
108
internal/services/event_choice_service_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// Unit tests for the pure helpers in event_choice_service.go. The CRUD
|
||||
// path needs a live DB and lives in the integration suite.
|
||||
|
||||
func TestValidateChoice_Appellant(t *testing.T) {
|
||||
for _, value := range []string{"claimant", "defendant", "both", "none"} {
|
||||
if err := validateChoice("appellant", value); err != nil {
|
||||
t.Errorf("appellant=%q should pass, got %v", value, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "applicant", "true", "claimaant"} {
|
||||
if err := validateChoice("appellant", bad); err == nil {
|
||||
t.Errorf("appellant=%q should fail validation", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChoice_IncludeCCR(t *testing.T) {
|
||||
for _, value := range []string{"true", "false"} {
|
||||
if err := validateChoice("include_ccr", value); err != nil {
|
||||
t.Errorf("include_ccr=%q should pass, got %v", value, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "yes", "1", "True"} {
|
||||
if err := validateChoice("include_ccr", bad); err == nil {
|
||||
t.Errorf("include_ccr=%q should fail validation", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChoice_Skip(t *testing.T) {
|
||||
for _, value := range []string{"true", "false"} {
|
||||
if err := validateChoice("skip", value); err != nil {
|
||||
t.Errorf("skip=%q should pass, got %v", value, err)
|
||||
}
|
||||
}
|
||||
if err := validateChoice("skip", "maybe"); err == nil {
|
||||
t.Errorf("skip=maybe should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChoice_UnknownKind(t *testing.T) {
|
||||
if err := validateChoice("not_a_kind", "true"); err == nil {
|
||||
t.Errorf("unknown choice_kind should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCalcOptionsAddendum_PerCardAppellant(t *testing.T) {
|
||||
choices := []models.ProjectEventChoice{
|
||||
{SubmissionCode: "upc.inf.cfi.decision", ChoiceKind: "appellant", ChoiceValue: "defendant"},
|
||||
{SubmissionCode: "de.inf.lg.urteil", ChoiceKind: "appellant", ChoiceValue: "both"},
|
||||
}
|
||||
out := ToCalcOptionsAddendum(choices)
|
||||
if out.PerCardAppellant["upc.inf.cfi.decision"] != "defendant" {
|
||||
t.Errorf("appellant pick for upc.inf.cfi.decision = %q, want defendant", out.PerCardAppellant["upc.inf.cfi.decision"])
|
||||
}
|
||||
if out.PerCardAppellant["de.inf.lg.urteil"] != "both" {
|
||||
t.Errorf("appellant pick for de.inf.lg.urteil = %q, want both", out.PerCardAppellant["de.inf.lg.urteil"])
|
||||
}
|
||||
if len(out.SkipRules) != 0 || len(out.IncludeCCRFor) != 0 {
|
||||
t.Errorf("appellant-only input should not populate skip/include_ccr maps")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCalcOptionsAddendum_SkipRules(t *testing.T) {
|
||||
choices := []models.ProjectEventChoice{
|
||||
{SubmissionCode: "upc.inf.cfi.ccr", ChoiceKind: "skip", ChoiceValue: "true"},
|
||||
{SubmissionCode: "upc.inf.cfi.prelim", ChoiceKind: "skip", ChoiceValue: "false"},
|
||||
}
|
||||
out := ToCalcOptionsAddendum(choices)
|
||||
if _, ok := out.SkipRules["upc.inf.cfi.ccr"]; !ok {
|
||||
t.Errorf("skip=true should populate SkipRules")
|
||||
}
|
||||
if _, ok := out.SkipRules["upc.inf.cfi.prelim"]; ok {
|
||||
t.Errorf("skip=false should NOT populate SkipRules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCalcOptionsAddendum_IncludeCCRFor(t *testing.T) {
|
||||
choices := []models.ProjectEventChoice{
|
||||
{SubmissionCode: "upc.inf.cfi.sod", ChoiceKind: "include_ccr", ChoiceValue: "true"},
|
||||
{SubmissionCode: "de.inf.lg.erwidg", ChoiceKind: "include_ccr", ChoiceValue: "false"},
|
||||
}
|
||||
out := ToCalcOptionsAddendum(choices)
|
||||
if _, ok := out.IncludeCCRFor["upc.inf.cfi.sod"]; !ok {
|
||||
t.Errorf("include_ccr=true should populate IncludeCCRFor")
|
||||
}
|
||||
if _, ok := out.IncludeCCRFor["de.inf.lg.erwidg"]; ok {
|
||||
t.Errorf("include_ccr=false should NOT populate IncludeCCRFor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCalcOptionsAddendum_EmptyInput(t *testing.T) {
|
||||
out := ToCalcOptionsAddendum(nil)
|
||||
if out.PerCardAppellant == nil || out.SkipRules == nil || out.IncludeCCRFor == nil {
|
||||
t.Errorf("empty input should still produce non-nil maps for safe indexing")
|
||||
}
|
||||
if len(out.PerCardAppellant) != 0 || len(out.SkipRules) != 0 || len(out.IncludeCCRFor) != 0 {
|
||||
t.Errorf("empty input should produce empty maps")
|
||||
}
|
||||
}
|
||||
@@ -51,13 +51,20 @@ const SpecVersion = 1
|
||||
// can't bury an in-flight approval, per the design doc §3 carve-out).
|
||||
// Set by the bar's `unread_only` axis on /inbox; other surfaces leave
|
||||
// it false and the spec is a no-op.
|
||||
//
|
||||
// Predicates is a flat per-source narrowing record: keys at the top
|
||||
// level are data sources ("deadline", "appointment", …) and values are
|
||||
// the per-source predicate structs directly. The shape on the wire and
|
||||
// the shape the frontend emits agree exactly — see t-paliad-283 for the
|
||||
// latent contract bug (Go used to wrap each entry in another Predicates
|
||||
// struct, so the frontend's overlay clicks parsed back as no-op).
|
||||
type FilterSpec struct {
|
||||
Version int `json:"version"`
|
||||
Sources []DataSource `json:"sources"`
|
||||
Scope ScopeSpec `json:"scope"`
|
||||
Time TimeSpec `json:"time"`
|
||||
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
|
||||
UnreadOnly bool `json:"unread_only,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Sources []DataSource `json:"sources"`
|
||||
Scope ScopeSpec `json:"scope"`
|
||||
Time TimeSpec `json:"time"`
|
||||
Predicates *Predicates `json:"predicates,omitempty"`
|
||||
UnreadOnly bool `json:"unread_only,omitempty"`
|
||||
}
|
||||
|
||||
// ScopeSpec narrows which projects contribute rows. Resolved at query
|
||||
@@ -147,7 +154,8 @@ const (
|
||||
)
|
||||
|
||||
// Predicates is the per-source narrowing payload. Empty fields mean
|
||||
// "no narrowing" — never "exclude all".
|
||||
// "no narrowing" — never "exclude all". One field per data source;
|
||||
// the wire shape is the same: `{"deadline": {...}, "appointment": {...}}`.
|
||||
type Predicates struct {
|
||||
Deadline *DeadlinePredicates `json:"deadline,omitempty"`
|
||||
Appointment *AppointmentPredicates `json:"appointment,omitempty"`
|
||||
@@ -305,14 +313,25 @@ func (s *FilterSpec) Validate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
for src, preds := range s.Predicates {
|
||||
if !isKnownSource(src) {
|
||||
return fmt.Errorf("%w: predicates set on unknown source %q", ErrInvalidInput, src)
|
||||
if s.Predicates != nil {
|
||||
// Reject predicates set on a source the spec doesn't list — we'd
|
||||
// silently drop the narrowing otherwise. Walk the set fields.
|
||||
type srcCheck struct {
|
||||
src DataSource
|
||||
present bool
|
||||
}
|
||||
if !seen[src] {
|
||||
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, src)
|
||||
checks := []srcCheck{
|
||||
{SourceDeadline, s.Predicates.Deadline != nil},
|
||||
{SourceAppointment, s.Predicates.Appointment != nil},
|
||||
{SourceProjectEvent, s.Predicates.ProjectEvent != nil},
|
||||
{SourceApprovalRequest, s.Predicates.ApprovalRequest != nil},
|
||||
}
|
||||
if err := preds.validate(); err != nil {
|
||||
for _, c := range checks {
|
||||
if c.present && !seen[c.src] {
|
||||
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, c.src)
|
||||
}
|
||||
}
|
||||
if err := s.Predicates.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
125
internal/services/filter_spec_predicates_test.go
Normal file
125
internal/services/filter_spec_predicates_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// t-paliad-283 regression: the bar's chip clicks POST a `predicates`
|
||||
// payload shaped as `{<source>: <per-source>}`. The Go side previously
|
||||
// declared `Predicates map[DataSource]Predicates` — a doubled-nested
|
||||
// shape — which silently unmarshalled the bar's payload as no-op
|
||||
// narrowing. This test pins the wire shape so the contract can't drift
|
||||
// again.
|
||||
//
|
||||
// Run with `go test ./internal/services/`.
|
||||
|
||||
func TestFilterSpec_FlatPredicatesWireShape(t *testing.T) {
|
||||
// The shape every chip click in the FilterBar emits: predicates is
|
||||
// keyed by data source, value is the per-source predicate struct
|
||||
// directly. Doubled-nesting would unmarshal as empty Predicates.
|
||||
const wire = `{
|
||||
"version": 1,
|
||||
"sources": ["deadline", "appointment", "project_event", "approval_request"],
|
||||
"scope": {"projects": {"mode": "all_visible"}},
|
||||
"time": {"field": "auto", "horizon": "past_30d"},
|
||||
"predicates": {
|
||||
"deadline": {"status": ["pending"]},
|
||||
"appointment": {"appointment_types": ["hearing"]},
|
||||
"project_event": {"event_types": ["deadline_created"]},
|
||||
"approval_request": {"viewer_role": "any_visible", "status": ["pending"]}
|
||||
}
|
||||
}`
|
||||
|
||||
var spec FilterSpec
|
||||
if err := json.Unmarshal([]byte(wire), &spec); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if err := spec.Validate(); err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
if spec.Predicates == nil {
|
||||
t.Fatal("predicates must be non-nil after unmarshalling the bar's shape")
|
||||
}
|
||||
if spec.Predicates.Deadline == nil || len(spec.Predicates.Deadline.Status) != 1 || spec.Predicates.Deadline.Status[0] != "pending" {
|
||||
t.Errorf("deadline.status must round-trip, got %+v", spec.Predicates.Deadline)
|
||||
}
|
||||
if spec.Predicates.Appointment == nil || len(spec.Predicates.Appointment.AppointmentTypes) != 1 {
|
||||
t.Errorf("appointment.appointment_types must round-trip, got %+v", spec.Predicates.Appointment)
|
||||
}
|
||||
if spec.Predicates.ProjectEvent == nil || len(spec.Predicates.ProjectEvent.EventTypes) != 1 {
|
||||
t.Errorf("project_event.event_types must round-trip, got %+v", spec.Predicates.ProjectEvent)
|
||||
}
|
||||
if spec.Predicates.ApprovalRequest == nil || spec.Predicates.ApprovalRequest.ViewerRole != "any_visible" {
|
||||
t.Errorf("approval_request.viewer_role must round-trip, got %+v", spec.Predicates.ApprovalRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// The shipped FilterSpec must marshal back to exactly the flat shape
|
||||
// the frontend declares in views/types.ts. Otherwise /api/views/system
|
||||
// (which serializes the InboxSystemView's Filter for the bar) returns a
|
||||
// shape the frontend can't consume without translation gymnastics.
|
||||
func TestFilterSpec_MarshalFlatPredicatesShape(t *testing.T) {
|
||||
spec := FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceDeadline},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
Predicates: &Predicates{
|
||||
Deadline: &DeadlinePredicates{Status: []string{"pending"}},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
// Parse back generically so the assertion is on the wire shape, not
|
||||
// on the Go type system that produced it.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
t.Fatalf("re-unmarshal: %v", err)
|
||||
}
|
||||
var preds map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw["predicates"], &preds); err != nil {
|
||||
t.Fatalf("predicates re-unmarshal: %v", err)
|
||||
}
|
||||
dl, ok := preds["deadline"]
|
||||
if !ok {
|
||||
t.Fatal("predicates.deadline missing — wire shape regressed")
|
||||
}
|
||||
var dlBody map[string]json.RawMessage
|
||||
if err := json.Unmarshal(dl, &dlBody); err != nil {
|
||||
t.Fatalf("deadline body unmarshal: %v", err)
|
||||
}
|
||||
if _, ok := dlBody["status"]; !ok {
|
||||
t.Errorf("predicates.deadline.status must be a top-level field; doubled-nesting reappeared. Body: %s", string(dl))
|
||||
}
|
||||
if _, ok := dlBody["deadline"]; ok {
|
||||
t.Errorf("predicates.deadline must NOT wrap a nested deadline key — that's the t-paliad-283 bug. Body: %s", string(dl))
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end pin: the bar's payload after the user clicks
|
||||
// "Frist-Status: Erledigt" (completed) must produce a spec whose
|
||||
// runDeadlines branch narrows to completed deadlines. Without the
|
||||
// t-paliad-283 fix, the unmarshal silently produced an empty Predicates
|
||||
// and the SQL ran without the `status='completed'` clause.
|
||||
func TestFilterSpec_BarChipPayloadNarrowsDeadlineStatus(t *testing.T) {
|
||||
const barPayload = `{
|
||||
"version": 1,
|
||||
"sources": ["deadline"],
|
||||
"scope": {"projects": {"mode": "all_visible"}},
|
||||
"time": {"field": "auto", "horizon": "past_30d"},
|
||||
"predicates": {"deadline": {"status": ["completed"]}}
|
||||
}`
|
||||
var spec FilterSpec
|
||||
if err := json.Unmarshal([]byte(barPayload), &spec); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if spec.Predicates == nil || spec.Predicates.Deadline == nil {
|
||||
t.Fatal("deadline predicate must survive the round-trip")
|
||||
}
|
||||
if len(spec.Predicates.Deadline.Status) != 1 || spec.Predicates.Deadline.Status[0] != "completed" {
|
||||
t.Errorf("deadline.status must be [\"completed\"], got %+v", spec.Predicates.Deadline.Status)
|
||||
}
|
||||
}
|
||||
@@ -180,8 +180,8 @@ func TestFilterSpec_NewSymmetricHorizonsValidate(t *testing.T) {
|
||||
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceDeadline}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}}},
|
||||
s.Predicates = &Predicates{
|
||||
Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("predicates on unselected source must reject, got %v", err)
|
||||
@@ -190,8 +190,8 @@ func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
|
||||
|
||||
func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"weird"}}},
|
||||
s.Predicates = &Predicates{
|
||||
Deadline: &DeadlinePredicates{Status: []string{"weird"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown deadline.status must reject, got %v", err)
|
||||
@@ -201,8 +201,8 @@ func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
|
||||
func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = append(s.Sources, SourceAppointment)
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}}},
|
||||
s.Predicates = &Predicates{
|
||||
Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown appointment_type must reject, got %v", err)
|
||||
@@ -212,8 +212,8 @@ func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
|
||||
func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceProjectEvent}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}}},
|
||||
s.Predicates = &Predicates{
|
||||
ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown project_event kind must reject, got %v", err)
|
||||
@@ -223,8 +223,8 @@ func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
|
||||
func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceApprovalRequest}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"}},
|
||||
s.Predicates = &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown viewer_role must reject, got %v", err)
|
||||
@@ -234,8 +234,8 @@ func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
|
||||
func TestFilterSpec_ApprovalRequestStatusEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceApprovalRequest}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}}},
|
||||
s.Predicates = &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown approval_request.status must reject, got %v", err)
|
||||
@@ -251,15 +251,15 @@ func TestFilterSpec_RoundTripJSON(t *testing.T) {
|
||||
PersonalOnly: false,
|
||||
},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{
|
||||
Predicates: &Predicates{
|
||||
Deadline: &DeadlinePredicates{
|
||||
Status: []string{"pending"},
|
||||
ApprovalStatus: []string{"approved", "pending"},
|
||||
}},
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
},
|
||||
ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "approver_eligible",
|
||||
Status: []string{"pending"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, err := MarshalFilterSpec(original)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -89,7 +90,54 @@ type UIDeadline struct {
|
||||
// is computed off a parent date that the COURT sets, not by the
|
||||
// court itself.
|
||||
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
|
||||
// IsConditional signals the rule's anchor is uncertain — no
|
||||
// concrete date can be projected. Set when the rule depends on:
|
||||
// - a court-set ancestor whose date isn't anchored
|
||||
// (overlaps with IsCourtSetIndirect; the two are kept
|
||||
// distinct because IsCourtSet wraps a specific UX message
|
||||
// "wird vom Gericht bestimmt", whereas IsConditional is
|
||||
// the broader "render as 'abhängig von <parent>'" signal)
|
||||
// - timing='before' rules whose forward anchor isn't set
|
||||
// (e.g. R.109(1) Antrag auf Simultanübersetzung 1 month
|
||||
// before the oral hearing — without the hearing date, the
|
||||
// backward arithmetic against the trigger date is meaningless)
|
||||
// - optional opposing-side rules whose true triggering event
|
||||
// hasn't been recorded for this project (e.g. R.262(2)
|
||||
// Erwiderung auf Vertraulichkeitsantrag — the data-model
|
||||
// parent is the SoC, but the real trigger is the opposing
|
||||
// party's confidentiality motion which may never happen)
|
||||
// When true, DueDate and OriginalDate are empty and the frontend
|
||||
// renders an "abhängig von <ParentRuleName>" chip in place of a
|
||||
// date. Suppressed by an explicit user anchor (IsOverridden wins).
|
||||
// (t-paliad-289)
|
||||
IsConditional bool `json:"isConditional,omitempty"`
|
||||
// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
|
||||
// parent's identity so the frontend can render
|
||||
// "abhängig von <ParentRuleName>" when IsConditional=true.
|
||||
// Populated whenever the rule has a parent_id, not only when
|
||||
// conditional — keeps the wire shape stable. Empty for root rules.
|
||||
ParentRuleCode string `json:"parentRuleCode,omitempty"`
|
||||
ParentRuleName string `json:"parentRuleName,omitempty"`
|
||||
ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"`
|
||||
IsOverridden bool `json:"isOverridden,omitempty"`
|
||||
// ChoicesOffered surfaces paliad.deadline_rules.choices_offered for
|
||||
// the rule so the frontend knows whether to render the per-event-card
|
||||
// caret affordance, and which choice-kinds to populate the popover
|
||||
// with. NULL / empty for rules with no choices. (t-paliad-265)
|
||||
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
|
||||
// AppellantContext is the per-decision appellant pick that applies
|
||||
// to descendants of the closest ancestor decision card with a
|
||||
// PerCardAppellant set. Empty when no per-card override is in
|
||||
// effect (page-level ?appellant= still applies in that case).
|
||||
// Frontend bucketer prefers this over the page-level appellant when
|
||||
// non-empty. (t-paliad-265)
|
||||
AppellantContext string `json:"appellantContext,omitempty"`
|
||||
// IsHidden marks a card the user has previously hidden via a
|
||||
// skip choice. Only ever true when CalcOptions.IncludeHidden is
|
||||
// set — the toggle re-surfaces these rows so the user can either
|
||||
// keep them faded for context or un-hide them via the inline
|
||||
// "Wieder einblenden" chip. (t-paliad-290 / m/paliad#122)
|
||||
IsHidden bool `json:"isHidden,omitempty"`
|
||||
}
|
||||
|
||||
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
|
||||
@@ -125,6 +173,14 @@ type UIResponse struct {
|
||||
// is the appealable first-instance decision (m/paliad#81).
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
// HiddenCount is the number of rules whose submission_code is in
|
||||
// CalcOptions.SkipRules AND whose condition_expr gate passes —
|
||||
// i.e. how many rows the user has hidden in this projection
|
||||
// regardless of the IncludeHidden toggle state. The frontend uses
|
||||
// this to render the "Ausgeblendete (N)" badge on the toggle even
|
||||
// when the toggle is OFF (so users know there's something to
|
||||
// re-surface). (t-paliad-290 / m/paliad#122)
|
||||
HiddenCount int `json:"hiddenCount"`
|
||||
}
|
||||
|
||||
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
|
||||
@@ -179,6 +235,42 @@ type CalcOptions struct {
|
||||
// Empty / nil = no override (default). Overrides apply equally to
|
||||
// the proceeding-tree and trigger-event branches.
|
||||
RuleOverrides []models.DeadlineRule
|
||||
|
||||
// Per-event-card choice overlays (t-paliad-265 / m/paliad#96).
|
||||
// Keyed by paliad.deadline_rules.submission_code — same key
|
||||
// AnchorOverrides uses.
|
||||
//
|
||||
// - PerCardAppellant: maps a decision-card's submission_code to the
|
||||
// user-picked appellant ("claimant"|"defendant"|"both"|"none").
|
||||
// The engine walks the parent chain of each rule and stamps the
|
||||
// resulting UIDeadline.AppellantContext from the closest ancestor
|
||||
// decision with a pick. The frontend bucketer then prefers the
|
||||
// per-rule context over the page-level appellant.
|
||||
// - SkipRules: set of submission_code values whose rules (and any
|
||||
// descendants) the user has opted out of for this projection.
|
||||
// Same suppression path as a failed condition_expr gate.
|
||||
// - IncludeCCRFor: set of submission_code values for rules where
|
||||
// the user opted in to the include-CCR choice (Klageerwiderung
|
||||
// cards). v1 simplification (design §4.2 #2): if non-empty,
|
||||
// "with_ccr" is appended to the flag set before gate
|
||||
// evaluation. Correct for single-CCR-entry-point proceedings
|
||||
// (UPC INF + DE LG today). Multi-CCR scope is a future expansion.
|
||||
PerCardAppellant map[string]string
|
||||
SkipRules map[string]struct{}
|
||||
IncludeCCRFor map[string]struct{}
|
||||
|
||||
// IncludeHidden re-surfaces rules whose submission_code is in
|
||||
// SkipRules (t-paliad-290 / m/paliad#122). When true:
|
||||
// - Skipped rules are NOT dropped from the result; they render
|
||||
// with UIDeadline.IsHidden=true so the frontend can fade them.
|
||||
// - Descendant suppression is bypassed (the skipped parent is
|
||||
// present in the result, so children compute their dates off
|
||||
// it as if the user had never hidden it).
|
||||
// Default false preserves the original skip semantic (drop rule +
|
||||
// suppress descendants). HiddenCount on UIResponse is independent
|
||||
// of this flag — it always reflects the number of hide-eligible
|
||||
// rows so the toggle's count badge stays accurate.
|
||||
IncludeHidden bool
|
||||
}
|
||||
|
||||
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||
@@ -233,6 +325,14 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
for _, f := range opts.Flags {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
// v1 simplification (design §4.2 #2, t-paliad-265): when any
|
||||
// IncludeCCRFor entry exists, we treat with_ccr as set in the flag
|
||||
// context. Correct for single-CCR-entry-point proceedings (UPC INF +
|
||||
// DE LG today). Multi-CCR scope is a future expansion that would
|
||||
// thread the include set through the gate evaluator per-rule.
|
||||
if len(opts.IncludeCCRFor) > 0 {
|
||||
flagSet["with_ccr"] = struct{}{}
|
||||
}
|
||||
|
||||
// Parse anchor overrides up-front so a malformed date errors out
|
||||
// before we start walking rules.
|
||||
@@ -329,6 +429,82 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
courtSet := make(map[uuid.UUID]bool, len(rules))
|
||||
deadlines := make([]UIDeadline, 0, len(rules))
|
||||
|
||||
// Pre-pass: identify rules flagged is_court_set=true in the data so
|
||||
// order-of-evaluation in sequence_order doesn't matter for the
|
||||
// parent-court-set check below. Without this, a rule processed
|
||||
// earlier than its court-set parent (e.g. R.109(1) Antrag auf
|
||||
// Simultanübersetzung sequence_order=45 vs. Mündliche Verhandlung
|
||||
// sequence_order=50 in upc.inf.cfi) misses the court-set propagation
|
||||
// and computes a meaningless date — for timing='before' rules, that
|
||||
// produces a backward offset from the trigger date, which has no
|
||||
// semantic relationship to the rule. (t-paliad-289)
|
||||
for _, r := range rules {
|
||||
if r.IsCourtSet {
|
||||
courtSet[r.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ruleByID lets the conditional-rendering branches resolve a parent
|
||||
// rule's display fields (submission_code, name, name_en) for the
|
||||
// "abhängig von <ParentRuleName>" chip without re-scanning the
|
||||
// rules slice on every iteration.
|
||||
ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules))
|
||||
for _, r := range rules {
|
||||
ruleByID[r.ID] = r
|
||||
}
|
||||
|
||||
// triggerEventByID powers the trigger-event override on the
|
||||
// conditional-label chip (m/paliad#126 / t-paliad-294). When a
|
||||
// rule carries a real paliad.trigger_events row, that catalog
|
||||
// event — not the rule's parent_id — is the rule's actual
|
||||
// semantic anchor. The override fires below when stamping
|
||||
// ParentRule* on the wire so the chip reads e.g.
|
||||
// abhängig von Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit
|
||||
// for R.262(2) Erwiderung auf Vertraulichkeitsantrag — instead of
|
||||
// the (misleading) parent_id-derived "abhängig von Klageerhebung".
|
||||
//
|
||||
// Bulk-loaded in one round-trip; trees in the live corpus carry at
|
||||
// most a handful of trigger_event_id-bearing rules (2 today on
|
||||
// upc.inf.cfi), so the IN(...) is small.
|
||||
var triggerIDs []int64
|
||||
seenTrigger := make(map[int64]struct{}, len(rules))
|
||||
for _, r := range rules {
|
||||
if r.TriggerEventID == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenTrigger[*r.TriggerEventID]; ok {
|
||||
continue
|
||||
}
|
||||
seenTrigger[*r.TriggerEventID] = struct{}{}
|
||||
triggerIDs = append(triggerIDs, *r.TriggerEventID)
|
||||
}
|
||||
triggerEventByID, err := s.rules.LoadTriggerEventsByIDs(ctx, triggerIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
|
||||
}
|
||||
|
||||
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
|
||||
// for membership tests; the engine reads them but doesn't mutate.
|
||||
skipRules := opts.SkipRules
|
||||
perCardAppellant := opts.PerCardAppellant
|
||||
// skippedIDs accumulates the set of rule UUIDs whose timeline entry
|
||||
// the user has opted out of. Walking in sequence_order means a
|
||||
// child rule's parent has already been classified — so descendant
|
||||
// suppression is a one-pass parent_id lookup.
|
||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
||||
// hiddenCount counts rows whose submission_code is in skipRules
|
||||
// AND that pass the condition_expr gate — i.e. rows the user has
|
||||
// hidden in this projection. Surfaced on UIResponse.HiddenCount so
|
||||
// the frontend's "Ausgeblendete (N)" badge stays accurate even when
|
||||
// IncludeHidden is off and the rows aren't in the result list.
|
||||
// (t-paliad-290 / m/paliad#122)
|
||||
hiddenCount := 0
|
||||
// appellantContext maps a rule UUID to the appellant value that
|
||||
// applies to its descendants. A rule that has its own PerCardAppellant
|
||||
// pick stamps itself with that value; a rule whose parent has a
|
||||
// context inherits it.
|
||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||
|
||||
for _, r := range rules {
|
||||
// Phase-3 unified gate: evaluate condition_expr (jsonb).
|
||||
// Suppression semantic preserved: when the gate fires false AND
|
||||
@@ -341,12 +517,62 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
continue
|
||||
}
|
||||
|
||||
// SkipRules suppression (t-paliad-265): the user has marked
|
||||
// this rule (or one of its ancestors) as "don't consider for
|
||||
// this case". Drop the row entirely AND record the rule ID so
|
||||
// descendants suppress too.
|
||||
//
|
||||
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
||||
// we re-surface the directly-skipped row (faded via IsHidden)
|
||||
// instead of dropping it. Descendants are NOT cascade-suppressed
|
||||
// in that mode either — the un-suppressed parent computes its
|
||||
// date normally, so children compute off it as usual. Either
|
||||
// way we count the hide for the toggle's badge.
|
||||
var isHidden bool
|
||||
if r.SubmissionCode != nil {
|
||||
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
|
||||
hiddenCount++
|
||||
if !opts.IncludeHidden {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
isHidden = true
|
||||
}
|
||||
}
|
||||
if r.ParentID != nil {
|
||||
if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// AppellantContext propagation. A rule with its own PerCardAppellant
|
||||
// pick stamps its UUID with that value. Otherwise inherit from
|
||||
// parent if the parent had a context.
|
||||
var ctxVal string
|
||||
if r.SubmissionCode != nil {
|
||||
if v, ok := perCardAppellant[*r.SubmissionCode]; ok {
|
||||
ctxVal = v
|
||||
}
|
||||
}
|
||||
if ctxVal == "" && r.ParentID != nil {
|
||||
if v, ok := appellantContext[*r.ParentID]; ok {
|
||||
ctxVal = v
|
||||
}
|
||||
}
|
||||
if ctxVal != "" {
|
||||
appellantContext[r.ID] = ctxVal
|
||||
}
|
||||
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
AppellantContext: ctxVal,
|
||||
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
|
||||
IsHidden: isHidden,
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
@@ -369,21 +595,58 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
d.NotesEN = *r.DeadlineNotesEn
|
||||
}
|
||||
|
||||
// Resolve the parent rule once so every conditional-rendering
|
||||
// branch (incl. the optional-not-recorded path below) can stamp
|
||||
// ParentRule* on the wire without re-scanning. Populated even
|
||||
// for non-conditional rows — the frontend dependency-footer
|
||||
// ("Folgt aus …") already consumes this on regular projected
|
||||
// rows. (t-paliad-289)
|
||||
var parentRule *models.DeadlineRule
|
||||
if r.ParentID != nil {
|
||||
if pr, ok := ruleByID[*r.ParentID]; ok {
|
||||
parentRule = &pr
|
||||
if pr.SubmissionCode != nil {
|
||||
d.ParentRuleCode = *pr.SubmissionCode
|
||||
}
|
||||
d.ParentRuleName = pr.Name
|
||||
d.ParentRuleNameEN = pr.NameEN
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger-event override on the user-facing dependency identity
|
||||
// (m/paliad#126 / t-paliad-294). When a rule has a real
|
||||
// trigger_event_id, that catalog event is the actual semantic
|
||||
// anchor — not the parent_id node, which is only the calc-time
|
||||
// arithmetic anchor. R.262(2) Erwiderung auf Vertraulichkeits-
|
||||
// antrag is the canonical case: parent_id resolves to the SoC
|
||||
// ("Klageerhebung"), but the real triggering event is the
|
||||
// opposing party's confidentiality application. Generalises to
|
||||
// any rule whose trigger_event_id is set (e.g. R.6(2)
|
||||
// translations_lodge → judge-rapporteur's order).
|
||||
//
|
||||
// Only the user-facing wire fields shift; parentRule (and the
|
||||
// parent_id chain that feeds parentIsCourtSet / the calc-time
|
||||
// date arithmetic below) stays anchored on the rule tree —
|
||||
// that's still the right calc semantic. parentRule is NOT
|
||||
// reassigned here.
|
||||
if r.TriggerEventID != nil {
|
||||
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
|
||||
d.ParentRuleCode = te.Code
|
||||
d.ParentRuleName = te.NameDE
|
||||
d.ParentRuleNameEN = te.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate court-set status from a parent rule whose date the
|
||||
// court determines: if the anchor itself has no real date,
|
||||
// nothing downstream can be computed either — UNLESS the user
|
||||
// has supplied an override date for the parent (which they can
|
||||
// once they know the real decision date).
|
||||
parentOverridden := false
|
||||
if r.ParentID != nil && courtSet[*r.ParentID] {
|
||||
for _, prev := range rules {
|
||||
if prev.ID == *r.ParentID {
|
||||
if prev.SubmissionCode != nil {
|
||||
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
|
||||
parentOverridden = true
|
||||
}
|
||||
}
|
||||
break
|
||||
if r.ParentID != nil && courtSet[*r.ParentID] && parentRule != nil {
|
||||
if parentRule.SubmissionCode != nil {
|
||||
if _, ok := overrideDates[*parentRule.SubmissionCode]; ok {
|
||||
parentOverridden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,6 +698,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
// "unbestimmt", not "wird vom Gericht bestimmt".
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
@@ -468,6 +732,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
// itself isn't a court action.
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
@@ -500,9 +765,19 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
// should say "unbestimmt", not "wird vom Gericht bestimmt":
|
||||
// the date isn't directly determined by the court, it's
|
||||
// derived from a date the court sets.
|
||||
//
|
||||
// timing='before' rules end up here too — a rule with
|
||||
// "1 Monat VOR der mündlichen Verhandlung" (R.109(1)) has the
|
||||
// oral hearing as its parent; if the hearing date isn't set,
|
||||
// the backward arithmetic against the trigger date is
|
||||
// meaningless. The pre-pass above ensures courtSet[oral.ID]
|
||||
// is true even when the oral hearing rule is processed later
|
||||
// in sequence_order. IsConditional surfaces the "abhängig
|
||||
// von <ParentRuleName>" UX. (t-paliad-289)
|
||||
if parentIsCourtSet {
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
@@ -605,18 +880,65 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
d.DueDate = adjusted.Format("2006-01-02")
|
||||
d.WasAdjusted = wasAdj
|
||||
d.AdjustmentReason = reason
|
||||
|
||||
// Optional-on-the-other-side detection (t-paliad-289 Symptom B).
|
||||
// Rules with priority='optional' AND primary_party='both' whose
|
||||
// data-model parent is the proceeding's trigger anchor (parent
|
||||
// has parent_id=NULL and is not court-set, i.e. the SoC root
|
||||
// rule) represent a rule whose REAL triggering event sits
|
||||
// outside the rule data — e.g. R.262(2) Erwiderung auf
|
||||
// Vertraulichkeitsantrag anchors on SoC in the data, but the
|
||||
// real trigger is the opposing party's confidentiality motion
|
||||
// which may never happen. Without an explicit anchor on the
|
||||
// rule itself (user clicks "Datum setzen" after the motion
|
||||
// arrives), the projection must NOT claim a concrete date.
|
||||
//
|
||||
// In the live corpus this catches confidentiality_response;
|
||||
// every other optional+both rule has a court-set ancestor and
|
||||
// is already caught by the parentIsCourtSet branches above.
|
||||
// Suppressed when IsOverridden (the user has anchored the rule
|
||||
// — the date is real) or when the rule has already been marked
|
||||
// IsConditional by an earlier branch.
|
||||
if !d.IsOverridden && !d.IsConditional &&
|
||||
r.Priority == "optional" &&
|
||||
r.PrimaryParty != nil && *r.PrimaryParty == "both" &&
|
||||
parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet {
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
d.WasAdjusted = false
|
||||
d.AdjustmentReason = nil
|
||||
// Mark this rule's ID as having an uncertain anchor so
|
||||
// rules chaining off it also surface conditional via the
|
||||
// parentIsCourtSet path (no rule currently chains off
|
||||
// confidentiality_response in the live corpus, but the
|
||||
// extension keeps the propagation semantics consistent).
|
||||
courtSet[r.ID] = true
|
||||
}
|
||||
|
||||
if r.SubmissionCode != nil {
|
||||
computed[*r.SubmissionCode] = adjusted
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
// t-paliad-296: within consecutive runs of rules sharing the same
|
||||
// trigger group (parent_id + trigger_event_id), reorder by duration
|
||||
// ascending so optional events following the same anchor render in
|
||||
// their likely-sequence order (a 1-month rule before a 2-month rule
|
||||
// chained off the same decision). Different trigger groups keep
|
||||
// their proceeding-sequence position — the chunk walk only sorts
|
||||
// adjacent same-group rows. Court-set / conditional rows whose
|
||||
// date isn't in the duration ladder sort LAST within their group.
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
resp := &UIResponse{
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
}
|
||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||
// so the trigger-event label rides on `pickedProceeding` (e.g.
|
||||
@@ -636,6 +958,138 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of
|
||||
// deadlines whose underlying rule shares the same trigger group
|
||||
// (parent_id + trigger_event_id) and reorders each run in place by
|
||||
// duration ascending. Different trigger groups keep their original
|
||||
// proceeding-sequence position — the walk only ever permutes adjacent
|
||||
// same-group rows.
|
||||
//
|
||||
// Sort key (within a run):
|
||||
// 1. Conditional / court-set rows (no concrete date in the duration
|
||||
// ladder) sort LAST, tiebroken by submission_code.
|
||||
// 2. duration_unit weight ASC: days/working_days < weeks < months < years
|
||||
// 3. duration_value ASC
|
||||
// 4. submission_code ASC (deterministic tiebreak)
|
||||
//
|
||||
// Issue: m/paliad#128 — post-decision optional events (R.151/R.353
|
||||
// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog
|
||||
// order instead of likely-sequence order. (t-paliad-296)
|
||||
func sortDeadlinesByDurationWithinTriggerGroup(
|
||||
deadlines []UIDeadline,
|
||||
ruleByID map[uuid.UUID]models.DeadlineRule,
|
||||
) {
|
||||
if len(deadlines) < 2 {
|
||||
return
|
||||
}
|
||||
n := len(deadlines)
|
||||
i := 0
|
||||
for i < n {
|
||||
gid := triggerGroupKey(deadlines[i], ruleByID)
|
||||
j := i + 1
|
||||
for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid {
|
||||
j++
|
||||
}
|
||||
// Root rules (no parent and no trigger_event) get gid="root"
|
||||
// and would otherwise collapse into one big run. Skip the sort
|
||||
// for the "root" pseudo-group — each root rule represents its
|
||||
// own anchor (SoC, oral hearing, decision …) and the
|
||||
// proceeding-sequence order between them must be preserved.
|
||||
if j-i > 1 && gid != "" {
|
||||
chunk := deadlines[i:j]
|
||||
sort.SliceStable(chunk, func(a, b int) bool {
|
||||
return durationLessForSort(chunk[a], chunk[b], ruleByID)
|
||||
})
|
||||
}
|
||||
i = j
|
||||
}
|
||||
}
|
||||
|
||||
// triggerGroupKey returns a string key identifying which trigger group
|
||||
// a deadline belongs to. Same key = same group = candidates for sort.
|
||||
// Empty string means "root" (no parent, no trigger_event) — used as a
|
||||
// sentinel by the caller to skip sorting roots against each other.
|
||||
func triggerGroupKey(d UIDeadline, ruleByID map[uuid.UUID]models.DeadlineRule) string {
|
||||
rid, err := uuid.Parse(d.RuleID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
r, ok := ruleByID[rid]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if r.ParentID != nil {
|
||||
return "p:" + r.ParentID.String()
|
||||
}
|
||||
if r.TriggerEventID != nil {
|
||||
return fmt.Sprintf("t:%d", *r.TriggerEventID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// durationLessForSort compares two deadlines for the duration-ascending
|
||||
// sort. Court-set / conditional rows (no concrete date) sort LAST
|
||||
// regardless of duration — they don't fit the duration ladder.
|
||||
func durationLessForSort(
|
||||
a, b UIDeadline,
|
||||
ruleByID map[uuid.UUID]models.DeadlineRule,
|
||||
) bool {
|
||||
aLast := a.IsCourtSet || a.IsConditional
|
||||
bLast := b.IsCourtSet || b.IsConditional
|
||||
if aLast != bLast {
|
||||
return !aLast
|
||||
}
|
||||
if aLast && bLast {
|
||||
return a.Code < b.Code
|
||||
}
|
||||
|
||||
ra := lookupRuleFromDeadline(a, ruleByID)
|
||||
rb := lookupRuleFromDeadline(b, ruleByID)
|
||||
|
||||
wa := durationUnitWeight(ra.DurationUnit)
|
||||
wb := durationUnitWeight(rb.DurationUnit)
|
||||
if wa != wb {
|
||||
return wa < wb
|
||||
}
|
||||
if ra.DurationValue != rb.DurationValue {
|
||||
return ra.DurationValue < rb.DurationValue
|
||||
}
|
||||
return a.Code < b.Code
|
||||
}
|
||||
|
||||
func lookupRuleFromDeadline(
|
||||
d UIDeadline,
|
||||
ruleByID map[uuid.UUID]models.DeadlineRule,
|
||||
) models.DeadlineRule {
|
||||
if d.RuleID == "" {
|
||||
return models.DeadlineRule{}
|
||||
}
|
||||
rid, err := uuid.Parse(d.RuleID)
|
||||
if err != nil {
|
||||
return models.DeadlineRule{}
|
||||
}
|
||||
return ruleByID[rid]
|
||||
}
|
||||
|
||||
// durationUnitWeight maps a duration unit to its sort weight so the
|
||||
// trigger-group sort can order shorter durations first. days and
|
||||
// working_days share weight 0 (both are sub-week granularities);
|
||||
// unknown units sort to the end so they're visible as a tail rather
|
||||
// than silently winning.
|
||||
func durationUnitWeight(unit string) int {
|
||||
switch unit {
|
||||
case "days", "working_days":
|
||||
return 0
|
||||
case "weeks":
|
||||
return 1
|
||||
case "months":
|
||||
return 2
|
||||
case "years":
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
// ErrUnknownRule is returned when CalculateRule can't resolve the
|
||||
// (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule.
|
||||
var ErrUnknownRule = errors.New("unknown rule")
|
||||
|
||||
221
internal/services/fristenrechner_sort_test.go
Normal file
221
internal/services/fristenrechner_sort_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for the trigger-group duration sort introduced
|
||||
// by t-paliad-296 / m/paliad#128. No DB needed — feeds synthetic
|
||||
// UIDeadlines and a ruleByID map directly into the helper.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// makeRule is a tiny constructor for a synthetic rule with just the
|
||||
// fields the sort reads (parent_id, duration_value, duration_unit,
|
||||
// submission_code, trigger_event_id).
|
||||
func makeRule(t *testing.T, parent *uuid.UUID, code string, val int, unit string) (uuid.UUID, models.DeadlineRule) {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
codeCopy := code
|
||||
return id, models.DeadlineRule{
|
||||
ID: id,
|
||||
ParentID: parent,
|
||||
SubmissionCode: &codeCopy,
|
||||
DurationValue: val,
|
||||
DurationUnit: unit,
|
||||
}
|
||||
}
|
||||
|
||||
func makeDeadline(id uuid.UUID, code string) UIDeadline {
|
||||
return UIDeadline{
|
||||
RuleID: id.String(),
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision is the
|
||||
// canonical scenario from m's report — four post-decision optional
|
||||
// events anchored on the same decision must render with 1-month rules
|
||||
// before 2-month rules.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision(t *testing.T) {
|
||||
decisionID := uuid.New()
|
||||
|
||||
// Catalog order matches mig 132 sequence_order: cons_orders(60),
|
||||
// cost_app(70), rectification(70), appeal_spawn(80).
|
||||
consOrdID, consOrdRule := makeRule(t, &decisionID, "upc.inf.cfi.cons_orders", 2, "months")
|
||||
costAppID, costAppRule := makeRule(t, &decisionID, "upc.inf.cfi.cost_app", 1, "months")
|
||||
rectID, rectRule := makeRule(t, &decisionID, "upc.inf.cfi.rectification", 1, "months")
|
||||
appealID, appealRule := makeRule(t, &decisionID, "upc.inf.cfi.appeal_spawn", 2, "months")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
consOrdID: consOrdRule,
|
||||
costAppID: costAppRule,
|
||||
rectID: rectRule,
|
||||
appealID: appealRule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(consOrdID, "upc.inf.cfi.cons_orders"),
|
||||
makeDeadline(costAppID, "upc.inf.cfi.cost_app"),
|
||||
makeDeadline(rectID, "upc.inf.cfi.rectification"),
|
||||
makeDeadline(appealID, "upc.inf.cfi.appeal_spawn"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// 1-month tier first (cost_app, rectification — alphabetical by
|
||||
// submission_code), then 2-month tier (appeal_spawn, cons_orders
|
||||
// — submission_code ASC tiebreak per spec).
|
||||
want := []string{
|
||||
"upc.inf.cfi.cost_app",
|
||||
"upc.inf.cfi.rectification",
|
||||
"upc.inf.cfi.appeal_spawn",
|
||||
"upc.inf.cfi.cons_orders",
|
||||
}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight asserts the
|
||||
// unit-weight ordering: days < weeks < months < years, with shorter
|
||||
// durations of the same unit winning their tier.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight(t *testing.T) {
|
||||
parentID := uuid.New()
|
||||
|
||||
d14ID, d14Rule := makeRule(t, &parentID, "x.14days", 14, "days")
|
||||
d2wID, d2wRule := makeRule(t, &parentID, "x.2weeks", 2, "weeks")
|
||||
d1mID, d1mRule := makeRule(t, &parentID, "x.1month", 1, "months")
|
||||
d6mID, d6mRule := makeRule(t, &parentID, "x.6months", 6, "months")
|
||||
d1yID, d1yRule := makeRule(t, &parentID, "x.1year", 1, "years")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
d14ID: d14Rule, d2wID: d2wRule, d1mID: d1mRule, d6mID: d6mRule, d1yID: d1yRule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(d6mID, "x.6months"),
|
||||
makeDeadline(d1yID, "x.1year"),
|
||||
makeDeadline(d2wID, "x.2weeks"),
|
||||
makeDeadline(d14ID, "x.14days"),
|
||||
makeDeadline(d1mID, "x.1month"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
want := []string{"x.14days", "x.2weeks", "x.1month", "x.6months", "x.1year"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder
|
||||
// guards the hard rule: rules with different parents must keep their
|
||||
// relative position. Sorting only ever permutes adjacent same-parent
|
||||
// rows.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder(t *testing.T) {
|
||||
parentAID := uuid.New()
|
||||
parentBID := uuid.New()
|
||||
|
||||
a3mID, a3mRule := makeRule(t, &parentAID, "ga.3months", 3, "months")
|
||||
b1mID, b1mRule := makeRule(t, &parentBID, "gb.1month", 1, "months")
|
||||
a14dID, a14dRule := makeRule(t, &parentAID, "ga.14days", 14, "days")
|
||||
b2mID, b2mRule := makeRule(t, &parentBID, "gb.2months", 2, "months")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
a3mID: a3mRule, b1mID: b1mRule, a14dID: a14dRule, b2mID: b2mRule,
|
||||
}
|
||||
|
||||
// Interleaved groups: A, B, A, B. Each group has one rule between
|
||||
// each other group's rules — the consecutive-run walk should treat
|
||||
// each as its own one-element run and not reorder anything.
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(a3mID, "ga.3months"),
|
||||
makeDeadline(b1mID, "gb.1month"),
|
||||
makeDeadline(a14dID, "ga.14days"),
|
||||
makeDeadline(b2mID, "gb.2months"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
want := []string{"ga.3months", "gb.1month", "ga.14days", "gb.2months"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q (interleaved groups must not reorder across)", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast asserts
|
||||
// that court-set / conditional rows (no concrete date in the duration
|
||||
// ladder) sort LAST within their group, regardless of their stated
|
||||
// duration value.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast(t *testing.T) {
|
||||
parentID := uuid.New()
|
||||
|
||||
dID, dRule := makeRule(t, &parentID, "x.duration", 2, "months")
|
||||
cID, cRule := makeRule(t, &parentID, "x.conditional", 1, "months")
|
||||
csID, csRule := makeRule(t, &parentID, "x.courtset", 1, "months")
|
||||
d2ID, d2Rule := makeRule(t, &parentID, "x.short", 14, "days")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
dID: dRule, cID: cRule, csID: csRule, d2ID: d2Rule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
{RuleID: cID.String(), Code: "x.conditional", IsConditional: true},
|
||||
{RuleID: dID.String(), Code: "x.duration"},
|
||||
{RuleID: csID.String(), Code: "x.courtset", IsCourtSet: true},
|
||||
{RuleID: d2ID.String(), Code: "x.short"},
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// Concrete rows first (sorted by duration): x.short (14d) then
|
||||
// x.duration (2mo). Then the two no-date rows, tiebroken by code:
|
||||
// x.conditional < x.courtset alphabetically.
|
||||
want := []string{"x.short", "x.duration", "x.conditional", "x.courtset"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged guards
|
||||
// the root-rule exception: top-level rules (parent_id=nil, no
|
||||
// trigger_event_id) must never be sorted against each other — they
|
||||
// represent distinct anchor points (SoC vs oral hearing vs decision)
|
||||
// whose proceeding-sequence order is non-negotiable.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged(t *testing.T) {
|
||||
rootSoCID, rootSoCRule := makeRule(t, nil, "x.soc", 0, "months")
|
||||
rootOralID, rootOralRule := makeRule(t, nil, "x.oral", 0, "months")
|
||||
rootDecID, rootDecRule := makeRule(t, nil, "x.decision", 0, "months")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
rootSoCID: rootSoCRule, rootOralID: rootOralRule, rootDecID: rootDecRule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(rootSoCID, "x.soc"),
|
||||
makeDeadline(rootOralID, "x.oral"),
|
||||
makeDeadline(rootDecID, "x.decision"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// Roots must keep their input order — they're not in the same
|
||||
// trigger group as each other.
|
||||
want := []string{"x.soc", "x.oral", "x.decision"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q (roots must not be sorted against each other)", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,3 +450,182 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
|
||||
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-289: rules anchored on uncertain triggers must render as
|
||||
// conditional (IsConditional=true, empty DueDate, ParentRule* populated)
|
||||
// rather than fabricating a date off the trigger.
|
||||
//
|
||||
// Three pillars from the issue:
|
||||
// - Symptom A: R.109(1) Antrag auf Simultanübersetzung (timing='before',
|
||||
// parent=Mündliche Verhandlung which is court-set). Pre-fix the rule
|
||||
// computed a meaningless "1 month before today" because sequence_order
|
||||
// places translation_request (45) before oral (50), so the parent
|
||||
// hadn't been classified as court-set yet. The new pre-pass in
|
||||
// Calculate seeds courtSet from is_court_set=true on the data, so
|
||||
// order-of-evaluation no longer matters.
|
||||
// - R.118(4) cons_orders (parent=Entscheidung, court-set) — already
|
||||
// worked via the legacy IsCourtSetIndirect path; assertion ensures
|
||||
// the new IsConditional flag rides alongside it.
|
||||
// - Symptom B: R.262(2) confidentiality_response (priority='optional',
|
||||
// primary_party='both', parent=SoC which is the trigger anchor).
|
||||
// The data-model parent is "always certain" but the real triggering
|
||||
// event (opposing party's confidentiality motion) sits outside the
|
||||
// rule data — render conditional until the user anchors the rule.
|
||||
func TestUIDeadline_IsConditional_UncertainAnchors(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
holidays := NewHolidayService(pool)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
svc := NewFristenrechnerService(rules, holidays, courts)
|
||||
|
||||
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := map[string]UIDeadline{}
|
||||
for _, d := range resp.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
code string
|
||||
wantConditional bool
|
||||
wantParentCode string
|
||||
}{
|
||||
// Symptom A — backward-anchored on the court-set oral hearing.
|
||||
// Pre-pass fix: order-of-evaluation no longer matters. These
|
||||
// rules have no trigger_event_id, so ParentRuleCode stays on
|
||||
// the parent_id-derived value.
|
||||
{"upc.inf.cfi.translation_request", true, "upc.inf.cfi.oral"},
|
||||
{"upc.inf.cfi.interpreter_cost", true, "upc.inf.cfi.oral"},
|
||||
// R.118(4) chain — parent=decision (court-set). No trigger_event_id.
|
||||
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
|
||||
// Symptom B — optional + both, data-model parent is SoC but the
|
||||
// real trigger is the opposing party's confidentiality application.
|
||||
// m/paliad#126 / t-paliad-294: ParentRuleCode now reflects the
|
||||
// trigger_events catalog row (id=25), NOT the parent_id chain.
|
||||
{"upc.inf.cfi.confidentiality_response", true, "application_to_request_confidentiality_from_the_public"},
|
||||
// Negative control — mandatory rule anchored on SoC must keep
|
||||
// its concrete date (no IsConditional, real DueDate). No
|
||||
// trigger_event_id, so parent_id-derived code stays.
|
||||
{"upc.inf.cfi.sod", false, "upc.inf.cfi.soc"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.code, func(t *testing.T) {
|
||||
d, ok := byCode[c.code]
|
||||
if !ok {
|
||||
t.Fatalf("rule %s missing from response", c.code)
|
||||
}
|
||||
if d.IsConditional != c.wantConditional {
|
||||
t.Errorf("IsConditional = %v, want %v", d.IsConditional, c.wantConditional)
|
||||
}
|
||||
if c.wantConditional {
|
||||
if d.DueDate != "" {
|
||||
t.Errorf("DueDate = %q, want empty (conditional)", d.DueDate)
|
||||
}
|
||||
if d.ParentRuleCode != c.wantParentCode {
|
||||
t.Errorf("ParentRuleCode = %q, want %q", d.ParentRuleCode, c.wantParentCode)
|
||||
}
|
||||
if d.ParentRuleName == "" {
|
||||
t.Errorf("ParentRuleName empty for conditional rule")
|
||||
}
|
||||
} else {
|
||||
if d.DueDate == "" {
|
||||
t.Errorf("non-conditional rule has empty DueDate")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// m/paliad#126 / t-paliad-294: the conditional chip for R.262(2)
|
||||
// reads from the trigger_events catalog (id=25), so the user sees
|
||||
// the actual semantic anchor instead of the parent_id-derived
|
||||
// "Klageerhebung". Pin the exact DE + EN strings so a future
|
||||
// rename of the catalog row surfaces here.
|
||||
t.Run("R.262(2) conditional label uses trigger_event_id, not parent_id", func(t *testing.T) {
|
||||
d, ok := byCode["upc.inf.cfi.confidentiality_response"]
|
||||
if !ok {
|
||||
t.Fatalf("confidentiality_response missing from response")
|
||||
}
|
||||
const wantNameDE = "Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit"
|
||||
const wantNameEN = "Application to request confidentiality from the public"
|
||||
if d.ParentRuleName != wantNameDE {
|
||||
t.Errorf("ParentRuleName = %q, want %q (trigger_events.name_de for id=25)", d.ParentRuleName, wantNameDE)
|
||||
}
|
||||
if d.ParentRuleNameEN != wantNameEN {
|
||||
t.Errorf("ParentRuleNameEN = %q, want %q (trigger_events.name for id=25)", d.ParentRuleNameEN, wantNameEN)
|
||||
}
|
||||
// Negative guard — neither label should leak the SoC ("Klageerhebung"),
|
||||
// which is the regression the fix exists to prevent.
|
||||
if d.ParentRuleName == "Klageerhebung" || d.ParentRuleNameEN == "Statement of Claim" {
|
||||
t.Errorf("conditional label still resolves via parent_id (SoC); fix regressed")
|
||||
}
|
||||
})
|
||||
|
||||
// Generalisation guard — translations_lodge also carries a real
|
||||
// trigger_event_id (113 = judge-rapporteur's order). Its
|
||||
// conditional chip should reference the order, not its parent_id
|
||||
// (Zwischenverfahren). Locks in the "any rule with trigger_event_id
|
||||
// uses THAT, not parent_id" contract from m/paliad#126.
|
||||
t.Run("translations_lodge conditional label uses trigger_event_id", func(t *testing.T) {
|
||||
d, ok := byCode["upc.inf.cfi.translations_lodge"]
|
||||
if !ok {
|
||||
t.Skip("upc.inf.cfi.translations_lodge missing from response — data drift?")
|
||||
}
|
||||
if !d.IsConditional {
|
||||
t.Skipf("translations_lodge IsConditional=false in current corpus; trigger-event override is only user-visible on conditional rows. Skip but keep the generalisation guard.")
|
||||
}
|
||||
if d.ParentRuleName == "Zwischenverfahren" {
|
||||
t.Errorf("translations_lodge still labelled via parent_id (Zwischenverfahren); should follow trigger_event_id=113")
|
||||
}
|
||||
if d.ParentRuleCode != "order_of_the_judge_rapporteur_to_lodge_translations" {
|
||||
t.Errorf("ParentRuleCode = %q, want trigger_events.code for id=113", d.ParentRuleCode)
|
||||
}
|
||||
})
|
||||
|
||||
// Override path: when the user anchors the oral hearing, the
|
||||
// backward-anchored R.109(1) flips back to a concrete date and
|
||||
// IsConditional clears. This is the click-to-edit unblock.
|
||||
t.Run("override on court-set parent clears IsConditional", func(t *testing.T) {
|
||||
resp2, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{
|
||||
AnchorOverrides: map[string]string{
|
||||
"upc.inf.cfi.oral": "2027-03-01",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate with override: %v", err)
|
||||
}
|
||||
var tr UIDeadline
|
||||
for _, d := range resp2.Deadlines {
|
||||
if d.Code == "upc.inf.cfi.translation_request" {
|
||||
tr = d
|
||||
break
|
||||
}
|
||||
}
|
||||
if tr.IsConditional {
|
||||
t.Errorf("translation_request IsConditional=true after oral override; want false")
|
||||
}
|
||||
if tr.DueDate == "" {
|
||||
t.Errorf("translation_request DueDate empty after oral override")
|
||||
}
|
||||
// 1 month before 2027-03-01 = ~2027-02-01 (with weekend bump).
|
||||
if tr.DueDate < "2027-01-25" || tr.DueDate > "2027-02-05" {
|
||||
t.Errorf("translation_request DueDate=%q not within expected 2027-01-25..2027-02-05 window", tr.DueDate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -189,6 +189,25 @@ func (s *HolidayService) IsNonWorkingDay(date time.Time, country, regime string)
|
||||
return h != nil && h.IsClosure
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDaysBackward is the symmetric counterpart of
|
||||
// AdjustForNonWorkingDays: walks the date *backward* day-by-day until it
|
||||
// lands on a working day for the given (country, regime). Used for
|
||||
// timing='before' rules (e.g. UPC R.109.1 "no later than 1 month before
|
||||
// the oral hearing") — when the computed cut-off lands on a weekend or
|
||||
// public holiday, the lawyer must finish *earlier*, not later. Forward
|
||||
// snap would push the cut-off past the statutory limit and cause the
|
||||
// step to be filed too late. Bound by the same 60-iter cap as the
|
||||
// forward variant.
|
||||
func (s *HolidayService) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
|
||||
original = date
|
||||
adjusted = date
|
||||
for i := 0; i < 60 && s.IsNonWorkingDay(adjusted, country, regime); i++ {
|
||||
adjusted = adjusted.AddDate(0, 0, -1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
return adjusted, original, wasAdjusted
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDays moves the date forward to the next working day for
|
||||
// the given (country, regime). Returns adjusted date, the original
|
||||
// (unmodified) date, and whether any adjustment was made.
|
||||
|
||||
@@ -38,6 +38,59 @@ type CreatePartyInput struct {
|
||||
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
|
||||
}
|
||||
|
||||
// PartySearchHit is one row of the cross-project party search — a real
|
||||
// paliad.parties row enriched with the parent project's title and
|
||||
// reference so the picker can render context the lawyer needs to
|
||||
// disambiguate identically-named parties on different cases
|
||||
// (t-paliad-287).
|
||||
type PartySearchHit struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
ProjectTitle string `db:"project_title" json:"project_title"`
|
||||
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Role *string `db:"role" json:"role,omitempty"`
|
||||
Representative *string `db:"representative" json:"representative,omitempty"`
|
||||
}
|
||||
|
||||
// Search returns parties from every project the caller can see, matched
|
||||
// by case-insensitive substring on name OR representative. Empty query
|
||||
// returns the 20 most recently-updated parties so the picker isn't
|
||||
// blank on first open. Capped at 25 rows; the frontend doesn't paginate
|
||||
// (the typical PA looks for one party they remember by name, not browses).
|
||||
//
|
||||
// Visibility is enforced inline via visibilityPredicatePositional —
|
||||
// invisible projects' parties never surface in the result set.
|
||||
func (s *PartyService) Search(ctx context.Context, userID uuid.UUID, query string, limit int) ([]PartySearchHit, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 25
|
||||
}
|
||||
q := strings.TrimSpace(query)
|
||||
args := []any{userID}
|
||||
conds := []string{visibilityPredicatePositional("p", 1)}
|
||||
if q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conds = append(conds,
|
||||
fmt.Sprintf(`(pa.name ILIKE $%d OR COALESCE(pa.representative,'') ILIKE $%d)`,
|
||||
len(args), len(args)))
|
||||
}
|
||||
args = append(args, limit)
|
||||
sqlStr := `
|
||||
SELECT pa.id, pa.project_id, p.title AS project_title,
|
||||
p.reference AS project_reference,
|
||||
pa.name, pa.role, pa.representative
|
||||
FROM paliad.parties pa
|
||||
JOIN paliad.projects p ON p.id = pa.project_id
|
||||
WHERE ` + strings.Join(conds, " AND ") + `
|
||||
ORDER BY pa.updated_at DESC
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args))
|
||||
hits := []PartySearchHit{}
|
||||
if err := s.db.SelectContext(ctx, &hits, sqlStr, args...); err != nil {
|
||||
return nil, fmt.Errorf("search parties: %w", err)
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// ListForProject returns all Parties for the Project, visibility-checked.
|
||||
func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
|
||||
@@ -97,6 +97,58 @@ func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-289: conditional rows (Status="conditional", Date=nil) must
|
||||
// pass through applyLookaheadCap untouched — they're not "future
|
||||
// predicted" rows by either Status or Date semantics, so they belong in
|
||||
// the pass-through bucket alongside court_set / undated rows. The cap
|
||||
// must NOT consume one of its slots for a conditional row, and the
|
||||
// row must survive even when projTotal exceeds the cap.
|
||||
func TestApplyLookaheadCap_ConditionalRowsPassThrough(t *testing.T) {
|
||||
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
rows := []TimelineEvent{
|
||||
// Three predicted future — cap=2 means the third drops.
|
||||
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "f1", Title: "F1"},
|
||||
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "f2", Title: "F2"},
|
||||
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "f3", Title: "F3"},
|
||||
// Two conditional — must survive uncapped, must NOT count
|
||||
// against projTotal / projShown.
|
||||
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c1", Title: "C1",
|
||||
DependsOnRuleCode: "p1", DependsOnRuleName: "Parent 1"},
|
||||
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c2", Title: "C2",
|
||||
DependsOnRuleCode: "p2", DependsOnRuleName: "Parent 2"},
|
||||
}
|
||||
|
||||
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
|
||||
if total != 3 {
|
||||
t.Errorf("ProjectedTotal = %d, want 3 (conditionals must not count)", total)
|
||||
}
|
||||
if shown != 2 {
|
||||
t.Errorf("ProjectedShown = %d, want 2", shown)
|
||||
}
|
||||
if overdue != 0 {
|
||||
t.Errorf("PredictedOverdue = %d, want 0", overdue)
|
||||
}
|
||||
// 2 predicted (capped) + 2 conditional pass-through = 4 rows.
|
||||
if len(kept) != 4 {
|
||||
t.Errorf("kept rows = %d, want 4", len(kept))
|
||||
}
|
||||
keptTitles := map[string]bool{}
|
||||
for _, r := range kept {
|
||||
keptTitles[r.Title] = true
|
||||
}
|
||||
for _, want := range []string{"F1", "F2", "C1", "C2"} {
|
||||
if !keptTitles[want] {
|
||||
t.Errorf("expected kept row %q missing", want)
|
||||
}
|
||||
}
|
||||
if keptTitles["F3"] {
|
||||
t.Errorf("F3 should have been dropped (cap=2)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleAnchorKind(t *testing.T) {
|
||||
hearing := "hearing"
|
||||
decision := "decision"
|
||||
|
||||
@@ -147,6 +147,17 @@ type TimelineEvent struct {
|
||||
// checkbox). At parent-node levels, rows with BubbleUp=true survive
|
||||
// the levelPolicy kind/status filter unconditionally.
|
||||
BubbleUp bool `json:"bubble_up,omitempty"`
|
||||
|
||||
// IsConditional marks projected rows whose anchor is uncertain —
|
||||
// the projection layer mirrors UIDeadline.IsConditional from the
|
||||
// fristenrechner so the SmartTimeline can render an "abhängig von
|
||||
// <parent>" chip in place of the date column. When true, Date is
|
||||
// nil and DependsOnRuleCode / DependsOnRuleName carry the parent
|
||||
// reference (already populated by annotateDependsOn for projected
|
||||
// rows; for conditional rows we additionally fall back to the
|
||||
// UIDeadline-supplied ParentRule* when the parent has no
|
||||
// computed date). Status is set to "conditional". (t-paliad-289)
|
||||
IsConditional bool `json:"is_conditional,omitempty"`
|
||||
}
|
||||
|
||||
// LaneInfo describes one column in the parent-node aggregated view.
|
||||
@@ -933,12 +944,13 @@ func (s *ProjectionService) computeProjections(
|
||||
Title: ruleDisplayName(rule, ui, lang(opts.Lang)),
|
||||
RuleCode: ui.Code,
|
||||
DeadlineRuleParty: ui.Party,
|
||||
IsConditional: ui.IsConditional,
|
||||
}
|
||||
idCopy := ruleID
|
||||
ev.DeadlineRuleID = &idCopy
|
||||
|
||||
// Date — UIDeadline.DueDate is YYYY-MM-DD when set, "" for
|
||||
// court-set rules whose date isn't bound yet.
|
||||
// court-set / conditional rules whose date isn't bound yet.
|
||||
if ui.DueDate != "" {
|
||||
if t, perr := time.Parse("2006-01-02", ui.DueDate); perr == nil {
|
||||
dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
@@ -946,7 +958,38 @@ func (s *ProjectionService) computeProjections(
|
||||
}
|
||||
}
|
||||
|
||||
// Conditional rows from the fristenrechner (t-paliad-289):
|
||||
// pre-stamp the dependency reference here so the row carries
|
||||
// the "abhängig von <parent>" payload even when the parent has
|
||||
// no computed date for annotateDependsOn to pick up later.
|
||||
// annotateDependsOn won't overwrite a non-empty DependsOnRuleCode,
|
||||
// and the parent's actual date (if anchored elsewhere) still
|
||||
// flows into DependsOnDate via the actuals-first preference.
|
||||
if ui.IsConditional && ui.ParentRuleCode != "" {
|
||||
ev.DependsOnRuleCode = ui.ParentRuleCode
|
||||
switch lang(opts.Lang) {
|
||||
case "en":
|
||||
if ui.ParentRuleNameEN != "" {
|
||||
ev.DependsOnRuleName = ui.ParentRuleNameEN
|
||||
} else {
|
||||
ev.DependsOnRuleName = ui.ParentRuleName
|
||||
}
|
||||
default:
|
||||
ev.DependsOnRuleName = ui.ParentRuleName
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case ui.IsConditional:
|
||||
// Anchor uncertain (court-set ancestor without override,
|
||||
// backward-anchor without forward date, or optional event
|
||||
// not recorded). Surface as conditional so the frontend
|
||||
// renders "abhängig von <parent>" in place of a date.
|
||||
// Conditional rows must not carry a date even if the
|
||||
// calculator left one — clear it to match the wire contract.
|
||||
// (t-paliad-289)
|
||||
ev.Date = nil
|
||||
ev.Status = "conditional"
|
||||
case ui.IsCourtSet && ev.Date == nil:
|
||||
// Pure court-set rule — date is bound by the court at
|
||||
// hearing/decision time. Surface as undated court_set.
|
||||
|
||||
@@ -604,92 +604,6 @@ func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ExportMigrationsSince returns a SQL blob containing one UPDATE / INSERT
|
||||
// per audited rule change after the given audit row id. Used by the
|
||||
// admin "export changes to a migration file" flow (Q-H-5: pure SQL
|
||||
// format). Returns SQL + count + the latest audit id seen so the
|
||||
// caller can pass it as ?since= on the next call.
|
||||
//
|
||||
// v1 generates one UPDATE per audit row using the after_json snapshot.
|
||||
// Slice 11b will polish the output (re-order so foreign-key edges
|
||||
// resolve, collapse consecutive UPDATEs on the same row, format the
|
||||
// header comment with author + reason). v1 emits one statement per
|
||||
// audit row in chronological order — sufficient for hand-review.
|
||||
type ExportResult struct {
|
||||
MigrationSQL string `json:"migration_sql"`
|
||||
Count int `json:"count"`
|
||||
LatestAuditID string `json:"latest_audit_id"`
|
||||
}
|
||||
|
||||
func (s *RuleEditorService) ExportMigrationsSince(ctx context.Context, sinceAuditID string) (*ExportResult, error) {
|
||||
type auditRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
RuleID uuid.UUID `db:"rule_id"`
|
||||
ChangedAt time.Time `db:"changed_at"`
|
||||
Action string `db:"action"`
|
||||
AfterJSON json.RawMessage `db:"after_json"`
|
||||
Reason string `db:"reason"`
|
||||
}
|
||||
var rows []auditRow
|
||||
q := `SELECT id, rule_id, changed_at, action, after_json, reason
|
||||
FROM paliad.deadline_rule_audit
|
||||
WHERE migration_exported = false`
|
||||
args := []any{}
|
||||
if sinceAuditID != "" {
|
||||
sid, err := uuid.Parse(sinceAuditID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid since= uuid", ErrInvalidInput)
|
||||
}
|
||||
q += ` AND changed_at >= (SELECT changed_at FROM paliad.deadline_rule_audit WHERE id = $1)`
|
||||
args = append(args, sid)
|
||||
}
|
||||
q += ` ORDER BY changed_at ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list audit since: %w", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("-- Auto-generated rule-editor migration export.\n")
|
||||
sb.WriteString("-- Generated at: " + time.Now().UTC().Format(time.RFC3339) + "\n")
|
||||
sb.WriteString("-- Rows: " + fmt.Sprintf("%d", len(rows)) + "\n\n")
|
||||
sb.WriteString("SELECT set_config('paliad.audit_reason',\n")
|
||||
sb.WriteString(" 'rule-editor export: replay of " + fmt.Sprintf("%d", len(rows)) + " edits', true);\n\n")
|
||||
|
||||
latest := ""
|
||||
for _, r := range rows {
|
||||
sb.WriteString("-- audit " + r.ID.String() + " (" + r.Action + " " + r.ChangedAt.Format(time.RFC3339) + "): " + sqlEscape(r.Reason) + "\n")
|
||||
switch r.Action {
|
||||
case "create", "update":
|
||||
if len(r.AfterJSON) == 0 {
|
||||
sb.WriteString("-- (no after_json — skipped)\n\n")
|
||||
continue
|
||||
}
|
||||
sb.WriteString("INSERT INTO paliad.deadline_rules\n")
|
||||
sb.WriteString(" SELECT (jsonb_populate_record(NULL::paliad.deadline_rules, '")
|
||||
sb.WriteString(sqlEscape(string(r.AfterJSON)))
|
||||
sb.WriteString("'::jsonb)).*\n")
|
||||
sb.WriteString("ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, name_en = EXCLUDED.name_en,\n")
|
||||
sb.WriteString(" duration_value = EXCLUDED.duration_value, duration_unit = EXCLUDED.duration_unit,\n")
|
||||
sb.WriteString(" timing = EXCLUDED.timing, priority = EXCLUDED.priority,\n")
|
||||
sb.WriteString(" is_court_set = EXCLUDED.is_court_set,\n")
|
||||
sb.WriteString(" condition_expr = EXCLUDED.condition_expr,\n")
|
||||
sb.WriteString(" lifecycle_state = EXCLUDED.lifecycle_state,\n")
|
||||
sb.WriteString(" updated_at = now();\n\n")
|
||||
case "delete", "archive":
|
||||
sb.WriteString("UPDATE paliad.deadline_rules SET lifecycle_state='archived', updated_at=now() WHERE id='")
|
||||
sb.WriteString(r.RuleID.String())
|
||||
sb.WriteString("';\n\n")
|
||||
}
|
||||
latest = r.ID.String()
|
||||
}
|
||||
|
||||
return &ExportResult{
|
||||
MigrationSQL: sb.String(),
|
||||
Count: len(rows),
|
||||
LatestAuditID: latest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internal helpers
|
||||
// =============================================================================
|
||||
@@ -814,6 +728,3 @@ func nullableJSON(b json.RawMessage) any {
|
||||
return []byte(b)
|
||||
}
|
||||
|
||||
func sqlEscape(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
|
||||
79
internal/services/submission_draft_language_test.go
Normal file
79
internal/services/submission_draft_language_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package services
|
||||
|
||||
// Regression tests for the per-draft language column (t-paliad-276).
|
||||
// The draft's `language` value drives both the placeholder-bag
|
||||
// language pick (`procedural_event.name` → name_de vs name_en) and the
|
||||
// template-variant lookup (`{code}.{lang}.docx` fallback chain). These
|
||||
// tests pin the pure-function pieces — Build wiring needs DB fixtures
|
||||
// and lives in the handler-layer smoke path.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func TestNormalizeDraftLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"de", "de"},
|
||||
{"DE", "de"},
|
||||
{" de ", "de"},
|
||||
{"en", "en"},
|
||||
{"EN", "en"},
|
||||
{" en ", "en"},
|
||||
{"fr", "de"}, // unknown collapses to de (the CHECK-allowed default)
|
||||
{"", "de"},
|
||||
{"english", "de"}, // strict — only the canonical two-letter code is accepted
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeDraftLanguage(c.in); got != c.want {
|
||||
t.Errorf("normalizeDraftLanguage(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The placeholder bag picks the language-matched value for the
|
||||
// canonical (procedural_event.name) and legacy (rule.name) keys based
|
||||
// on the lang argument. This pins the wiring used by Build when a
|
||||
// draft's language overrides the user's UI lang (t-paliad-276).
|
||||
func TestAddRuleVars_LanguageSelectsMatchedName(t *testing.T) {
|
||||
t.Parallel()
|
||||
code := "de.inf.lg.erwidg"
|
||||
rule := &models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
SubmissionCode: &code,
|
||||
Name: "Klageerwiderung",
|
||||
NameEN: "Statement of Defence",
|
||||
}
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
bag := PlaceholderMap{}
|
||||
addRuleVars(bag, rule, lang)
|
||||
want := rule.Name
|
||||
if strings.EqualFold(lang, "en") {
|
||||
want = rule.NameEN
|
||||
}
|
||||
if got := bag["procedural_event.name"]; got != want {
|
||||
t.Errorf("lang=%s: procedural_event.name = %q, want %q", lang, got, want)
|
||||
}
|
||||
if got := bag["rule.name"]; got != want {
|
||||
t.Errorf("lang=%s: rule.name = %q, want %q (legacy alias must mirror canonical)", lang, got, want)
|
||||
}
|
||||
// The explicit *_de / *_en keys never change — both are always
|
||||
// emitted so a template can pin one regardless of the draft's
|
||||
// language. Regression guard against accidentally
|
||||
// language-gating the explicit variants.
|
||||
if bag["procedural_event.name_de"] != rule.Name {
|
||||
t.Errorf("lang=%s: procedural_event.name_de = %q, want %q", lang, bag["procedural_event.name_de"], rule.Name)
|
||||
}
|
||||
if bag["procedural_event.name_en"] != rule.NameEN {
|
||||
t.Errorf("lang=%s: procedural_event.name_en = %q, want %q", lang, bag["procedural_event.name_en"], rule.NameEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
@@ -42,20 +43,33 @@ import (
|
||||
// parties / deadline state to resolve). All callers must check for nil
|
||||
// before treating it as a uuid.
|
||||
type SubmissionDraft struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
SubmissionCode string `db:"submission_code" json:"submission_code"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
VariablesRaw []byte `db:"variables" json:"-"`
|
||||
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
SubmissionCode string `db:"submission_code" json:"submission_code"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
// Language is the output language for the generated .docx — 'de' or
|
||||
// 'en'. Drives the template-variant lookup ({code}.{lang}.docx
|
||||
// fallback chain) and language-aware variable resolution
|
||||
// ({{procedural_event.name}} → name_de or name_en). t-paliad-276.
|
||||
Language string `db:"language" json:"language"`
|
||||
VariablesRaw []byte `db:"variables" json:"-"`
|
||||
SelectedPartiesRaw pq.StringArray `db:"selected_parties" json:"-"`
|
||||
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
|
||||
LastImportedAt *time.Time `db:"last_imported_at" json:"last_imported_at,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// Variables is the decoded overrides map; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
Variables PlaceholderMap `json:"variables"`
|
||||
|
||||
// SelectedParties is the parsed uuid form of SelectedPartiesRaw —
|
||||
// populated on read by decodeSelectedParties(). An empty slice keeps
|
||||
// the backward-compat "include every party" behaviour; a non-empty
|
||||
// slice restricts the variable bag to the listed paliad.parties rows.
|
||||
SelectedParties []uuid.UUID `json:"selected_parties"`
|
||||
}
|
||||
|
||||
// SubmissionDraftService handles CRUD on submission_drafts and exposes
|
||||
@@ -94,6 +108,15 @@ type DraftPatch struct {
|
||||
Name *string
|
||||
Variables *PlaceholderMap
|
||||
ProjectID **uuid.UUID
|
||||
|
||||
// SelectedParties: nil = no change. A non-nil pointer always writes
|
||||
// the column; pass *p = nil or an empty slice to reset to "include
|
||||
// every party on the project" (the backward-compat default).
|
||||
SelectedParties *[]uuid.UUID
|
||||
|
||||
// Language sets the output language. Valid values: "de", "en".
|
||||
// Anything else returns ErrInvalidInput. t-paliad-276.
|
||||
Language *string
|
||||
}
|
||||
|
||||
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
|
||||
@@ -106,8 +129,10 @@ var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already tak
|
||||
|
||||
// draftColumns is the canonical select list — kept in one place so
|
||||
// every fetch stays in sync.
|
||||
const draftColumns = `id, project_id, submission_code, user_id, name,
|
||||
variables, last_exported_at, last_exported_sha,
|
||||
const draftColumns = `id, project_id, submission_code, user_id, name, language,
|
||||
variables, selected_parties,
|
||||
last_exported_at, last_exported_sha,
|
||||
last_imported_at,
|
||||
created_at, updated_at`
|
||||
|
||||
// List returns every draft for (project, submission_code, user)
|
||||
@@ -127,7 +152,7 @@ func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uui
|
||||
return nil, fmt.Errorf("list submission drafts: %w", err)
|
||||
}
|
||||
for i := range rows {
|
||||
if err := rows[i].decodeVariables(); err != nil {
|
||||
if err := rows[i].decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -157,8 +182,9 @@ type DraftWithProject struct {
|
||||
func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid.UUID) ([]DraftWithProject, error) {
|
||||
var rows []DraftWithProject
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name,
|
||||
d.variables, d.last_exported_at, d.last_exported_sha,
|
||||
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
|
||||
d.variables, d.selected_parties,
|
||||
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
|
||||
d.created_at, d.updated_at,
|
||||
p.title AS project_title,
|
||||
p.reference AS project_reference
|
||||
@@ -175,7 +201,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
|
||||
return nil, fmt.Errorf("list all submission drafts for user: %w", err)
|
||||
}
|
||||
for i := range rows {
|
||||
if err := rows[i].decodeVariables(); err != nil {
|
||||
if err := rows[i].decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -213,7 +239,7 @@ func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.U
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
if err := d.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
@@ -241,7 +267,7 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure latest submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
if err := d.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
@@ -263,17 +289,22 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Seed the new draft's output language from the user's UI lang so
|
||||
// the editor opens in the language the lawyer is already working in.
|
||||
// Anything other than "en" normalizes to "de" — matches the DB CHECK
|
||||
// constraint and the project's primary-language default.
|
||||
draftLang := normalizeDraftLanguage(lang)
|
||||
var d SubmissionDraft
|
||||
err = s.db.GetContext(ctx, &d,
|
||||
`INSERT INTO paliad.submission_drafts
|
||||
(project_id, submission_code, user_id, name)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
(project_id, submission_code, user_id, name, language)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING `+draftColumns,
|
||||
projectID, submissionCode, userID, name)
|
||||
projectID, submissionCode, userID, name, draftLang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
if err := d.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
@@ -394,6 +425,28 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.SelectedParties != nil {
|
||||
ids := *patch.SelectedParties
|
||||
strs := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
strs = append(strs, id.String())
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("selected_parties = $%d::uuid[]", idx))
|
||||
args = append(args, pq.StringArray(strs))
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.Language != nil {
|
||||
newLang := strings.ToLower(strings.TrimSpace(*patch.Language))
|
||||
if newLang != "de" && newLang != "en" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("language = $%d", idx))
|
||||
args = append(args, newLang)
|
||||
idx++
|
||||
}
|
||||
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return existing, nil
|
||||
}
|
||||
@@ -415,7 +468,7 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
if err := d.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
@@ -436,6 +489,82 @@ func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uui
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportFromProject re-pulls every project-derived variable on the
|
||||
// draft by stripping the lawyer's overrides for those keys and bumping
|
||||
// `last_imported_at`. Project-derived prefixes today are project.*,
|
||||
// parties.*, deadline.* and (because the rule is keyed on
|
||||
// submission_code) procedural_event.* / rule.*; the lawyer's overrides
|
||||
// for firm.*, today.*, user.* survive because those values aren't
|
||||
// "imported from the project" in any meaningful sense.
|
||||
//
|
||||
// Idempotent on repeat clicks: nothing else mutates on the second
|
||||
// call apart from the new timestamp. The draft must be owned by the
|
||||
// caller (Get() applies the same ErrNotFound semantics as the rest of
|
||||
// the service).
|
||||
func (s *SubmissionDraftService) ImportFromProject(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error) {
|
||||
existing, err := s.Get(ctx, userID, draftID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing.ProjectID == nil {
|
||||
// No project to import from — surface as 400 via ErrInvalidInput.
|
||||
return nil, fmt.Errorf("%w: cannot import from project on a project-less draft", ErrInvalidInput)
|
||||
}
|
||||
|
||||
// Strip overrides that came from project state.
|
||||
cleaned := PlaceholderMap{}
|
||||
for k, v := range existing.Variables {
|
||||
if isProjectDerivedKey(k) {
|
||||
continue
|
||||
}
|
||||
cleaned[k] = v
|
||||
}
|
||||
raw, err := json.Marshal(cleaned)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal variables: %w", err)
|
||||
}
|
||||
|
||||
var d SubmissionDraft
|
||||
err = s.db.GetContext(ctx, &d,
|
||||
`UPDATE paliad.submission_drafts
|
||||
SET variables = $1::jsonb,
|
||||
last_imported_at = now()
|
||||
WHERE id = $2 AND user_id = $3
|
||||
RETURNING `+draftColumns,
|
||||
string(raw), draftID, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionDraftNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("import from project: %w", err)
|
||||
}
|
||||
if err := d.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// isProjectDerivedKey reports whether a placeholder key sources its
|
||||
// value from the project record (rather than firm-wide or user-wide
|
||||
// state). The "Aus Projekt importieren" affordance strips overrides
|
||||
// for exactly these keys so the lawyer's manual edits don't survive
|
||||
// a re-pull.
|
||||
func isProjectDerivedKey(key string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(key, "project."):
|
||||
return true
|
||||
case strings.HasPrefix(key, "parties."):
|
||||
return true
|
||||
case strings.HasPrefix(key, "deadline."):
|
||||
return true
|
||||
case strings.HasPrefix(key, "procedural_event."):
|
||||
return true
|
||||
case strings.HasPrefix(key, "rule."):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MarkExported updates the last_exported_* columns after a successful
|
||||
// export. Background-context safe.
|
||||
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, templateSHA string) error {
|
||||
@@ -461,9 +590,9 @@ func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.
|
||||
//
|
||||
// Override semantics:
|
||||
//
|
||||
// variables[key] = "" → delete the key (force [KEIN WERT: key])
|
||||
// variables[key] = "X" → bag[key] = "X"
|
||||
// key absent → bag[key] unchanged (falls back to resolved value)
|
||||
// variables[key] = "" → delete the key (force [KEIN WERT: key])
|
||||
// variables[key] = "X" → bag[key] = "X"
|
||||
// key absent → bag[key] unchanged (falls back to resolved value)
|
||||
//
|
||||
// Returns the final PlaceholderMap along with the SubmissionVarsResult
|
||||
// so callers (export, file naming) get the resolved entities too. A
|
||||
@@ -473,9 +602,14 @@ func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.
|
||||
// lawyer's overrides fill the rest.
|
||||
func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *SubmissionDraft) (PlaceholderMap, *SubmissionVarsResult, error) {
|
||||
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
|
||||
UserID: draft.UserID,
|
||||
ProjectID: draft.ProjectID,
|
||||
SubmissionCode: draft.SubmissionCode,
|
||||
UserID: draft.UserID,
|
||||
ProjectID: draft.ProjectID,
|
||||
SubmissionCode: draft.SubmissionCode,
|
||||
SelectedParties: draft.SelectedParties,
|
||||
// The draft's language overrides the user's UI lang — the lawyer
|
||||
// can author an EN draft in a DE-UI session and vice versa
|
||||
// (t-paliad-276). Empty / unknown falls back to "de".
|
||||
Lang: normalizeDraftLanguage(draft.Language),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -530,12 +664,13 @@ func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDr
|
||||
// ProjectService.GetByID — callers get ErrNotFound on no-access.
|
||||
// ErrSubmissionRuleNotFound surfaces when no published rule matches the
|
||||
// requested submission_code.
|
||||
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
|
||||
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
|
||||
pid := projectID
|
||||
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
|
||||
UserID: userID,
|
||||
ProjectID: &pid,
|
||||
SubmissionCode: submissionCode,
|
||||
Lang: normalizeDraftLanguage(lang),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -547,8 +682,17 @@ func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, us
|
||||
return out, resolved, nil
|
||||
}
|
||||
|
||||
// decode fills the parsed views (Variables, SelectedParties) from the
|
||||
// raw scan fields. Called by every fetch path so the caller sees both
|
||||
// populated together.
|
||||
func (d *SubmissionDraft) decode() error {
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.decodeSelectedParties()
|
||||
}
|
||||
|
||||
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
|
||||
// Called by every fetch path so the caller sees a populated Variables.
|
||||
func (d *SubmissionDraft) decodeVariables() error {
|
||||
if len(d.VariablesRaw) == 0 {
|
||||
d.Variables = PlaceholderMap{}
|
||||
@@ -562,6 +706,41 @@ func (d *SubmissionDraft) decodeVariables() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeSelectedParties parses the uuid[] payload from pq.StringArray
|
||||
// into []uuid.UUID. Unparseable entries are dropped so a single bad
|
||||
// row never bricks the fetch — the worst case is one extra party
|
||||
// silently dropped from the selection, which surfaces as it not being
|
||||
// rendered in the merged document.
|
||||
func (d *SubmissionDraft) decodeSelectedParties() error {
|
||||
if len(d.SelectedPartiesRaw) == 0 {
|
||||
d.SelectedParties = nil
|
||||
return nil
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(d.SelectedPartiesRaw))
|
||||
for _, s := range d.SelectedPartiesRaw {
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
d.SelectedParties = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeDraftLanguage maps any input to one of the two allowed
|
||||
// language values for paliad.submission_drafts.language. Anything other
|
||||
// than "en" (case-insensitive) collapses to "de" — matches the DB CHECK
|
||||
// constraint, the project's primary-language default, and the seed
|
||||
// behaviour for existing rows that came in before the column existed.
|
||||
func normalizeDraftLanguage(lang string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(lang), "en") {
|
||||
return "en"
|
||||
}
|
||||
return "de"
|
||||
}
|
||||
|
||||
|
||||
// Compile-time guard: ensure the *models.User reference in the import
|
||||
// graph doesn't get optimised away by linters. The service doesn't
|
||||
// dereference User directly — that happens in SubmissionVarsService —
|
||||
|
||||
@@ -327,6 +327,40 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderHTML_WrapsOverriddenValueSameAsResolved is the t-paliad-274
|
||||
// regression: m's report on m/paliad#106 was that "When filled, the link
|
||||
// disappears". The preview HTML must wrap an override value with the
|
||||
// same <span class="draft-var"> as it would an unfilled placeholder, so
|
||||
// the click-jump from preview→sidebar persists after the user types a
|
||||
// value. There is no distinction at the renderer level between a value
|
||||
// that came from the resolved bag (project / parties / deadline lookups)
|
||||
// and a value the lawyer typed into the sidebar — both arrive in the
|
||||
// same PlaceholderMap and both must be wrapped.
|
||||
func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
|
||||
doc := `<w:document><w:body>` +
|
||||
`<w:p><w:r><w:t>{{project.case_number}} / {{firm.name}}</w:t></w:r></w:p>` +
|
||||
`</w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
// project.case_number is the typed-by-lawyer override.
|
||||
// firm.name is the always-resolved value from the firm bag.
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{
|
||||
"project.case_number": "UPC_CFI_42/2026",
|
||||
"firm.name": "HLC",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render html: %v", err)
|
||||
}
|
||||
wantOverride := `<span class="draft-var" data-var="project.case_number">UPC_CFI_42/2026</span>`
|
||||
if !strings.Contains(html, wantOverride) {
|
||||
t.Errorf("expected overridden value wrapped in draft-var span (click-jump must persist after fill, t-paliad-274), got %q", html)
|
||||
}
|
||||
wantResolved := `<span class="draft-var" data-var="firm.name">HLC</span>`
|
||||
if !strings.Contains(html, wantResolved) {
|
||||
t.Errorf("expected resolved value still wrapped, got %q", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_DocxOutputUnchangedByPreviewWrap asserts the hard rule from
|
||||
// t-paliad-261: the .docx export path must NOT carry the preview-only
|
||||
// draft-var sentinels or any draft-var span markup. Renders the same
|
||||
|
||||
@@ -6,17 +6,28 @@ package services
|
||||
//
|
||||
// Variables span six namespaces:
|
||||
//
|
||||
// firm.* process-wide (branding.Name)
|
||||
// user.* caller's user row
|
||||
// today.* server time in Europe/Berlin, locale-aware
|
||||
// project.* paliad.projects + joined proceeding type
|
||||
// parties.* paliad.parties grouped by role
|
||||
// rule.* paliad.deadline_rules row keyed by submission_code
|
||||
// deadline.* next open paliad.deadlines row for (project, rule), if any
|
||||
// firm.* process-wide (branding.Name)
|
||||
// user.* caller's user row
|
||||
// today.* server time in Europe/Berlin, locale-aware
|
||||
// project.* paliad.projects + joined proceeding type
|
||||
// parties.* paliad.parties grouped by role
|
||||
// procedural_event.* paliad.deadline_rules row keyed by submission_code
|
||||
// — the "what kind of step in the proceeding"
|
||||
// identity (Schriftsatz, Anhörung, Entscheidung,
|
||||
// …). See docs/design-procedural-events-model-
|
||||
// 2026-05-25.md (t-paliad-262 Slice A).
|
||||
// rule.* legacy alias for procedural_event.*; emitted
|
||||
// unconditionally for backward compatibility
|
||||
// with Word templates and saved drafts authored
|
||||
// before the rename. @deprecated — new templates
|
||||
// should use the procedural_event.* form.
|
||||
// deadline.* next open paliad.deadlines row for
|
||||
// (project, procedural_event), if any
|
||||
//
|
||||
// Locale handling: every long-form date string is computed in both DE
|
||||
// and EN; the renderer picks based on the user's lang preference. The
|
||||
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
|
||||
// procedural-event pretty-printer (legalSourcePretty) also has DE/EN
|
||||
// variants.
|
||||
//
|
||||
// Visibility: caller passes userID; ProjectService.GetByID enforces
|
||||
// paliad.can_see_project — unauthorised callers get the standard
|
||||
@@ -61,10 +72,24 @@ func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *Pa
|
||||
// ProjectID is optional since t-paliad-243 — a global Schriftsatz draft
|
||||
// started from /submissions/new without picking a project carries
|
||||
// nil here and the project / parties / deadline lookups are skipped.
|
||||
//
|
||||
// SelectedParties is the t-paliad-277 multi-party selection: an empty
|
||||
// or nil slice means "include every party on the project" (the
|
||||
// backward-compat default that every legacy draft renders with); a
|
||||
// non-empty slice restricts the variable bag to the listed parties so
|
||||
// the submission only mentions the chosen subset.
|
||||
type SubmissionVarsContext struct {
|
||||
UserID uuid.UUID
|
||||
ProjectID *uuid.UUID
|
||||
SubmissionCode string
|
||||
UserID uuid.UUID
|
||||
ProjectID *uuid.UUID
|
||||
SubmissionCode string
|
||||
SelectedParties []uuid.UUID
|
||||
// Lang pins the output language for this Build, overriding the
|
||||
// caller's UI preference (user.Lang). When empty, Build falls back
|
||||
// to user.Lang so existing callers (the format-only Slice 1 path)
|
||||
// keep working unchanged. The draft editor passes the per-draft
|
||||
// `language` column (t-paliad-276) so DE/EN can be picked
|
||||
// independently of the UI session.
|
||||
Lang string
|
||||
}
|
||||
|
||||
// SubmissionVarsResult bundles the placeholder map with the lookup
|
||||
@@ -114,7 +139,15 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lang := user.Lang
|
||||
// Per-call Lang override (t-paliad-276) wins over the user's UI
|
||||
// language so the draft editor can render an EN .docx from a DE-UI
|
||||
// session and vice versa. Falls back to the user pref when the
|
||||
// caller didn't specify, preserving the format-only Slice 1
|
||||
// behaviour.
|
||||
lang := strings.ToLower(strings.TrimSpace(in.Lang))
|
||||
if lang != "de" && lang != "en" {
|
||||
lang = user.Lang
|
||||
}
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
@@ -163,7 +196,7 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
}
|
||||
|
||||
addProjectVars(bag, project, pt, lang)
|
||||
addPartyVars(bag, parties)
|
||||
addPartyVars(bag, filterPartiesBySelection(parties, in.SelectedParties))
|
||||
addDeadlineVars(bag, next, project, lang)
|
||||
|
||||
out.Project = project
|
||||
@@ -173,9 +206,36 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadPublishedRule fetches the deadline_rule that owns the given
|
||||
// submission_code. Restricts to lifecycle_state='published' so drafts
|
||||
// never end up shaping a real submission.
|
||||
// filterPartiesBySelection returns the subset of parties whose IDs
|
||||
// appear in selected. An empty or nil `selected` slice is the
|
||||
// backward-compat default — every party flows through unchanged. A
|
||||
// non-empty slice preserves the input ordering of `parties` (which is
|
||||
// stable by name from PartyService.ListForProject) so the bag's
|
||||
// "first claimant / first defendant / first other" picks remain
|
||||
// deterministic for a given project state.
|
||||
func filterPartiesBySelection(parties []models.Party, selected []uuid.UUID) []models.Party {
|
||||
if len(selected) == 0 {
|
||||
return parties
|
||||
}
|
||||
allowed := make(map[uuid.UUID]struct{}, len(selected))
|
||||
for _, id := range selected {
|
||||
allowed[id] = struct{}{}
|
||||
}
|
||||
out := make([]models.Party, 0, len(parties))
|
||||
for _, p := range parties {
|
||||
if _, ok := allowed[p.ID]; ok {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loadPublishedRule fetches the published procedural-event template
|
||||
// (paliad.deadline_rules row) keyed by submission_code. Restricts to
|
||||
// lifecycle_state='published' so drafts never end up shaping a real
|
||||
// submission. Function name retained for Slice A (prose-only); Slice
|
||||
// B renames it to loadPublishedProceduralEvent when the Go type is
|
||||
// renamed (t-paliad-262 §6).
|
||||
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
@@ -310,57 +370,147 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
|
||||
}
|
||||
}
|
||||
|
||||
// addPartyVars populates parties.* using the first row of each role.
|
||||
// Multi-claimant / multi-defendant suits use the first row in Slice 1
|
||||
// per design §13.6; expanded grouping is Phase 2.
|
||||
// addPartyVars populates the parties.* namespace from the (already
|
||||
// filtered) list of parties.
|
||||
//
|
||||
// Three forms coexist per role (claimant / defendant / other) so
|
||||
// templates authored against any of them keep merging correctly:
|
||||
//
|
||||
// - Comma-joined list (t-paliad-277, primary form for multi-party
|
||||
// suits):
|
||||
//
|
||||
// {{parties.claimants}} — all claimants' names
|
||||
// {{parties.claimants.representatives}}
|
||||
// {{parties.defendants}} / .representatives
|
||||
// {{parties.others}} / .representatives
|
||||
//
|
||||
// - Indexed access (templates that need the primary individually):
|
||||
//
|
||||
// {{parties.claimant.0.name}} / .representative
|
||||
// {{parties.defendant.0.name}} / .representative
|
||||
// {{parties.other.0.name}} / .representative
|
||||
//
|
||||
// - Flat legacy (kept forever per the issue's backward-compat
|
||||
// contract; resolves to the FIRST selected party of each role):
|
||||
//
|
||||
// {{parties.claimant.name}} / .representative
|
||||
// {{parties.defendant.name}} / .representative
|
||||
// {{parties.other.name}} / .representative
|
||||
//
|
||||
// Role bucketing matches the prior shape: German strings ("Kläger",
|
||||
// "Beklagte") and their English equivalents fold into claimant /
|
||||
// defendant; everything else (Streithelfer, Patentinhaberin, …) flows
|
||||
// into "other".
|
||||
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
||||
var claimant, defendant, other *models.Party
|
||||
var claimants, defendants, others []models.Party
|
||||
for i := range parties {
|
||||
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
|
||||
switch role {
|
||||
case "claimant", "kläger", "klaeger":
|
||||
if claimant == nil {
|
||||
claimant = &parties[i]
|
||||
}
|
||||
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
|
||||
claimants = append(claimants, parties[i])
|
||||
case "defendant", "beklagter", "beklagte":
|
||||
if defendant == nil {
|
||||
defendant = &parties[i]
|
||||
}
|
||||
defendants = append(defendants, parties[i])
|
||||
default:
|
||||
if other == nil {
|
||||
other = &parties[i]
|
||||
}
|
||||
others = append(others, parties[i])
|
||||
}
|
||||
}
|
||||
if claimant != nil {
|
||||
bag["parties.claimant.name"] = claimant.Name
|
||||
bag["parties.claimant.representative"] = derefString(claimant.Representative)
|
||||
|
||||
emitPartyGroup(bag, "claimant", "claimants", claimants)
|
||||
emitPartyGroup(bag, "defendant", "defendants", defendants)
|
||||
emitPartyGroup(bag, "other", "others", others)
|
||||
}
|
||||
|
||||
// emitPartyGroup writes the three forms (joined list, indexed access,
|
||||
// flat legacy first-of-role) for a single role bucket. `singular` is
|
||||
// the legacy/indexed prefix (claimant / defendant / other); `plural`
|
||||
// is the joined-list prefix (claimants / defendants / others).
|
||||
func emitPartyGroup(bag PlaceholderMap, singular, plural string, group []models.Party) {
|
||||
names := make([]string, 0, len(group))
|
||||
reps := make([]string, 0, len(group))
|
||||
for _, p := range group {
|
||||
names = append(names, p.Name)
|
||||
reps = append(reps, derefString(p.Representative))
|
||||
}
|
||||
if defendant != nil {
|
||||
bag["parties.defendant.name"] = defendant.Name
|
||||
bag["parties.defendant.representative"] = derefString(defendant.Representative)
|
||||
|
||||
bag["parties."+plural] = strings.Join(names, ", ")
|
||||
bag["parties."+plural+".representatives"] = joinNonEmpty(reps, ", ")
|
||||
|
||||
for i, p := range group {
|
||||
idx := fmt.Sprintf("parties.%s.%d", singular, i)
|
||||
bag[idx+".name"] = p.Name
|
||||
bag[idx+".representative"] = derefString(p.Representative)
|
||||
}
|
||||
if other != nil {
|
||||
bag["parties.other.name"] = other.Name
|
||||
bag["parties.other.representative"] = derefString(other.Representative)
|
||||
|
||||
if len(group) > 0 {
|
||||
first := group[0]
|
||||
bag["parties."+singular+".name"] = first.Name
|
||||
bag["parties."+singular+".representative"] = derefString(first.Representative)
|
||||
}
|
||||
}
|
||||
|
||||
// addRuleVars populates rule.* — submission_code, name(_en),
|
||||
// legal_source (+ pretty form), primary_party, event_type.
|
||||
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
|
||||
bag["rule.submission_code"] = derefString(r.SubmissionCode)
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["rule.name"] = r.NameEN
|
||||
} else {
|
||||
bag["rule.name"] = r.Name
|
||||
// joinNonEmpty joins a slice with sep but skips empty entries so a
|
||||
// list of representatives where one party has no representative reads
|
||||
// as "A, B" instead of "A, , B".
|
||||
func joinNonEmpty(parts []string, sep string) string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return strings.Join(out, sep)
|
||||
}
|
||||
|
||||
// addRuleVars populates the procedural-event variable namespace —
|
||||
// code, name(_en), legal_source (+ pretty form), primary_party, kind.
|
||||
//
|
||||
// Two key prefixes are emitted for every value:
|
||||
//
|
||||
// - procedural_event.* — canonical name (t-paliad-262 Slice A,
|
||||
// design docs/design-procedural-events-model-2026-05-25.md).
|
||||
// - rule.* — legacy alias kept forever (m's call,
|
||||
// issue m/paliad#93 Q7); existing Word templates and saved
|
||||
// submission_drafts authored before the rename keep working.
|
||||
//
|
||||
// `procedural_event.event_kind` is the canonical key for the
|
||||
// procedural-event kind (filing|reply|hearing|decision|order). The
|
||||
// legacy `rule.event_type` alias holds the same string. The column
|
||||
// itself stays named `event_type` on `paliad.deadline_rules` — Slice
|
||||
// A is prose-only; the column-level rename to `event_kind` is Slice B.
|
||||
//
|
||||
// Function name stays `addRuleVars` to avoid coupling Slice A to the
|
||||
// Go-type rename which is Slice B (B.5 sub-slice).
|
||||
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
|
||||
code := derefString(r.SubmissionCode)
|
||||
var localizedName string
|
||||
if strings.EqualFold(lang, "en") {
|
||||
localizedName = r.NameEN
|
||||
} else {
|
||||
localizedName = r.Name
|
||||
}
|
||||
legalSource := derefString(r.LegalSource)
|
||||
legalSourcePrettyVal := legalSourcePretty(legalSource, lang)
|
||||
primaryParty := derefString(r.PrimaryParty)
|
||||
eventKind := derefString(r.EventType)
|
||||
|
||||
bag["procedural_event.code"] = code
|
||||
bag["procedural_event.name"] = localizedName
|
||||
bag["procedural_event.name_de"] = r.Name
|
||||
bag["procedural_event.name_en"] = r.NameEN
|
||||
bag["procedural_event.legal_source"] = legalSource
|
||||
bag["procedural_event.legal_source_pretty"] = legalSourcePrettyVal
|
||||
bag["procedural_event.primary_party"] = primaryParty
|
||||
bag["procedural_event.event_kind"] = eventKind
|
||||
|
||||
bag["rule.submission_code"] = code
|
||||
bag["rule.name"] = localizedName
|
||||
bag["rule.name_de"] = r.Name
|
||||
bag["rule.name_en"] = r.NameEN
|
||||
bag["rule.legal_source"] = derefString(r.LegalSource)
|
||||
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
|
||||
bag["rule.primary_party"] = derefString(r.PrimaryParty)
|
||||
bag["rule.event_type"] = derefString(r.EventType)
|
||||
bag["rule.legal_source"] = legalSource
|
||||
bag["rule.legal_source_pretty"] = legalSourcePrettyVal
|
||||
bag["rule.primary_party"] = primaryParty
|
||||
bag["rule.event_type"] = eventKind
|
||||
}
|
||||
|
||||
// addDeadlineVars populates deadline.* from the next pending row. When
|
||||
|
||||
153
internal/services/submission_vars_aliases_test.go
Normal file
153
internal/services/submission_vars_aliases_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package services
|
||||
|
||||
// Regression test for the procedural-event placeholder aliases
|
||||
// (t-paliad-262 Slice A, m/paliad#93 Q7).
|
||||
//
|
||||
// The variable bag emits TWO key prefixes for the procedural-event
|
||||
// namespace:
|
||||
//
|
||||
// - procedural_event.* (canonical, post-rename)
|
||||
// - rule.* (legacy, @deprecated)
|
||||
//
|
||||
// m's lock: keep the legacy aliases forever so lawyer-authored Word
|
||||
// templates and existing paliad.submission_drafts rows that already
|
||||
// contain `{{rule.X}}` keep merging correctly.
|
||||
//
|
||||
// This test pins the contract: every (canonical, legacy) pair must
|
||||
// resolve to the same string in the placeholder map, for every value
|
||||
// of (lang, present-vs-NULL columns). Removing the legacy aliases —
|
||||
// or letting them drift in value from the canonical — must light up
|
||||
// here BEFORE the change can land in main.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func TestAddRuleVars_CanonicalAndLegacyAliasesMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Pairs are (canonical key, legacy key). Order matters only for
|
||||
// the assertion message — the test checks string equality both
|
||||
// ways round.
|
||||
pairs := []struct {
|
||||
canonical string
|
||||
legacy string
|
||||
}{
|
||||
{"procedural_event.code", "rule.submission_code"},
|
||||
{"procedural_event.name", "rule.name"},
|
||||
{"procedural_event.name_de", "rule.name_de"},
|
||||
{"procedural_event.name_en", "rule.name_en"},
|
||||
{"procedural_event.legal_source", "rule.legal_source"},
|
||||
{"procedural_event.legal_source_pretty", "rule.legal_source_pretty"},
|
||||
{"procedural_event.primary_party", "rule.primary_party"},
|
||||
{"procedural_event.event_kind", "rule.event_type"},
|
||||
}
|
||||
|
||||
// Build a fully-populated rule row. Every nullable column has a
|
||||
// distinct non-empty value so missing-value bugs (e.g. the legacy
|
||||
// key copying "" while the canonical key copies the real value)
|
||||
// would surface.
|
||||
code := "dpma.appeal.bgh.begruendung"
|
||||
desc := "Rechtsbeschwerdebegründung — § 102 PatG"
|
||||
party := "both"
|
||||
kind := "filing"
|
||||
legal := "DE.PatG.102"
|
||||
ruleCode := "§ 102 PatG"
|
||||
|
||||
rule := &models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
SubmissionCode: &code,
|
||||
Name: "Rechtsbeschwerdebegründung",
|
||||
NameEN: "Appeal brief",
|
||||
Description: &desc,
|
||||
PrimaryParty: &party,
|
||||
EventType: &kind,
|
||||
LegalSource: &legal,
|
||||
RuleCode: &ruleCode,
|
||||
}
|
||||
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
t.Run(lang, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
bag := PlaceholderMap{}
|
||||
addRuleVars(bag, rule, lang)
|
||||
|
||||
for _, p := range pairs {
|
||||
canonicalVal, canonicalOK := bag[p.canonical]
|
||||
legacyVal, legacyOK := bag[p.legacy]
|
||||
|
||||
if !canonicalOK {
|
||||
t.Errorf("canonical key %q missing from bag (lang=%s); "+
|
||||
"Slice A must emit both forms", p.canonical, lang)
|
||||
}
|
||||
if !legacyOK {
|
||||
t.Errorf("legacy alias %q missing from bag (lang=%s); "+
|
||||
"removing legacy aliases would break existing Word "+
|
||||
"templates that paliad doesn't see — keep the "+
|
||||
"emission per m/paliad#93 Q7", p.legacy, lang)
|
||||
}
|
||||
if canonicalVal != legacyVal {
|
||||
t.Errorf("alias drift: %q=%q vs %q=%q (lang=%s)",
|
||||
p.canonical, canonicalVal,
|
||||
p.legacy, legacyVal, lang)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the localized name actually localizes (the
|
||||
// canonical and legacy `name` keys depend on lang). If
|
||||
// this fails the loop above wouldn't catch it (both keys
|
||||
// would agree on the wrong language).
|
||||
localized := bag["procedural_event.name"]
|
||||
if strings.EqualFold(lang, "en") && localized != rule.NameEN {
|
||||
t.Errorf("expected EN localized name=%q, got %q",
|
||||
rule.NameEN, localized)
|
||||
}
|
||||
if strings.EqualFold(lang, "de") && localized != rule.Name {
|
||||
t.Errorf("expected DE localized name=%q, got %q",
|
||||
rule.Name, localized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRuleVars_NullableFieldsEmitEmptyOnBothPrefixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A minimal rule with every optional column NULL. The bag must
|
||||
// still emit every canonical + legacy key — with the empty
|
||||
// string — so downstream merging produces the standard
|
||||
// "[KEIN WERT: ...]" marker rather than a broken template.
|
||||
rule := &models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "Generic step",
|
||||
NameEN: "Generic step",
|
||||
}
|
||||
|
||||
bag := PlaceholderMap{}
|
||||
addRuleVars(bag, rule, "de")
|
||||
|
||||
mustHave := []string{
|
||||
"procedural_event.code", "rule.submission_code",
|
||||
"procedural_event.legal_source", "rule.legal_source",
|
||||
"procedural_event.legal_source_pretty", "rule.legal_source_pretty",
|
||||
"procedural_event.primary_party", "rule.primary_party",
|
||||
"procedural_event.event_kind", "rule.event_type",
|
||||
}
|
||||
for _, key := range mustHave {
|
||||
val, ok := bag[key]
|
||||
if !ok {
|
||||
t.Errorf("key %q missing from bag even with NULL source column; "+
|
||||
"derefString must materialize the empty string so the "+
|
||||
"merger sees the variable and renders the missing-value "+
|
||||
"marker", key)
|
||||
}
|
||||
if val != "" {
|
||||
t.Errorf("key %q = %q, want \"\" (source column was NULL)", key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
200
internal/services/submission_vars_parties_test.go
Normal file
200
internal/services/submission_vars_parties_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package services
|
||||
|
||||
// Multi-party variable bag tests (t-paliad-277 / m/paliad#109).
|
||||
//
|
||||
// Pins the three coexisting forms that addPartyVars emits per role:
|
||||
//
|
||||
// - Comma-joined list: parties.claimants / .defendants / .others
|
||||
// - Indexed access: parties.claimant.0.name, parties.defendant.0.name, …
|
||||
// - Flat legacy (first-of): parties.claimant.name, parties.defendant.name, …
|
||||
//
|
||||
// Also covers filterPartiesBySelection — the empty-selection default
|
||||
// (every party included) and the non-empty restriction.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func mkParty(name, role, rep string) models.Party {
|
||||
p := models.Party{
|
||||
ID: uuid.New(),
|
||||
Name: name,
|
||||
}
|
||||
if role != "" {
|
||||
r := role
|
||||
p.Role = &r
|
||||
}
|
||||
if rep != "" {
|
||||
r := rep
|
||||
p.Representative = &r
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestAddPartyVars_MultiPartyMixedRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
parties := []models.Party{
|
||||
mkParty("Acme Inc.", "claimant", "Maria Schmidt"),
|
||||
mkParty("Globex GmbH", "claimant", ""),
|
||||
mkParty("Initech", "defendant", "John Doe"),
|
||||
mkParty("Streithelferin", "intervenor", ""),
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
addPartyVars(bag, parties)
|
||||
|
||||
wants := map[string]string{
|
||||
// Comma-joined per role.
|
||||
"parties.claimants": "Acme Inc., Globex GmbH",
|
||||
"parties.claimants.representatives": "Maria Schmidt", // Globex has no rep → skipped from join.
|
||||
"parties.defendants": "Initech",
|
||||
"parties.defendants.representatives": "John Doe",
|
||||
"parties.others": "Streithelferin",
|
||||
"parties.others.representatives": "",
|
||||
// Indexed access.
|
||||
"parties.claimant.0.name": "Acme Inc.",
|
||||
"parties.claimant.0.representative": "Maria Schmidt",
|
||||
"parties.claimant.1.name": "Globex GmbH",
|
||||
"parties.claimant.1.representative": "",
|
||||
"parties.defendant.0.name": "Initech",
|
||||
"parties.defendant.0.representative": "John Doe",
|
||||
"parties.other.0.name": "Streithelferin",
|
||||
// Flat legacy: first-of-role.
|
||||
"parties.claimant.name": "Acme Inc.",
|
||||
"parties.claimant.representative": "Maria Schmidt",
|
||||
"parties.defendant.name": "Initech",
|
||||
"parties.defendant.representative": "John Doe",
|
||||
"parties.other.name": "Streithelferin",
|
||||
}
|
||||
for key, want := range wants {
|
||||
got, ok := bag[key]
|
||||
if !ok {
|
||||
t.Errorf("missing key %q in bag", key)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("bag[%q] = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPartyVars_GermanRoleStrings(t *testing.T) {
|
||||
t.Parallel()
|
||||
// German role strings on real-world data must bucket the same as
|
||||
// the English equivalents — "Kläger" / "Klägerin" → claimants.
|
||||
parties := []models.Party{
|
||||
mkParty("Erika Musterfrau", "Klägerin", ""),
|
||||
mkParty("Max Mustermann", "Beklagter", ""),
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
addPartyVars(bag, parties)
|
||||
|
||||
if got := bag["parties.claimants"]; got != "Erika Musterfrau" {
|
||||
t.Errorf("parties.claimants = %q, want %q", got, "Erika Musterfrau")
|
||||
}
|
||||
if got := bag["parties.defendants"]; got != "Max Mustermann" {
|
||||
t.Errorf("parties.defendants = %q, want %q", got, "Max Mustermann")
|
||||
}
|
||||
// Backward-compat: legacy flat alias resolves to the first row of
|
||||
// the German-bucketed group.
|
||||
if got := bag["parties.claimant.name"]; got != "Erika Musterfrau" {
|
||||
t.Errorf("parties.claimant.name = %q, want %q", got, "Erika Musterfrau")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPartyVars_BackwardCompatFlatAliasResolvesFirstRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Critical guarantee from m/paliad#109: templates that say
|
||||
// {{parties.claimant.name}} (old shape) must keep merging — they
|
||||
// resolve to the FIRST selected claimant. Pinning this stops a
|
||||
// future refactor silently dropping the alias and breaking every
|
||||
// .docx in the repo.
|
||||
parties := []models.Party{
|
||||
mkParty("FirstCo", "claimant", "Repr A"),
|
||||
mkParty("SecondCo", "claimant", "Repr B"),
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
addPartyVars(bag, parties)
|
||||
if got := bag["parties.claimant.name"]; got != "FirstCo" {
|
||||
t.Errorf("parties.claimant.name (flat alias) = %q, want %q (first selected claimant)",
|
||||
got, "FirstCo")
|
||||
}
|
||||
if got := bag["parties.claimant.representative"]; got != "Repr A" {
|
||||
t.Errorf("parties.claimant.representative (flat alias) = %q, want %q",
|
||||
got, "Repr A")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPartiesBySelection_EmptyMeansAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
parties := []models.Party{
|
||||
mkParty("A", "claimant", ""),
|
||||
mkParty("B", "defendant", ""),
|
||||
}
|
||||
got := filterPartiesBySelection(parties, nil)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("empty selection should include every party, got %d/%d", len(got), len(parties))
|
||||
}
|
||||
got = filterPartiesBySelection(parties, []uuid.UUID{})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("empty []uuid selection should include every party, got %d/%d", len(got), len(parties))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPartiesBySelection_NonEmptyRestricts(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := mkParty("Acme", "claimant", "")
|
||||
b := mkParty("Initech", "defendant", "")
|
||||
c := mkParty("Globex", "claimant", "")
|
||||
parties := []models.Party{a, b, c}
|
||||
|
||||
got := filterPartiesBySelection(parties, []uuid.UUID{a.ID, c.ID})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d parties, want 2", len(got))
|
||||
}
|
||||
// Order must match the input order (PartyService.ListForProject
|
||||
// returns by name ascending; we preserve that to keep "first
|
||||
// claimant" deterministic across renders).
|
||||
if got[0].ID != a.ID || got[1].ID != c.ID {
|
||||
t.Errorf("selection lost input order: got %v", []string{got[0].Name, got[1].Name})
|
||||
}
|
||||
|
||||
// The "Initech" defendant was deselected; the bag should not list
|
||||
// it under defendants.
|
||||
bag := PlaceholderMap{}
|
||||
addPartyVars(bag, got)
|
||||
if v, ok := bag["parties.defendants"]; ok && v != "" {
|
||||
t.Errorf("parties.defendants = %q after deselecting Initech, want empty", v)
|
||||
}
|
||||
if !strings.Contains(bag["parties.claimants"], "Acme") || !strings.Contains(bag["parties.claimants"], "Globex") {
|
||||
t.Errorf("parties.claimants = %q, want both Acme and Globex", bag["parties.claimants"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProjectDerivedKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
derived := []string{
|
||||
"project.title", "project.proceeding.name",
|
||||
"parties.claimants", "parties.claimant.0.name",
|
||||
"deadline.due_date",
|
||||
"procedural_event.name", "rule.name",
|
||||
}
|
||||
for _, k := range derived {
|
||||
if !isProjectDerivedKey(k) {
|
||||
t.Errorf("expected %q to be project-derived", k)
|
||||
}
|
||||
}
|
||||
survives := []string{
|
||||
"firm.name", "today", "today.long_de",
|
||||
"user.email", "user.display_name",
|
||||
}
|
||||
for _, k := range survives {
|
||||
if isProjectDerivedKey(k) {
|
||||
t.Errorf("expected %q to survive Import-from-project (firm/today/user namespace)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,8 @@ func AgendaSystemView() SystemView {
|
||||
Sources: []DataSource{SourceDeadline, SourceAppointment},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"pending"}}},
|
||||
Predicates: &Predicates{
|
||||
Deadline: &DeadlinePredicates{Status: []string{"pending"}},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
@@ -126,14 +126,14 @@ func InboxSystemView() SystemView {
|
||||
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
Predicates: &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "any_visible",
|
||||
Status: []string{"pending"},
|
||||
}},
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||
},
|
||||
ProjectEvent: &ProjectEventPredicates{
|
||||
EventTypes: InboxProjectEventKinds,
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
@@ -159,10 +159,10 @@ func InboxRequesterSystemView() SystemView {
|
||||
Sources: []DataSource{SourceApprovalRequest},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
Predicates: &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "self_requested",
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
|
||||
@@ -82,11 +82,10 @@ func TestInboxSystemView_RowActionInbox(t *testing.T) {
|
||||
|
||||
func TestInboxSystemView_CuratedProjectEventKinds(t *testing.T) {
|
||||
sv := InboxSystemView()
|
||||
preds := sv.Filter.Predicates[SourceProjectEvent]
|
||||
if preds.ProjectEvent == nil {
|
||||
if sv.Filter.Predicates == nil || sv.Filter.Predicates.ProjectEvent == nil {
|
||||
t.Fatal("InboxSystemView must narrow project_event predicates")
|
||||
}
|
||||
got := preds.ProjectEvent.EventTypes
|
||||
got := sv.Filter.Predicates.ProjectEvent.EventTypes
|
||||
if len(got) != len(InboxProjectEventKinds) {
|
||||
t.Errorf("expected %d curated kinds, got %d", len(InboxProjectEventKinds), len(got))
|
||||
}
|
||||
|
||||
@@ -234,8 +234,8 @@ func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec
|
||||
uid := userID
|
||||
df.CreatedBy = &uid
|
||||
}
|
||||
if preds, ok := spec.Predicates[SourceDeadline]; ok && preds.Deadline != nil {
|
||||
dp := preds.Deadline
|
||||
if spec.Predicates != nil && spec.Predicates.Deadline != nil {
|
||||
dp := spec.Predicates.Deadline
|
||||
// Status: ListFilter has DeadlineStatusFilter (single-value filter).
|
||||
// If the spec asks for both pending+completed → no narrowing; if
|
||||
// only pending → DeadlineFilterPending; only completed → Completed.
|
||||
@@ -317,8 +317,8 @@ func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, sp
|
||||
}
|
||||
af.From = bounds.from
|
||||
af.To = bounds.to
|
||||
if preds, ok := spec.Predicates[SourceAppointment]; ok && preds.Appointment != nil {
|
||||
ap := preds.Appointment
|
||||
if spec.Predicates != nil && spec.Predicates.Appointment != nil {
|
||||
ap := spec.Predicates.Appointment
|
||||
// AppointmentListFilter takes a single Type today; narrow to first
|
||||
// listed value, fall back to all if multiple.
|
||||
if len(ap.AppointmentTypes) == 1 {
|
||||
@@ -482,21 +482,24 @@ func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, s
|
||||
// ApprovalService inbox queries. ViewerRole picks which underlying
|
||||
// query runs.
|
||||
func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) {
|
||||
preds := spec.Predicates[SourceApprovalRequest]
|
||||
var ap *ApprovalRequestPredicates
|
||||
if spec.Predicates != nil {
|
||||
ap = spec.Predicates.ApprovalRequest
|
||||
}
|
||||
role := "approver_eligible"
|
||||
if preds.ApprovalRequest != nil && preds.ApprovalRequest.ViewerRole != "" {
|
||||
role = preds.ApprovalRequest.ViewerRole
|
||||
if ap != nil && ap.ViewerRole != "" {
|
||||
role = ap.ViewerRole
|
||||
}
|
||||
|
||||
filter := InboxFilter{}
|
||||
if preds.ApprovalRequest != nil {
|
||||
if ap != nil {
|
||||
// InboxFilter takes a single status today. If the spec says
|
||||
// only one, narrow; if multiple, leave open.
|
||||
if len(preds.ApprovalRequest.Status) == 1 {
|
||||
filter.Status = preds.ApprovalRequest.Status[0]
|
||||
if len(ap.Status) == 1 {
|
||||
filter.Status = ap.Status[0]
|
||||
}
|
||||
if len(preds.ApprovalRequest.EntityTypes) == 1 {
|
||||
filter.EntityType = preds.ApprovalRequest.EntityTypes[0]
|
||||
if len(ap.EntityTypes) == 1 {
|
||||
filter.EntityType = ap.EntityTypes[0]
|
||||
}
|
||||
}
|
||||
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 {
|
||||
@@ -665,19 +668,18 @@ func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool {
|
||||
// approvalStatusMatches checks the entity-side approval_status filter.
|
||||
// Returns true when the row passes (no filter set → always true).
|
||||
func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool {
|
||||
preds, ok := spec.Predicates[src]
|
||||
if !ok {
|
||||
if spec.Predicates == nil {
|
||||
return true
|
||||
}
|
||||
var allowed []string
|
||||
switch src {
|
||||
case SourceDeadline:
|
||||
if preds.Deadline != nil {
|
||||
allowed = preds.Deadline.ApprovalStatus
|
||||
if spec.Predicates.Deadline != nil {
|
||||
allowed = spec.Predicates.Deadline.ApprovalStatus
|
||||
}
|
||||
case SourceAppointment:
|
||||
if preds.Appointment != nil {
|
||||
allowed = preds.Appointment.ApprovalStatus
|
||||
if spec.Predicates.Appointment != nil {
|
||||
allowed = spec.Predicates.Appointment.ApprovalStatus
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
@@ -689,15 +691,15 @@ func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bo
|
||||
// allowedAppointmentTypes returns nil when the filter is open, otherwise
|
||||
// a set of legal appointment_type values.
|
||||
func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceAppointment]
|
||||
if !ok || preds.Appointment == nil {
|
||||
if spec.Predicates == nil || spec.Predicates.Appointment == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.Appointment.AppointmentTypes) <= 1 {
|
||||
ap := spec.Predicates.Appointment
|
||||
if len(ap.AppointmentTypes) <= 1 {
|
||||
return nil // single-value already pushed down via AppointmentListFilter.Type
|
||||
}
|
||||
out := make(map[string]bool, len(preds.Appointment.AppointmentTypes))
|
||||
for _, t := range preds.Appointment.AppointmentTypes {
|
||||
out := make(map[string]bool, len(ap.AppointmentTypes))
|
||||
for _, t := range ap.AppointmentTypes {
|
||||
out[t] = true
|
||||
}
|
||||
return out
|
||||
@@ -712,13 +714,16 @@ func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
|
||||
// don't want both rows showing up side-by-side. The drop applies to
|
||||
// both the explicit caller list and the implicit "all kinds" path.
|
||||
func allowedProjectEventKinds(spec FilterSpec) []string {
|
||||
preds, ok := spec.Predicates[SourceProjectEvent]
|
||||
var pe *ProjectEventPredicates
|
||||
if spec.Predicates != nil {
|
||||
pe = spec.Predicates.ProjectEvent
|
||||
}
|
||||
dedupApprovals := slices.Contains(spec.Sources, SourceApprovalRequest)
|
||||
|
||||
var requested []string
|
||||
switch {
|
||||
case ok && preds.ProjectEvent != nil && len(preds.ProjectEvent.EventTypes) > 0:
|
||||
requested = preds.ProjectEvent.EventTypes
|
||||
case pe != nil && len(pe.EventTypes) > 0:
|
||||
requested = pe.EventTypes
|
||||
case dedupApprovals:
|
||||
// No explicit narrowing, but ApprovalRequest is in sources —
|
||||
// rebuild the implicit "all" list so we can subtract approvals.
|
||||
@@ -750,30 +755,30 @@ func isApprovalAuditKind(kind string) bool {
|
||||
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
|
||||
// already pushed into InboxFilter.Status").
|
||||
func allowedRequestStatuses(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceApprovalRequest]
|
||||
if !ok || preds.ApprovalRequest == nil {
|
||||
if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.ApprovalRequest.Status) <= 1 {
|
||||
ap := spec.Predicates.ApprovalRequest
|
||||
if len(ap.Status) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(preds.ApprovalRequest.Status))
|
||||
for _, s := range preds.ApprovalRequest.Status {
|
||||
out := make(map[string]bool, len(ap.Status))
|
||||
for _, s := range ap.Status {
|
||||
out[s] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allowedRequestEntityTypes(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceApprovalRequest]
|
||||
if !ok || preds.ApprovalRequest == nil {
|
||||
if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.ApprovalRequest.EntityTypes) <= 1 {
|
||||
ap := spec.Predicates.ApprovalRequest
|
||||
if len(ap.EntityTypes) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(preds.ApprovalRequest.EntityTypes))
|
||||
for _, t := range preds.ApprovalRequest.EntityTypes {
|
||||
out := make(map[string]bool, len(ap.EntityTypes))
|
||||
for _, t := range ap.EntityTypes {
|
||||
out[t] = true
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -13,8 +13,8 @@ func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
|
||||
spec := FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||
Predicates: &Predicates{
|
||||
ProjectEvent: &ProjectEventPredicates{
|
||||
EventTypes: []string{
|
||||
"deadline_created",
|
||||
"deadline_approval_requested",
|
||||
@@ -22,7 +22,7 @@ func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
|
||||
"approval_decided",
|
||||
"note_created",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := allowedProjectEventKinds(spec)
|
||||
@@ -47,13 +47,13 @@ func TestAllowedProjectEventKinds_NoDedupWhenApprovalsAbsent(t *testing.T) {
|
||||
spec := FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceProjectEvent},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||
Predicates: &Predicates{
|
||||
ProjectEvent: &ProjectEventPredicates{
|
||||
EventTypes: []string{
|
||||
"deadline_created",
|
||||
"deadline_approval_requested",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := allowedProjectEventKinds(spec)
|
||||
|
||||
449
scripts/gen-hl-skeleton-template/main.go
Normal file
449
scripts/gen-hl-skeleton-template/main.go
Normal file
@@ -0,0 +1,449 @@
|
||||
// HL-firm skeleton submission template generator (t-paliad-275).
|
||||
//
|
||||
// Reads HLC's "HL Patents Style" .dotm letterhead, strips its VBA
|
||||
// macros and template-only artifacts, then emits a clean .docx that:
|
||||
//
|
||||
// 1. Preserves every HL paragraph + character style (HLpat-Heading-H1,
|
||||
// HLpat-Body-B0, HLpat-Signature, HLpat-Table-Recitals-*, …) by
|
||||
// keeping word/styles.xml, word/theme/*, word/numbering.xml,
|
||||
// word/fontTable.xml, settings.xml, footnotes/endnotes from the
|
||||
// source .dotm untouched.
|
||||
// 2. Preserves the firm letterhead (logo header + firm-address footer)
|
||||
// by keeping word/header[12].xml + word/footer[12].xml and the
|
||||
// sectPr that references them.
|
||||
// 3. Replaces word/document.xml with a Schriftsatz-shaped body that
|
||||
// exercises every SubmissionVarsService placeholder (firm.*,
|
||||
// today.*, user.*, project.*, parties.*, procedural_event.*, rule.*,
|
||||
// deadline.*) — applying HL paragraph/character styles to each
|
||||
// section so the rendered output reads as a real HL submission with
|
||||
// variables substituted.
|
||||
//
|
||||
// Drop the output into HL/mWorkRepo at
|
||||
//
|
||||
// 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
|
||||
//
|
||||
// so paliad's submission generator picks it up via the fallback chain.
|
||||
// Lookup order after this CL: per-firm per-code → _firm-skeleton.docx
|
||||
// (THIS file — HL formatting + placeholders) → universal _skeleton.docx
|
||||
// (generic skeleton from t-paliad-259) → bare HL Patents Style .dotm
|
||||
// (no placeholders). See internal/handlers/submission_drafts.go
|
||||
// resolveSubmissionTemplate.
|
||||
//
|
||||
// Why this is firm-specific: the .dotm carries HL-licensed fonts,
|
||||
// HL-branded logo media, and HLpat-prefixed style IDs. The output lives
|
||||
// under the firm-namespaced directory in mWorkRepo so a future firm gets
|
||||
// its own equivalent file generated against its own .dotm.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./scripts/gen-hl-skeleton-template \
|
||||
// -in /tmp/hl-patents-style.dotm \
|
||||
// -out /tmp/_firm-skeleton.docx
|
||||
//
|
||||
// Output is byte-stable across runs for a given input (zip mtimes
|
||||
// pinned).
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
in := flag.String("in", "", "path to source HL Patents Style .dotm (required)")
|
||||
out := flag.String("out", "_firm-skeleton.docx", "output .docx path")
|
||||
flag.Parse()
|
||||
|
||||
if *in == "" {
|
||||
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: -in is required (path to HL Patents Style .dotm)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
srcBytes, err := os.ReadFile(*in)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: read source:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
docx, err := buildDocx(srcBytes)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
|
||||
}
|
||||
|
||||
// fixedTime pins every zip entry's mtime so successive runs over the
|
||||
// same .dotm produce byte-stable output. Useful for diffing the
|
||||
// generated file in PR review.
|
||||
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// dropPaths lists zip entries removed during the .dotm → .docx
|
||||
// conversion. VBA macros + their keymap binding + the template-only
|
||||
// glossary parts and ribbon customizations are all dead weight (and
|
||||
// some actively trigger Word's macro-security warning) — none of them
|
||||
// add anything to a placeholder-rich Schriftsatz starter.
|
||||
var dropPaths = map[string]bool{
|
||||
"word/vbaProject.bin": true,
|
||||
"word/vbaData.xml": true,
|
||||
"word/customizations.xml": true,
|
||||
"userCustomization/customUI.xml": true,
|
||||
"customUI/customUI14.xml": true,
|
||||
"word/glossary/document.xml": true,
|
||||
"word/glossary/_rels/document.xml.rels": true,
|
||||
"word/glossary/fontTable.xml": true,
|
||||
"word/glossary/numbering.xml": true,
|
||||
"word/glossary/settings.xml": true,
|
||||
"word/glossary/styles.xml": true,
|
||||
"word/glossary/webSettings.xml": true,
|
||||
}
|
||||
|
||||
// rIdsToDrop names the document-rel ids whose targets are stripped
|
||||
// from the package (vbaProject, customizations.xml, glossary). They
|
||||
// must vanish from word/_rels/document.xml.rels so Word doesn't choke
|
||||
// on a dangling reference.
|
||||
var rIdsToDrop = map[string]bool{
|
||||
"rId1": true, // vbaProject.bin
|
||||
"rId2": true, // customizations.xml (keymap to VBA)
|
||||
"rId21": true, // glossary/document.xml
|
||||
}
|
||||
|
||||
func buildDocx(src []byte) ([]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open source zip: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if dropPaths[name] {
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := readZipEntry(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "[Content_Types].xml":
|
||||
body = []byte(patchContentTypes(string(body)))
|
||||
case "_rels/.rels":
|
||||
body = []byte(patchRootRels(string(body)))
|
||||
case "word/_rels/document.xml.rels":
|
||||
body = []byte(patchDocumentRels(string(body)))
|
||||
case "word/document.xml":
|
||||
body = []byte(buildDocumentXML())
|
||||
}
|
||||
|
||||
hdr := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
Modified: fixedTime,
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("write %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// patchContentTypes rewrites the macroEnabledTemplate part type to the
|
||||
// regular wordprocessingml.document type (a .dotm carries the macro
|
||||
// part type even on the body part), and removes Default/Override
|
||||
// entries that target now-deleted parts (vba binary, customizations,
|
||||
// glossary).
|
||||
func patchContentTypes(in string) string {
|
||||
out := in
|
||||
out = strings.ReplaceAll(out,
|
||||
`<Override PartName="/word/document.xml" ContentType="application/vnd.ms-word.template.macroEnabledTemplate.main+xml"/>`,
|
||||
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>`)
|
||||
|
||||
removals := []string{
|
||||
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`,
|
||||
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`,
|
||||
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`,
|
||||
`<Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/>`,
|
||||
`<Override PartName="/word/glossary/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>`,
|
||||
`<Override PartName="/word/glossary/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`,
|
||||
`<Override PartName="/word/glossary/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>`,
|
||||
`<Override PartName="/word/glossary/webSettings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"/>`,
|
||||
`<Override PartName="/word/glossary/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/>`,
|
||||
}
|
||||
for _, r := range removals {
|
||||
out = strings.ReplaceAll(out, r, "")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// patchRootRels drops the userCustomization (ribbon mini-tab) and the
|
||||
// customUI14 extensibility relationships — both reference VBA-backed
|
||||
// UI we don't ship.
|
||||
func patchRootRels(in string) string {
|
||||
out := in
|
||||
out = stripRelByPrefix(out, `<Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2006/relationships/ui/userCustomization"`)
|
||||
out = stripRelByPrefix(out, `<Relationship Id="Rf8f70ab1afd0469a" Type="http://schemas.microsoft.com/office/2007/relationships/ui/extensibility"`)
|
||||
return out
|
||||
}
|
||||
|
||||
// patchDocumentRels drops the document-level rels whose targets we
|
||||
// stripped (vbaProject, customizations.xml, glossaryDocument).
|
||||
func patchDocumentRels(in string) string {
|
||||
out := in
|
||||
for rid := range rIdsToDrop {
|
||||
needle := `<Relationship Id="` + rid + `" `
|
||||
out = stripRelByPrefix(out, needle)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stripRelByPrefix removes the full <Relationship .../> element whose
|
||||
// open tag starts with the given prefix. Tolerates either a regular
|
||||
// closing tag (</Relationship>) or the more common self-closing form.
|
||||
func stripRelByPrefix(s, prefix string) string {
|
||||
for {
|
||||
start := strings.Index(s, prefix)
|
||||
if start < 0 {
|
||||
return s
|
||||
}
|
||||
// Find end of this element (next "/>"). The .dotm always uses the
|
||||
// self-closing form for Relationship elements.
|
||||
end := strings.Index(s[start:], "/>")
|
||||
if end < 0 {
|
||||
return s
|
||||
}
|
||||
s = s[:start] + s[start+end+2:]
|
||||
}
|
||||
}
|
||||
|
||||
// buildDocumentXML emits a Schriftsatz skeleton that exercises every
|
||||
// SubmissionVarsService placeholder (the canonical 48-key v1 contract
|
||||
// + the procedural_event.* canonical names + their rule.* legacy
|
||||
// aliases). The structure mirrors a real DE/UPC submission — title
|
||||
// block → court → rubrum → patent reference → submission title →
|
||||
// legal grounds → Sachverhalt/Anträge/Rechtsausführungen/Beweis →
|
||||
// signature → locale-variant verification footer.
|
||||
//
|
||||
// Each placeholder lives in its own <w:r> run so the renderer's pass-1
|
||||
// (format-preserving single-run replace) catches every key. HL
|
||||
// paragraph styles (HLpat-Heading-H1, HLpat-Header-Section, etc.) are
|
||||
// applied via pStyle, character styles via rStyle.
|
||||
//
|
||||
// The sectPr at the bottom is copied verbatim from the source .dotm
|
||||
// so the firm header/footer references (rId16=header1, rId17=footer1,
|
||||
// rId18=header2 first-page, rId19=footer2 first-page) keep resolving
|
||||
// after we replace the body. pgSz/pgMar/cols/docGrid match the .dotm
|
||||
// exactly — a lawyer printing this gets the same A4 layout the .dotm
|
||||
// produces.
|
||||
func buildDocumentXML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`)
|
||||
b.WriteString(`<w:body>`)
|
||||
|
||||
skeletonBanner(&b)
|
||||
|
||||
heading(&b, "HLpat-Heading-H1", "{{firm.name}}")
|
||||
body0(&b, "Bearbeiter: {{user.display_name}}")
|
||||
body0(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
|
||||
body0(&b, "Datum: {{today.long_de}} ({{today.iso}})")
|
||||
body0(&b, "{{firm.signature_block}}")
|
||||
|
||||
headerSection(&b, "{{project.court}}")
|
||||
body0(&b, "Aktenzeichen: {{project.case_number}}")
|
||||
body0(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
|
||||
body0(&b, "Instanz: {{project.instance_level}}")
|
||||
|
||||
headerSubsection(&b, "In der Sache")
|
||||
|
||||
recitalsParty(&b, "{{parties.claimant.name}}")
|
||||
recitalsPartyDetails(&b, "vertreten durch {{parties.claimant.representative}}")
|
||||
recitalsRoles(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
|
||||
|
||||
recitalsSequencer(&b, "gegen")
|
||||
|
||||
recitalsParty(&b, "{{parties.defendant.name}}")
|
||||
recitalsPartyDetails(&b, "vertreten durch {{parties.defendant.representative}}")
|
||||
recitalsRoles(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
|
||||
|
||||
recitalsSequencer(&b, "sowie")
|
||||
|
||||
recitalsParty(&b, "{{parties.other.name}}")
|
||||
recitalsPartyDetails(&b, "vertreten durch {{parties.other.representative}}")
|
||||
recitalsRoles(&b, "— Weitere Beteiligte —")
|
||||
|
||||
headerSubsection(&b, "Betreff")
|
||||
body0(&b, "Streitpatent: {{project.patent_number}} (UPC-Schreibweise: {{project.patent_number_upc}})")
|
||||
body0(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
|
||||
body0(&b, "Projekttitel: {{project.title}}")
|
||||
body0(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
|
||||
body0(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
|
||||
body0(&b, "Internes Aktenzeichen: {{project.reference}}")
|
||||
|
||||
heading(&b, "HLpat-Heading-H1", "{{procedural_event.name}}")
|
||||
body0(&b, "(Schriftsatz-Code: {{procedural_event.code}})")
|
||||
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
|
||||
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
|
||||
|
||||
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
|
||||
// {{deadline.*}} placeholders stay resolvable in the variable bag
|
||||
// for custom templates that want them, but the default HL skeleton
|
||||
// no longer renders them in the submission body: the deadline is
|
||||
// internal/admin context and has no place in a court-bound document.
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
|
||||
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "II. Anträge")
|
||||
requestsIntro(&b, "Es wird beantragt:")
|
||||
requestsLevel1(&b, "[Antrag 1 — gemäß {{procedural_event.legal_source_pretty}}]")
|
||||
requestsLevel1(&b, "[Antrag 2]")
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "III. Rechtsausführungen")
|
||||
body0(&b, "[Hier folgen die Rechtsausführungen.]")
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "IV. Beweis")
|
||||
evidenceOffering(&b, "Beweis: [Beweismittel — z. B. Anlage K1: {{project.patent_number}}]")
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "Schlussformel")
|
||||
signature(&b, "{{today.long_de}}")
|
||||
signature(&b, "")
|
||||
signature(&b, "{{user.display_name}}")
|
||||
signature(&b, "{{firm.name}}")
|
||||
|
||||
// Locale-aware verification block — exercises every EN/DE alias the
|
||||
// variable bag carries plus the rule.* legacy aliases so a lawyer
|
||||
// editing the template sees that both surfaces resolve. A real
|
||||
// submission deletes this section after sanity-checking the render.
|
||||
heading(&b, "HLpat-Heading-H3", "Locale-Varianten & Legacy-Aliase (SKELETON)")
|
||||
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
|
||||
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
|
||||
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
|
||||
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
|
||||
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")
|
||||
body1(&b, "Rule legacy aliases — primary_party: {{rule.primary_party}}, event_type: {{rule.event_type}}")
|
||||
|
||||
// sectPr — copied verbatim from the source .dotm. Keeps the firm
|
||||
// letterhead header (rId16=header1.xml, rId18=header2.xml first-page)
|
||||
// and the firm-address footer (rId17, rId19) on every printed page.
|
||||
b.WriteString(sectPrXML)
|
||||
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sectPrXML matches the source .dotm's section properties exactly so
|
||||
// the firm header/footer refs and A4 page geometry round-trip.
|
||||
const sectPrXML = `<w:sectPr><w:headerReference w:type="default" r:id="rId16"/><w:footerReference w:type="default" r:id="rId17"/><w:headerReference w:type="first" r:id="rId18"/><w:footerReference w:type="first" r:id="rId19"/><w:pgSz w:w="11906" w:h="16838" w:code="9"/><w:pgMar w:top="567" w:right="1418" w:bottom="567" w:left="1418" w:header="284" w:footer="284" w:gutter="0"/><w:cols w:space="720"/><w:titlePg/><w:docGrid w:linePitch="286"/></w:sectPr>`
|
||||
|
||||
func skeletonBanner(b *strings.Builder) {
|
||||
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="HLpat-Heading-H1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — HL Patents Style mit Platzhaltern (nicht freigegeben)</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
func heading(b *strings.Builder, style, text string) { styledPara(b, style, "", text) }
|
||||
func headerSection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Section", "", text) }
|
||||
func headerSubsection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Subsection", "", text) }
|
||||
func body0(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B0", "", text) }
|
||||
func body1(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B1", "", text) }
|
||||
func recitalsParty(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Party", "", text) }
|
||||
func recitalsPartyDetails(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyDetails", "", text) }
|
||||
func recitalsRoles(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyRoles", "", text) }
|
||||
func recitalsSequencer(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Sequencers", "", text) }
|
||||
func requestsIntro(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Intro", "", text) }
|
||||
func requestsLevel1(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Level1", "", text) }
|
||||
func evidenceOffering(b *strings.Builder, text string) { styledPara(b, "HLpat-EvidenceOffering", "", text) }
|
||||
func signature(b *strings.Builder, text string) { styledPara(b, "HLpat-Signature", "", text) }
|
||||
|
||||
// styledPara writes one paragraph with the given pStyle (paragraph
|
||||
// style id) and optional rStyle (character style applied to every run).
|
||||
// Empty style ids drop the corresponding wrapper. Placeholders inside
|
||||
// `text` are split into their own runs so the renderer's pass-1
|
||||
// single-run replace catches each one independently.
|
||||
func styledPara(b *strings.Builder, pStyle, rStyle, text string) {
|
||||
b.WriteString(`<w:p>`)
|
||||
if pStyle != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(pStyle)
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
for _, seg := range splitOnPlaceholders(text) {
|
||||
b.WriteString(`<w:r>`)
|
||||
if rStyle != "" {
|
||||
b.WriteString(`<w:rPr><w:rStyle w:val="`)
|
||||
b.WriteString(rStyle)
|
||||
b.WriteString(`"/></w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(seg))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
}
|
||||
|
||||
func splitOnPlaceholders(s string) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
var out []string
|
||||
for {
|
||||
open := strings.Index(s, "{{")
|
||||
if open < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
close := strings.Index(s[open:], "}}")
|
||||
if close < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
end := open + close + 2
|
||||
if open > 0 {
|
||||
out = append(out, s[:open])
|
||||
}
|
||||
out = append(out, s[open:end])
|
||||
s = s[end:]
|
||||
if s == "" {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
@@ -137,14 +137,19 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
</w:styles>`
|
||||
|
||||
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
|
||||
// case caption + parties + submission heading + deadline + a single
|
||||
// neutral body block. Mirrors the variable bag from SubmissionVarsService
|
||||
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
|
||||
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
|
||||
// structure. A lawyer customising this template for a UPC SoC, EPO
|
||||
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
|
||||
// renames the party labels — every placeholder still resolves regardless
|
||||
// of the submission_code chosen.
|
||||
// case caption + parties + submission heading + a single neutral body
|
||||
// block. Mirrors the variable bag from SubmissionVarsService (firm.* /
|
||||
// today.* / user.* / project.* / parties.* / rule.*) without baking in
|
||||
// DE-LG-Klageerwiderung-specific structure. A lawyer customising this
|
||||
// template for a UPC SoC, EPO opposition, or DPMA appeal replaces the
|
||||
// [Schriftsatztext] block and renames the party labels — every
|
||||
// placeholder still resolves regardless of the submission_code chosen.
|
||||
//
|
||||
// The {{deadline.*}} placeholders are deliberately NOT rendered by the
|
||||
// default skeleton (t-paliad-287). The deadline is internal context for
|
||||
// the lawyer, not text that belongs in a court-bound submission. The
|
||||
// keys stay resolvable in the bag so a custom template can still
|
||||
// reference them where it actually wants them.
|
||||
//
|
||||
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
|
||||
// (format-preserving, single-run) substitution catches it. The
|
||||
@@ -194,11 +199,12 @@ func buildDocumentXML() string {
|
||||
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
||||
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||
|
||||
heading2(&b, "Frist")
|
||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
||||
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
|
||||
// {{deadline.*}} placeholders stay resolvable in the variable bag
|
||||
// (lawyer can still drop them into a custom paragraph) but the
|
||||
// default skeleton no longer renders them in the submission body:
|
||||
// the deadline is internal/admin context and has no place in a
|
||||
// document going out to court.
|
||||
|
||||
heading2(&b, "Schriftsatztext")
|
||||
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
|
||||
@@ -217,7 +223,7 @@ func buildDocumentXML() string {
|
||||
// the bare {{today}} alias. A lawyer customising the template can
|
||||
// delete this block; the renderer round-trips it cleanly today.
|
||||
heading2(&b, "Locale-aware variants (SKELETON)")
|
||||
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
|
||||
plain(&b, "EN long date: {{today.long_en}}")
|
||||
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
|
||||
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
||||
|
||||
Reference in New Issue
Block a user