Compare commits
22 Commits
mai/knuth/
...
mai/cronus
| Author | SHA1 | Date | |
|---|---|---|---|
| c901293c9c | |||
| c4e9875ff4 | |||
| e4c694e01c | |||
| 5efb9f5098 | |||
| c6267e4e6d | |||
| 8e696487e0 | |||
| 001542a3ce | |||
| 4fc3005db8 | |||
| a6d0acbcb4 | |||
| 96eab90044 | |||
| 5348cb548f | |||
| b1340e2be4 | |||
| 1292aa575d | |||
| 87c200a47e | |||
| 4f910e31ea | |||
| bf60fc1400 | |||
| dc47ea7f43 | |||
| 930771a898 | |||
| 7368e7012b | |||
| d4df81e374 | |||
| 169ace5d26 | |||
| ac7bc27fb7 |
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
|
||||
|
||||
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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
@@ -306,6 +307,24 @@ 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",
|
||||
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||
"deadlines.mode.event": "Was kommt nach\u2026",
|
||||
@@ -421,6 +440,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.claimant": "Klägerseite",
|
||||
"deadlines.side.defendant": "Beklagtenseite",
|
||||
"deadlines.side.both": "Beide",
|
||||
"deadlines.side.from_project": "Aus Akte:",
|
||||
"deadlines.side.override": "Andere Seite wählen",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
@@ -1473,6 +1494,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 aus, welche Parteien im Schriftsatz genannt werden sollen.",
|
||||
// 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",
|
||||
@@ -3044,7 +3074,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// /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",
|
||||
@@ -3274,6 +3304,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",
|
||||
@@ -3373,6 +3404,24 @@ 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",
|
||||
"deadlines.adjusted": "Adjusted",
|
||||
"deadlines.adjusted.reason": "weekend/holiday",
|
||||
"deadlines.adjusted.weekend": "weekend",
|
||||
@@ -3495,6 +3544,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.claimant": "Claimant",
|
||||
"deadlines.side.defendant": "Defendant",
|
||||
"deadlines.side.both": "Both",
|
||||
"deadlines.side.from_project": "From case:",
|
||||
"deadlines.side.override": "Choose other side",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
@@ -4520,7 +4571,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": "Select which parties to mention in this submission.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
@@ -6074,7 +6134,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// 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",
|
||||
|
||||
@@ -20,13 +20,25 @@ interface SubmissionDraftJSON {
|
||||
submission_code: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
// t-paliad-276 — per-draft output language ("de" or "en"). Drives the
|
||||
// template-variant lookup and language-aware variable resolution.
|
||||
language: string;
|
||||
variables: Record<string, string>;
|
||||
selected_parties: string[];
|
||||
last_exported_at?: string | null;
|
||||
last_exported_sha?: string | null;
|
||||
last_imported_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AvailablePartyJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
representative?: string;
|
||||
}
|
||||
|
||||
interface SubmissionRuleSummary {
|
||||
name: string;
|
||||
name_en: string;
|
||||
@@ -46,6 +58,12 @@ interface SubmissionDraftView {
|
||||
lang: string;
|
||||
has_template: boolean;
|
||||
template_missing?: boolean;
|
||||
available_parties: AvailablePartyJSON[];
|
||||
// t-paliad-276 — template-tier metadata used to surface the
|
||||
// "Fallback: universelles Skelett" notice when the requested draft
|
||||
// language has no per-firm language-matched template.
|
||||
template_tier?: string;
|
||||
language_fallback?: boolean;
|
||||
}
|
||||
|
||||
interface SubmissionDraftListResponse {
|
||||
@@ -401,7 +419,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) throw new Error("no draft id");
|
||||
if (state.inFlight) {
|
||||
@@ -451,6 +469,10 @@ function paint(): void {
|
||||
paintNoProjectBanner();
|
||||
paintSwitcher();
|
||||
paintNameRow();
|
||||
paintImportRow();
|
||||
paintPartyPicker();
|
||||
paintLanguageRow();
|
||||
paintLanguageFallback();
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
}
|
||||
@@ -562,6 +584,193 @@ function paintNameRow(): void {
|
||||
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
|
||||
}
|
||||
|
||||
// t-paliad-277 — "Aus Projekt importieren" + last-imported-at stamp.
|
||||
// Hidden when the draft has no project (no project state to import).
|
||||
function paintImportRow(): void {
|
||||
const row = document.getElementById("submission-draft-import-row");
|
||||
const btn = document.getElementById("submission-draft-import-btn") as HTMLButtonElement | null;
|
||||
const stamp = document.getElementById("submission-draft-import-stamp");
|
||||
if (!row || !btn || !stamp || !state.view) return;
|
||||
|
||||
if (!state.view.draft.project_id) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
|
||||
const last = state.view.draft.last_imported_at;
|
||||
if (last) {
|
||||
stamp.textContent = (isEN() ? "Last imported: " : "Zuletzt importiert: ") + formatStamp(last);
|
||||
} else {
|
||||
stamp.textContent = isEN() ? "Never imported" : "Noch nicht importiert";
|
||||
}
|
||||
btn.onclick = () => { void onImportFromProject(btn); };
|
||||
}
|
||||
|
||||
// t-paliad-277 — multi-select party picker. Lists every party on the
|
||||
// draft's project (view.available_parties), grouped by role, with one
|
||||
// checkbox per party. Checked = include in the variable bag. Empty
|
||||
// selection falls back to the legacy "include every party" default
|
||||
// (consistent with the migration default).
|
||||
function paintPartyPicker(): void {
|
||||
const block = document.getElementById("submission-draft-parties");
|
||||
const list = document.getElementById("submission-draft-parties-list");
|
||||
if (!block || !list || !state.view) return;
|
||||
|
||||
const parties = state.view.available_parties ?? [];
|
||||
if (!state.view.draft.project_id || parties.length === 0) {
|
||||
block.style.display = "none";
|
||||
list.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
block.style.display = "";
|
||||
|
||||
const selected = new Set(state.view.draft.selected_parties ?? []);
|
||||
// Empty selection is the implicit "all" default — pre-check every
|
||||
// party so the lawyer can see what's currently being mentioned and
|
||||
// then deselect what they want to drop. This matches the issue's
|
||||
// "default = all parties on the project, lawyer can deselect" line.
|
||||
const effective = selected.size === 0
|
||||
? new Set(parties.map((p) => p.id))
|
||||
: selected;
|
||||
|
||||
const grouped = groupPartiesByRole(parties);
|
||||
let html = "";
|
||||
for (const group of grouped) {
|
||||
if (group.parties.length === 0) continue;
|
||||
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
|
||||
html += `<legend>${escapeHtml(group.label)}</legend>`;
|
||||
for (const p of group.parties) {
|
||||
const checked = effective.has(p.id) ? " checked" : "";
|
||||
const chip = p.role
|
||||
? `<span class="submission-draft-party-chip">${escapeHtml(p.role)}</span>`
|
||||
: "";
|
||||
const rep = p.representative
|
||||
? `<span class="submission-draft-party-rep">${escapeHtml(
|
||||
(isEN() ? "Repr.: " : "Vertr.: ") + p.representative,
|
||||
)}</span>`
|
||||
: "";
|
||||
html += `<label class="submission-draft-party-row">`;
|
||||
html += `<input type="checkbox" class="submission-draft-party-check"`;
|
||||
html += ` data-party-id="${escapeHtml(p.id)}"${checked} />`;
|
||||
html += `<span class="submission-draft-party-name">${escapeHtml(p.name)}</span>`;
|
||||
html += chip;
|
||||
html += rep;
|
||||
html += `</label>`;
|
||||
}
|
||||
html += `</fieldset>`;
|
||||
}
|
||||
list.innerHTML = html;
|
||||
|
||||
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
|
||||
inp.addEventListener("change", () => onPartySelectionChange());
|
||||
});
|
||||
}
|
||||
|
||||
interface PartyRoleGroup {
|
||||
bucket: "claimant" | "defendant" | "other";
|
||||
label: string;
|
||||
parties: AvailablePartyJSON[];
|
||||
}
|
||||
|
||||
function groupPartiesByRole(parties: AvailablePartyJSON[]): PartyRoleGroup[] {
|
||||
const claimants: AvailablePartyJSON[] = [];
|
||||
const defendants: AvailablePartyJSON[] = [];
|
||||
const others: AvailablePartyJSON[] = [];
|
||||
for (const p of parties) {
|
||||
const role = (p.role ?? "").trim().toLowerCase();
|
||||
if (role === "claimant" || role === "kläger" || role === "klaeger"
|
||||
|| role === "klägerin" || role === "klaegerin") {
|
||||
claimants.push(p);
|
||||
} else if (role === "defendant" || role === "beklagter" || role === "beklagte") {
|
||||
defendants.push(p);
|
||||
} else {
|
||||
others.push(p);
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
bucket: "claimant",
|
||||
label: isEN() ? "Claimants" : "Klägerinnen",
|
||||
parties: claimants,
|
||||
},
|
||||
{
|
||||
bucket: "defendant",
|
||||
label: isEN() ? "Defendants" : "Beklagte",
|
||||
parties: defendants,
|
||||
},
|
||||
{
|
||||
bucket: "other",
|
||||
label: isEN() ? "Other parties" : "Weitere Parteien",
|
||||
parties: others,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function formatStamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(isEN() ? "en-GB" : "de-DE");
|
||||
}
|
||||
|
||||
// paintLanguageRow syncs the DE/EN radio with the loaded draft's
|
||||
// language. Switching the radio fires onLanguageChange which PATCHes
|
||||
// the draft and lets the server return the freshly-resolved bag +
|
||||
// preview HTML (so the lawyer sees the EN form names appear without a
|
||||
// manual reload). t-paliad-276.
|
||||
function paintLanguageRow(): void {
|
||||
if (!state.view) return;
|
||||
const lang = (state.view.draft.language || "de").toLowerCase();
|
||||
const de = document.getElementById("submission-draft-language-de") as HTMLInputElement | null;
|
||||
const en = document.getElementById("submission-draft-language-en") as HTMLInputElement | null;
|
||||
if (de) {
|
||||
de.checked = lang === "de";
|
||||
de.onchange = () => { void onLanguageChange("de"); };
|
||||
}
|
||||
if (en) {
|
||||
en.checked = lang === "en";
|
||||
en.onchange = () => { void onLanguageChange("en"); };
|
||||
}
|
||||
}
|
||||
|
||||
// paintLanguageFallback shows / hides the "no language-matched
|
||||
// template" notice. The server sets language_fallback=true when the
|
||||
// resolved template tier doesn't match the draft's language
|
||||
// (e.g. EN draft → DE per-code template, or no skeleton EN sibling).
|
||||
function paintLanguageFallback(): void {
|
||||
const el = document.getElementById("submission-draft-language-fallback");
|
||||
if (!el) return;
|
||||
const fallback = !!state.view?.language_fallback;
|
||||
el.style.display = fallback ? "" : "none";
|
||||
}
|
||||
|
||||
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
|
||||
if (!state.view) return;
|
||||
if ((state.view.draft.language || "de").toLowerCase() === lang) return;
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
try {
|
||||
const view = await patchDraft({ language: lang });
|
||||
state.view = view;
|
||||
// Repaint everything that depends on language: the DE/EN form
|
||||
// values in the resolved bag, the localized rule name in the
|
||||
// header, and the fallback notice.
|
||||
paintHeader();
|
||||
paintLanguageRow();
|
||||
paintLanguageFallback();
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
console.error("submission-draft language switch:", err);
|
||||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||||
// Revert the radio to the persisted value so the UI doesn't lie
|
||||
// about which language is active.
|
||||
paintLanguageRow();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function paintVariables(): void {
|
||||
const host = document.getElementById("submission-draft-variables");
|
||||
if (!host || !state.view) return;
|
||||
@@ -610,10 +819,25 @@ function paintVariables(): void {
|
||||
|
||||
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
|
||||
inp.addEventListener("input", () => onVarChange(inp));
|
||||
// t-paliad-274 (B) — focus into a sidebar field highlights every
|
||||
// matching .draft-var span in the preview (sticky while focused,
|
||||
// clears on blur). Survives autosave repaints because paintVariables
|
||||
// is called by flushAutosave and we re-bind every render.
|
||||
inp.addEventListener("focusin", () => onVarFocusEnter(inp.dataset.var ?? ""));
|
||||
inp.addEventListener("focusout", () => onVarFocusLeave(inp.dataset.var ?? ""));
|
||||
});
|
||||
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
|
||||
});
|
||||
// After repaint, re-apply the active highlight if a field is still
|
||||
// focused (paintVariables runs after autosave; the same input regains
|
||||
// focus via restoreVarFocus and would otherwise emit focusin too
|
||||
// late for our handler — re-apply explicitly).
|
||||
const active = document.activeElement;
|
||||
if (isVarField(active)) {
|
||||
const key = active.dataset.var;
|
||||
if (key) applyPreviewActiveHighlight(key);
|
||||
}
|
||||
}
|
||||
|
||||
function paintPreview(): void {
|
||||
@@ -621,6 +845,16 @@ function paintPreview(): void {
|
||||
if (!host || !state.view) return;
|
||||
host.innerHTML = state.view.preview_html ?? "";
|
||||
wireDraftVars(host);
|
||||
// t-paliad-274 (B) — preview HTML was just blown away by innerHTML,
|
||||
// so any prior --active classes are gone. Re-apply for whichever
|
||||
// sidebar field is currently focused (typing in a field triggers an
|
||||
// autosave round-trip that ends in paintPreview, and the user should
|
||||
// see the highlight stay put across that cycle).
|
||||
const active = document.activeElement;
|
||||
if (isVarField(active)) {
|
||||
const key = active.dataset.var;
|
||||
if (key) applyPreviewActiveHighlight(key);
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-261 (B) — click a substituted variable in the preview to
|
||||
@@ -695,6 +929,48 @@ function onDraftVarClick(key: string, ev: Event): void {
|
||||
flashVarRow(input);
|
||||
}
|
||||
|
||||
// t-paliad-274 (B) — sidebar-field-focus → preview-occurrence highlight.
|
||||
// Reverse direction of the click-to-jump from #92: when the user focuses
|
||||
// any .submission-draft-var-input, every matching .draft-var span in the
|
||||
// preview gets the --active modifier; on blur (or focus shift to a
|
||||
// different field), the previous key's highlights clear and the new
|
||||
// key's apply. Sticky-while-focused, not a one-shot flash — the lawyer
|
||||
// can scan the preview for "where does this variable land in my prose?"
|
||||
// while the field stays focused.
|
||||
function onVarFocusEnter(key: string): void {
|
||||
if (!key) return;
|
||||
// Clear any leftover highlight before applying the new one — covers
|
||||
// the focus-shift-without-blur case (Tab between fields).
|
||||
clearPreviewActiveHighlight();
|
||||
applyPreviewActiveHighlight(key);
|
||||
}
|
||||
|
||||
function onVarFocusLeave(_key: string): void {
|
||||
// We don't need the key here — if focus moves to a different sidebar
|
||||
// input, that input's focusin will re-call apply with the new key
|
||||
// (after our clearPreviewActiveHighlight). If focus leaves the sidebar
|
||||
// entirely, this clears.
|
||||
clearPreviewActiveHighlight();
|
||||
}
|
||||
|
||||
function applyPreviewActiveHighlight(key: string): void {
|
||||
const host = document.getElementById("submission-draft-preview");
|
||||
if (!host) return;
|
||||
host.querySelectorAll<HTMLElement>(
|
||||
`.draft-var[data-var="${cssEscape(key)}"]`,
|
||||
).forEach((el) => {
|
||||
el.classList.add("draft-var--active");
|
||||
});
|
||||
}
|
||||
|
||||
function clearPreviewActiveHighlight(): void {
|
||||
const host = document.getElementById("submission-draft-preview");
|
||||
if (!host) return;
|
||||
host.querySelectorAll<HTMLElement>(".draft-var--active").forEach((el) => {
|
||||
el.classList.remove("draft-var--active");
|
||||
});
|
||||
}
|
||||
|
||||
function flashVarRow(input: HTMLElement): void {
|
||||
const row = input.closest<HTMLElement>(".submission-draft-var-row");
|
||||
if (!row) return;
|
||||
@@ -710,6 +986,69 @@ function flashVarRow(input: HTMLElement): void {
|
||||
// Event handlers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function onPartySelectionChange(): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const host = document.getElementById("submission-draft-parties-list");
|
||||
if (!host) return;
|
||||
const checks = host.querySelectorAll<HTMLInputElement>(".submission-draft-party-check");
|
||||
const selectedIDs: string[] = [];
|
||||
checks.forEach((c) => {
|
||||
if (c.checked && c.dataset.partyId) selectedIDs.push(c.dataset.partyId);
|
||||
});
|
||||
|
||||
// If the lawyer has checked every party, persist that as an empty
|
||||
// array so the row matches the "implicit all" default semantics — a
|
||||
// future party added to the project will then be picked up
|
||||
// automatically rather than silently dropped from this submission.
|
||||
// If they've unchecked some, persist the actual subset.
|
||||
const available = state.view.available_parties ?? [];
|
||||
const allChecked = selectedIDs.length === available.length;
|
||||
const payload = allChecked ? [] : selectedIDs;
|
||||
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
try {
|
||||
const view = await patchDraft({ selected_parties: payload });
|
||||
state.view = view;
|
||||
paintImportRow();
|
||||
paintPartyPicker();
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
console.error("submission-draft party selection:", err);
|
||||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const draftID = state.view.draft.id;
|
||||
const originalLabel = btn.textContent ?? "";
|
||||
btn.disabled = true;
|
||||
btn.textContent = isEN() ? "Importing…" : "Importiert…";
|
||||
setSaveStatus(isEN() ? "Importing from project…" : "Importiere aus Projekt…");
|
||||
try {
|
||||
const resp = await fetch(`/api/submission-drafts/${draftID}/import-from-project`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!resp.ok) throw new Error(`import ${resp.status}`);
|
||||
const view = (await resp.json()) as SubmissionDraftView;
|
||||
state.view = view;
|
||||
paintImportRow();
|
||||
paintPartyPicker();
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
setSaveStatus(isEN() ? "Imported" : "Importiert");
|
||||
} catch (err) {
|
||||
console.error("submission-draft import-from-project:", err);
|
||||
setSaveStatus(isEN() ? "Import failed" : "Import fehlgeschlagen", true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function onVarChange(input: HTMLInputElement): void {
|
||||
const key = input.dataset.var;
|
||||
if (!key || !state.view) return;
|
||||
|
||||
@@ -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,37 @@ 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);
|
||||
}
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
@@ -210,6 +255,7 @@ async function doCalc() {
|
||||
flags: readFlags(),
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
perCardChoices,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
@@ -302,6 +348,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 +439,125 @@ 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.both");
|
||||
}
|
||||
|
||||
// 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";
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -529,6 +699,44 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
// t-paliad-265 — per-event-card choices. Unbound surface, so commits
|
||||
// mutate the in-memory list + URL, then trigger a recalc. The
|
||||
// popover module owns the popover lifecycle; this page owns the
|
||||
// recalc + URL plumbing.
|
||||
perCardChoices = readChoicesFromURL();
|
||||
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 +747,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();
|
||||
});
|
||||
|
||||
292
frontend/src/client/views/event-card-choices.ts
Normal file
292
frontend/src/client/views/event-card-choices.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
// 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 target = (e.target as HTMLElement | null)?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (target) {
|
||||
e.stopPropagation();
|
||||
openPopover(state, target);
|
||||
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 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[] = [];
|
||||
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>`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -191,6 +191,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,17 @@ 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;
|
||||
}
|
||||
|
||||
// priorityRendering returns the per-priority UX hints the save-modal
|
||||
@@ -139,6 +150,16 @@ 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;
|
||||
}>;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -272,6 +293,18 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
? '<span class="optional-badge">optional</span>'
|
||||
: "";
|
||||
|
||||
// t-paliad-265 — caret affordance + chip indicator when this rule
|
||||
// offers per-card choices and the user has made a pick. The popover
|
||||
// open/commit lifecycle lives in client/views/event-card-choices.ts;
|
||||
// the data-* attributes here are the wire contract between the two.
|
||||
const choicesHtml = dl.code !== "" && dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0
|
||||
? `<button type="button" class="event-card-choices-caret"
|
||||
data-submission-code="${escAttr(dl.code)}"
|
||||
data-choices-offered="${escAttr(JSON.stringify(dl.choicesOffered))}"
|
||||
aria-label="${escAttr(t("choices.caret.title"))}"
|
||||
title="${escAttr(t("choices.caret.title"))}">▾</button>`
|
||||
: "";
|
||||
|
||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||
|
||||
const adjustedNote = dl.wasAdjusted
|
||||
@@ -310,12 +343,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}
|
||||
${chipHtml}
|
||||
</span>
|
||||
${dateStr}
|
||||
${choicesHtml}
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
@@ -532,7 +575,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);
|
||||
@@ -625,6 +676,10 @@ 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,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -1008,6 +1008,23 @@ 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.skip.false"
|
||||
| "choices.skip.title"
|
||||
| "choices.skip.true"
|
||||
| "choices.skipped.chip"
|
||||
| "common.cancel"
|
||||
| "common.close"
|
||||
| "common.forbidden"
|
||||
@@ -1446,7 +1463,9 @@ export type I18nKey =
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.from_project"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.side.override"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
@@ -1477,6 +1496,7 @@ export type I18nKey =
|
||||
| "deadlines.step2.happened.desc"
|
||||
| "deadlines.step2.happened.title"
|
||||
| "deadlines.step2.heading"
|
||||
| "deadlines.step2.perspective"
|
||||
| "deadlines.step3"
|
||||
| "deadlines.step3a.back"
|
||||
| "deadlines.step3a.draft.desc"
|
||||
@@ -2599,9 +2619,16 @@ export type I18nKey =
|
||||
| "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"
|
||||
|
||||
@@ -3476,6 +3476,133 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--status-amber-fg);
|
||||
}
|
||||
|
||||
/* t-paliad-265 — per-event-card optional choices. The caret sits in
|
||||
* the card header next to the date; the chip surfaces the active pick
|
||||
* inline with the title; the popover is body-attached and positioned
|
||||
* by the JS module. Skipped rows fade to 50% opacity. */
|
||||
.event-card-choices-caret {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-left: 0.4rem;
|
||||
border: 1px solid var(--color-border, #d4d4d4);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.event-card-choices-caret:hover,
|
||||
.event-card-choices-caret:focus-visible {
|
||||
background: var(--color-accent-bg, rgba(198, 244, 28, 0.18));
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.event-card-choices-chip {
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.event-card-choices-chip[data-empty="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.event-card-choices-chip-part {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 99px;
|
||||
background: var(--color-accent-bg, rgba(198, 244, 28, 0.22));
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.event-card-choices-chip-part--skipped {
|
||||
background: var(--color-bg-soft, #f1f1f1);
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.timeline-item--skipped {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.event-card-choices-popover {
|
||||
background: var(--color-bg, #fff);
|
||||
border: 1px solid var(--color-border, #d4d4d4);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
padding: 0.6rem 0.7rem;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.event-card-choices-block + .event-card-choices-block {
|
||||
margin-top: 0.7rem;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px solid var(--color-border-soft, #ececec);
|
||||
}
|
||||
|
||||
.event-card-choices-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.event-card-choices-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.event-card-choices-option {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border, #d4d4d4);
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-card-choices-option:hover,
|
||||
.event-card-choices-option:focus-visible {
|
||||
background: var(--color-bg-soft, #f1f1f1);
|
||||
}
|
||||
|
||||
.event-card-choices-option--active {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-card-choices-reset {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.event-card-choices-reset:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.event-card-choices-error {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--status-red-fg, #b00020);
|
||||
}
|
||||
|
||||
.timeline-rule {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
@@ -3572,6 +3699,59 @@ input[type="range"]::-moz-range-thumb {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Visual divider between the perspective block (side + appellant)
|
||||
and the date / court / flag knobs below. t-paliad-279 reorder put
|
||||
the most-defining inputs (side, appellant) at the top of step-2; the
|
||||
divider keeps the date block from reading as a continuation of the
|
||||
perspective rows. */
|
||||
.verfahrensablauf-step2-divider {
|
||||
height: 1px;
|
||||
margin: 1rem 0;
|
||||
background: var(--color-border, #e5e5e5);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Read-only auto-fill chip for #side-row. Renders when ?project=<id>
|
||||
resolves a project whose our_side is set: shows the inferred side
|
||||
with a small "Andere Seite wählen" override link that swaps the row
|
||||
back to the radio cluster. t-paliad-279 / m/paliad#111. */
|
||||
.side-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #e5e5e5);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-bg-subtle, #fafafa);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.side-chip-tag {
|
||||
color: var(--color-text-muted, #666);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.side-chip-value {
|
||||
color: var(--color-text, #222);
|
||||
}
|
||||
.side-chip-override {
|
||||
margin-left: 0.3rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 9999px;
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text-muted, #555);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
.side-chip-override:hover {
|
||||
background: var(--color-bg-subtle, #f4f4f4);
|
||||
border-color: var(--color-text-muted, #aaa);
|
||||
}
|
||||
.side-chip-override:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Compact note hint — sits in the timeline-meta line when the notes
|
||||
toggle is off. Native browser tooltip via title= attribute carries
|
||||
the full text on hover; tabindex=0 + aria-label make it
|
||||
@@ -5774,6 +5954,40 @@ dialog.modal::backdrop {
|
||||
color: var(--color-danger, #c00);
|
||||
}
|
||||
|
||||
/* t-paliad-276 — DE/EN language toggle on the draft editor. Same look
|
||||
as the rest of the sidebar mini-controls; muted label + inline radios
|
||||
so it doesn't compete with the editor's primary inputs. */
|
||||
.submission-draft-language-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0.25rem 0 0.5rem 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.submission-draft-language-label {
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.submission-draft-language-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submission-draft-language-fallback {
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.5rem 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-left: 2px solid var(--color-warning, #d4a017);
|
||||
background: var(--color-warning-bg, rgba(212, 160, 23, 0.08));
|
||||
}
|
||||
|
||||
.submission-draft-variables {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -5880,16 +6094,27 @@ dialog.modal::backdrop {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* t-paliad-261 (B) — substituted variables in the preview are wrapped
|
||||
in <span class="draft-var" data-var="…"> by the Go HTML renderer.
|
||||
.draft-var by itself shows a subtle dotted underline so the lawyer
|
||||
can SEE which text was filled in from a variable. .draft-var--has-input
|
||||
(added client-side when a matching sidebar input exists) layers on
|
||||
the clickable affordance — pointer cursor + brighter hover background.
|
||||
Non-matching draft-vars (derived variables not exposed in the
|
||||
sidebar) stay visually distinct but non-interactive. */
|
||||
/* t-paliad-261 / t-paliad-274 — substituted variables in the preview
|
||||
are wrapped in <span class="draft-var" data-var="…"> by the Go HTML
|
||||
renderer for BOTH filled values and missing-marker text. The lawyer
|
||||
can click any wrapped span and jump to the matching sidebar input;
|
||||
conversely, focusing a sidebar input lights up every matching span in
|
||||
the preview via .draft-var--active.
|
||||
|
||||
Visual contract:
|
||||
.draft-var — invisible by default (prose stays clean
|
||||
per t-paliad-274 m's request).
|
||||
.draft-var--has-input — pointer cursor; dotted underline on
|
||||
hover so the click affordance reveals
|
||||
itself, plus a brighter lime tint.
|
||||
.draft-var--active — sticky lime highlight applied while the
|
||||
matching sidebar input is focused
|
||||
(t-paliad-274 reverse direction).
|
||||
[KEIN WERT: …] / [NO VALUE: …] markers carry their own warning
|
||||
style via .submission-draft-var-marker on the sidebar hint; in the
|
||||
preview they read as obvious gap text, so .draft-var itself doesn't
|
||||
need an always-on visual to flag them. */
|
||||
.draft-var {
|
||||
background-color: rgba(198, 244, 28, 0.12);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
box-decoration-break: clone;
|
||||
@@ -5904,9 +6129,21 @@ dialog.modal::backdrop {
|
||||
.draft-var--has-input:hover,
|
||||
.draft-var--has-input:focus-visible {
|
||||
background-color: rgba(198, 244, 28, 0.45);
|
||||
text-decoration: underline dotted rgba(198, 244, 28, 0.85);
|
||||
text-underline-offset: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* t-paliad-274 (B) — sticky highlight while the matching sidebar input
|
||||
is focused. Brighter than the hover tint so the user's eye lands on
|
||||
every occurrence at once when they click into a field. Applies to ALL
|
||||
.draft-var spans for that data-var, not just one. */
|
||||
.draft-var--active,
|
||||
.draft-var--has-input.draft-var--active {
|
||||
background-color: rgba(198, 244, 28, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(198, 244, 28, 0.85);
|
||||
}
|
||||
|
||||
/* t-paliad-261 (B) — brief lime flash on the sidebar row after a
|
||||
click-jump from the preview, so the user's eye lands on the right
|
||||
input even after the smooth-scroll motion. Animation restarts on
|
||||
@@ -6049,6 +6286,96 @@ dialog.modal::backdrop {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* t-paliad-277 — "Aus Projekt importieren" row + multi-select party
|
||||
picker block on the submission draft editor sidebar. */
|
||||
|
||||
.submission-draft-import-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.submission-draft-import-stamp {
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-parties {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.submission-draft-parties-hint {
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.submission-draft-parties-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-draft-parties-group {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.submission-draft-parties-group > legend {
|
||||
padding: 0 0.4rem;
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-party-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submission-draft-party-check {
|
||||
margin: 0;
|
||||
accent-color: var(--color-accent, #c6f41c);
|
||||
}
|
||||
|
||||
.submission-draft-party-name {
|
||||
font-size: 0.92em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.submission-draft-party-chip {
|
||||
font-size: 0.72em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.1em 0.5em;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-lime-tint, #f0fac6);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.submission-draft-party-rep {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.checklist-instance-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
@@ -8279,6 +8606,7 @@ input.rule-mode-custom {
|
||||
}
|
||||
|
||||
.fristen-step1-search-row .fristen-search-icon {
|
||||
position: static;
|
||||
color: var(--color-muted, #666);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -17610,9 +17938,10 @@ dialog.quick-add-sheet::backdrop {
|
||||
}
|
||||
|
||||
.date-range-panel {
|
||||
/* Inherits .multi-panel positioning + border + shadow. Widen it so
|
||||
the symmetric fan + the custom editor have room to breathe. */
|
||||
width: 32rem;
|
||||
/* Inherits .multi-panel positioning + border + shadow. Sized so the
|
||||
3-column grid holds the widest chip text ("Ganze Vergangenheit")
|
||||
without wrapping while staying within the viewport on tablets. */
|
||||
width: 34rem;
|
||||
max-width: calc(100vw - 1rem);
|
||||
top: 100%;
|
||||
left: 0;
|
||||
@@ -17620,88 +17949,54 @@ dialog.quick-add-sheet::backdrop {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.date-range-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
.date-range-grid {
|
||||
/* Past / NOW / Future as three equal vertical columns. Each column
|
||||
is a top-aligned chip stack so closeness-to-NOW (closest at top,
|
||||
farthest at bottom) reads spatially. */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.date-range-fan {
|
||||
.date-range-col {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
align-content: flex-start;
|
||||
flex: 1 1 12rem;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.date-range-fan--past {
|
||||
/* Past fan: outermost chip (Ganze Vergangenheit) leftmost. */
|
||||
justify-content: flex-end;
|
||||
.date-range-col--now {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.date-range-fan--next {
|
||||
/* Future fan: innermost chip (Morgen / next_1d) leftmost. */
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.date-range-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.date-range-center-btn {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
background: var(--color-surface-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.6rem;
|
||||
min-width: 4.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.date-range-center-btn:hover {
|
||||
background: var(--color-overlay-subtle);
|
||||
border-color: var(--color-accent-light);
|
||||
}
|
||||
.date-range-center-btn:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.date-range-center-btn--active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.date-range-center-glyph {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.date-range-center-label {
|
||||
.date-range-col-heading {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted, #71717a);
|
||||
text-align: center;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.date-range-col-heading--glyph {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--color-text-muted, #71717a);
|
||||
}
|
||||
|
||||
.date-range-chip {
|
||||
/* .agenda-chip provides bg/border/radius/typography; this modifier
|
||||
only tightens horizontal padding so more chips fit per row. */
|
||||
padding: 0.3rem 0.65rem;
|
||||
/* .agenda-chip provides bg/border/radius/typography; in the
|
||||
3-column stack each chip fills its column so the closeness-to-NOW
|
||||
ordering reads as a single vertical column rather than a ragged
|
||||
row. */
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-range-chip--custom {
|
||||
@@ -17788,17 +18083,14 @@ dialog.quick-add-sheet::backdrop {
|
||||
color: var(--status-red-fg, #b91c1c);
|
||||
}
|
||||
|
||||
/* Mobile: stack past / centre / next vertically so each fan gets
|
||||
the full popover width. */
|
||||
/* Mobile: stack the 3 columns vertically (one column per row),
|
||||
preserving the closeness-to-NOW sort within each column. */
|
||||
@media (max-width: 540px) {
|
||||
.date-range-panel {
|
||||
width: calc(100vw - 1rem);
|
||||
}
|
||||
.date-range-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.date-range-fan--past,
|
||||
.date-range-fan--next {
|
||||
justify-content: flex-start;
|
||||
.date-range-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,8 +109,93 @@ 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: multi-select party picker.
|
||||
Populated from view.available_parties; checkbox
|
||||
per party, grouped by role. Hidden when no
|
||||
project or no parties on the project. */}
|
||||
<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,79 @@ 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.both">Beide</span>
|
||||
</label>
|
||||
</div>
|
||||
</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>
|
||||
</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 +280,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 +
|
||||
|
||||
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.';
|
||||
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
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)
|
||||
}
|
||||
@@ -97,6 +97,20 @@ var fileRegistry = map[string]fileEntry{
|
||||
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
|
||||
@@ -113,6 +127,11 @@ const skeletonSubmissionSlug = "submission/_skeleton.docx"
|
||||
// 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
|
||||
@@ -122,14 +141,32 @@ const firmSkeletonSubmissionSlug = "submission/_firm-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 {
|
||||
@@ -235,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
|
||||
|
||||
@@ -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,15 @@ 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"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -61,11 +73,42 @@ 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,
|
||||
})
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,52 +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 and the t-paliad-275
|
||||
// firm-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. firm-formatted _firm-skeleton.docx — full HL paragraph + character
|
||||
// styles (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
|
||||
// HLpat-Table-Recitals-*, HLpat-Signature, …) preserved from the
|
||||
// source .dotm, the firm letterhead header/footer, plus the full
|
||||
// 48-key placeholder bag. Catches every code without a dedicated
|
||||
// template so the editor still renders firm-branded output.
|
||||
// 3. universal _skeleton.docx — same variable bag, no firm formatting.
|
||||
// Backstop for when the firm skeleton is unreachable (e.g. a future
|
||||
// firm hasn't authored one yet).
|
||||
// 4. universal HL Patents Style .dotm — macro-only letterhead, no
|
||||
// placeholders. Final fallback when even both skeletons are
|
||||
// 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
|
||||
}
|
||||
// 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, nil
|
||||
return data, sha, tplTierSkeleton, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s, falling back to universal skeleton: %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, 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: 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
|
||||
@@ -969,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"`
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,18 @@ type UIDeadline struct {
|
||||
// court itself.
|
||||
IsCourtSetIndirect bool `json:"isCourtSetIndirect,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"`
|
||||
}
|
||||
|
||||
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
|
||||
@@ -179,6 +191,29 @@ 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{}
|
||||
}
|
||||
|
||||
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||
@@ -233,6 +268,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 +372,21 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
courtSet := make(map[uuid.UUID]bool, len(rules))
|
||||
deadlines := make([]UIDeadline, 0, len(rules))
|
||||
|
||||
// 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))
|
||||
// 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 +399,49 @@ 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.
|
||||
if r.SubmissionCode != nil {
|
||||
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
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),
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
|
||||
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
|
||||
|
||||
@@ -72,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
|
||||
@@ -125,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"
|
||||
}
|
||||
@@ -174,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
|
||||
@@ -184,6 +206,30 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -324,42 +370,98 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user