Compare commits
169 Commits
mai/lorenz
...
mai/darwin
| Author | SHA1 | Date | |
|---|---|---|---|
| aa435e5435 | |||
| 3966394a39 | |||
| 5dacc97a6b | |||
| 15bcba5d7c | |||
| 48f78a713b | |||
| a421bff856 | |||
| 0aa81139a3 | |||
| fbd087e0cd | |||
| 8bac1b4f88 | |||
| 1fcfab7791 | |||
| 12ed8bb8da | |||
| 7654ce6833 | |||
| f3b947e3ad | |||
| f0b08e9d06 | |||
| 760a0de931 | |||
| bc8dc9d048 | |||
| 694c7a53ad | |||
| 81cb89f68e | |||
| a6b2979a94 | |||
| 8f1f88b517 | |||
| d5c80febb1 | |||
| 1765d5e55f | |||
| c85c382b1b | |||
| 7a359989a9 | |||
| 1a8eee2a10 | |||
| 4472faf224 | |||
| 2504e50f29 | |||
| d244ff5158 | |||
| 741cab4d25 | |||
| 0263a0e932 | |||
| 0fd02bf033 | |||
| dce98e273b | |||
| c1c5532d52 | |||
| ee837815e1 | |||
| e035512e70 | |||
| 6401a8198d | |||
| 6a202411f6 | |||
| d924ab9743 | |||
| fb2896c836 | |||
| 705e1a2e79 | |||
| d8acbd613c | |||
| c01f3f2db8 | |||
| 2fa47278ce | |||
| 6c7e9ef44d | |||
| 17cd5b3b0c | |||
| d127c768f7 | |||
| dab06e068f | |||
| defa516e4f | |||
| 6ff26e8a6e | |||
| 2c94420a4b | |||
| 3677c81fbe | |||
| 8ea3509b98 | |||
| 5ff637ab70 | |||
| 265f240151 | |||
| 1039680878 | |||
| 773654523e | |||
| f7585376df | |||
| f9ff7b93e8 | |||
| 86d20ed6d4 | |||
| 1639b3919a | |||
| bf31935767 | |||
| aee177a303 | |||
| 28c7215458 | |||
| 9aebe5780b | |||
| 8a43aed100 | |||
| 52b3feb9d2 | |||
| 586ba29b86 | |||
| 0b57ec5257 | |||
| 2007ad39bb | |||
| b7c4de9ac9 | |||
| 8e0e4c9dcc | |||
| 023f32d4f2 | |||
| 621fe35d79 | |||
| 139c4a6406 | |||
| 6e8e2e7653 | |||
| de20356cec | |||
| 8414aa4c14 | |||
| 1e1c84b0f6 | |||
| e1b91a9481 | |||
| 92780cf726 | |||
| a0082d2b0d | |||
| c921925c68 | |||
| 22cfdb909f | |||
| 4ddcd28d26 | |||
| c10f8cff70 | |||
| 5ae1e5ad01 | |||
| 06c826a818 | |||
| 8020cb2ddb | |||
| a5b94739b4 | |||
| 283c9e8f67 | |||
| dece61107b | |||
| 8bf1626997 | |||
| 7f49851abf | |||
| 518b2d9617 | |||
| 4131d2e2a6 | |||
| d507db22a7 | |||
| a0a3ec32a3 | |||
| f9d32a90e7 | |||
| a18b825bee | |||
| 7d275cac6b | |||
| 21727bf1ca | |||
| d126913185 | |||
| ea29165d2f | |||
| bc5b3557d0 | |||
| bd2c7a217e | |||
| edcf41d203 | |||
| 391be09b1e | |||
| d76b8a6c64 | |||
| 061780dea5 | |||
| b07702a095 | |||
| aa9e47fda9 | |||
| 216abbfc98 | |||
| cce0ada3ce | |||
| e857829ac2 | |||
| 1d535a2175 | |||
| af30c06d9b | |||
| 8833c6975a | |||
| 0123d11c6e | |||
| 4d2382679b | |||
| 35aa5e63c0 | |||
| 3c9ecabf17 | |||
| aa82434af9 | |||
| 4f66feffce | |||
| bdd4999213 | |||
| cbcc67bae7 | |||
| 40e49e87d4 | |||
| 2686d43a38 | |||
| 29a6b58747 | |||
| 4361c65887 | |||
| 6fc8c0136e | |||
| 8b6b9254ed | |||
| a33060e600 | |||
| d7b2292f8f | |||
| ff8f95abaa | |||
| 84aadc838a | |||
| c4564b4031 | |||
| 7dae9b2216 | |||
| 99a72a744f | |||
| f9305d6108 | |||
| 7f72ee7b9e | |||
| d027b0874c | |||
| 7571e43078 | |||
| c7b48f6ea7 | |||
| 8f6cee5a83 | |||
| edc81bbbc2 | |||
| 08e20883a5 | |||
| 86946ba441 | |||
| 193b988798 | |||
| 1c45c93570 | |||
| 36bdfecb04 | |||
| 936c4967fd | |||
| 7decc5095f | |||
| b21ce6dd7b | |||
| 358c64d172 | |||
| 5d22e5db21 | |||
| 09615ec48e | |||
| 5431fcd3cd | |||
| 16ae2f0cf0 | |||
| 4c3d091280 | |||
| d6f5e0c97e | |||
| a55f45ebea | |||
| 6f77c8354c | |||
| b64d929586 | |||
| e30bfe89da | |||
| d8edea0f4c | |||
| 65617a5dcb | |||
| 7bfec310a0 | |||
| 253dc1d1b3 | |||
| 992b99c375 |
@@ -47,9 +47,13 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
|
||||
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
|
||||
| `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). |
|
||||
| `PALIADIN_SESSION_PREFIX` | optional (default `paliad-paliadin`) | Prefix for the per-user tmux session names the Paliadin service uses (t-paliad-155). Each Paliad user gets their own session named `<prefix>-<userid8>` (first 8 hex chars of the user's UUID); conversation history accumulates per visit, `ResetSession` kills the session entirely. The persona + response protocol now live in `~/.claude/skills/paliadin/SKILL.md` (installed via `scripts/install-paliadin-skill`) — no in-process system prompt is sent. |
|
||||
| `PALIADIN_SESSION_PREFIX` | optional (default `paliad-paliadin`) | Prefix for the per-user tmux session names the legacy Paliadin service uses (t-paliad-155). Each Paliad user gets their own session named `<prefix>-<userid8>` (first 8 hex chars of the user's UUID); conversation history accumulates per visit, `ResetSession` kills the session entirely. **Skill source-of-truth moved to `m/mAi` under `skills/aichat/paliadin/` (m's 2026-05-13 decision, t-paliad-194).** The aichat backend owns installation on mRiver via its own deploy doc (`m/mAi/docs/reference/aichat-deploy.md`). Legacy `LocalPaliadinService` (PoC) and `RemotePaliadinService` (shim) still rely on `~/.claude/skills/paliadin/SKILL.md` being present on the target host — install it manually from the aichat repo until those paths are retired. |
|
||||
| `PALIADIN_REMOTE_CWD` | shim env (default `/home/m/dev/paliad`) | Working directory `paliadin-shim` uses when spawning the long-lived `claude` pane on mRiver. Must be the paliad repo root so claude picks up `.mcp.json` (project-scoped Supabase MCP); without it, the SKILL.md SQL recipes have no DB tool. Set on mRiver only — paliad's Go side never reads this. |
|
||||
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. |
|
||||
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. (Legacy `LocalPaliadinService` path only — aichat owns its own response dir at `/tmp/aichat/paliadin/`.) |
|
||||
| `PALIADIN_BACKEND` | optional (default `legacy`) | Selects which Paliadin backend boots (t-paliad-194 / m/paliad#38 Phase B). `legacy` keeps the existing tree (`PALIADIN_REMOTE_HOST` → SSH shim, else local tmux, else disabled). `aichat` opts into the centralized `m/mAi#207` backend on mRiver — `RemotePaliadinService`/`LocalPaliadinService` are bypassed and `AichatPaliadinService` issues HTTP calls instead. Parallel paths during the migration window; flip back is one env-var change. |
|
||||
| `AICHAT_URL` | required when `PALIADIN_BACKEND=aichat` | Aichat service root (typically `http://100.99.98.203:8765` over Tailscale; see `m/mAi/docs/reference/aichat-deploy.md`). No trailing slash needed. |
|
||||
| `AICHAT_TOKEN` | required when `PALIADIN_BACKEND=aichat` | Raw bearer token registered for paliad's app_id in aichat's `tokens.yaml`. Distributed via Dokploy secret per Q11 (age-encrypted at rest). |
|
||||
| `AICHAT_PERSONA` | optional (default `paliadin`) | Persona id to target. Override only when running a non-default deploy (e.g. staging persona). |
|
||||
|
||||
> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy.
|
||||
| `FIRM_NAME` | optional (default `HLC`) | Display name of the firm Paliad is being branded for in this deployment. Read once at process start by `internal/branding.Name` (Go) and inlined into client bundles by `frontend/build.ts` (TypeScript). Powers every user-facing surface — landing hero, page titles, login hint, Downloads page, footer, invitation/reminder email bodies. The `ALLOWED_EMAIL_DOMAINS` whitelist is a separate concern (real DNS domains, not display name) and rotates independently. |
|
||||
|
||||
73
Makefile
Normal file
73
Makefile
Normal file
@@ -0,0 +1,73 @@
|
||||
# Paliad — developer entrypoints.
|
||||
#
|
||||
# Targets here are the gate tier from the test-strategy design
|
||||
# (docs/design-paliad-test-strategy-2026-05-19.md). Slice 1 lands:
|
||||
#
|
||||
# make verify-migrations — dry-run every pending migration (BEGIN..ROLLBACK)
|
||||
# plus the full boot smoke (apply + tracker
|
||||
# advances + /healthz returns 200).
|
||||
# make verify-mig — alias for verify-migrations.
|
||||
# make test — short test pass: go test ./internal/... -short
|
||||
# plus the cmd/server package. Includes the
|
||||
# live-DB tests when TEST_DATABASE_URL is set,
|
||||
# skips them otherwise.
|
||||
# make test-go — go test ./... -race (full Go suite).
|
||||
#
|
||||
# Future slices will extend this with:
|
||||
# make test-frontend — bun test (Slice 3 / Slice 6)
|
||||
# make e2e — Playwright golden-path suite (Slice 4)
|
||||
#
|
||||
# All targets are idempotent. None of them write to the filesystem outside
|
||||
# the test runner's working dirs. None of them touch internal/db/migrations/
|
||||
# files.
|
||||
|
||||
.PHONY: help verify-migrations verify-mig test test-go
|
||||
|
||||
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 " test Short test pass — covers gate tier"
|
||||
@echo " test-go Full Go suite with race detector"
|
||||
@echo ""
|
||||
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
|
||||
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
|
||||
|
||||
# Gate target — the test that would have caught mig 098 / mig 099 before
|
||||
# deploy. Combines:
|
||||
# - TestMigrations_DryRun (internal/db): per-migration BEGIN..ROLLBACK
|
||||
# - TestBootSmoke (cmd/server): apply-end-to-end + tracker advances
|
||||
# + /healthz 200
|
||||
#
|
||||
# Requires TEST_DATABASE_URL. Without it, both tests skip and the target
|
||||
# is effectively a no-op — guard against that explicitly so CI doesn't
|
||||
# silently green a missing env var.
|
||||
verify-migrations:
|
||||
@if [ -z "$$TEST_DATABASE_URL" ]; then \
|
||||
echo "ERROR: TEST_DATABASE_URL is not set."; \
|
||||
echo " The migration gate cannot run without a scratch DB."; \
|
||||
echo " Set TEST_DATABASE_URL to a Postgres URL the test can"; \
|
||||
echo " open transactions against, e.g."; \
|
||||
echo " export TEST_DATABASE_URL=postgres://paliad:PW@localhost:11833/paliad_test"; \
|
||||
exit 2; \
|
||||
fi
|
||||
@echo "==> migration dry-run (per-mig BEGIN..ROLLBACK)"
|
||||
go test -count=1 -run TestMigrations_DryRun ./internal/db/
|
||||
@echo "==> boot smoke (apply + tracker + /healthz)"
|
||||
go test -count=1 -run TestBootSmoke ./cmd/server/
|
||||
|
||||
verify-mig: verify-migrations
|
||||
|
||||
# Gate-tier test pass. -short skips the slow live-DB tests when the
|
||||
# author opts out via `if testing.Short() { t.Skip(...) }`; today most of
|
||||
# paliad's live-DB tests gate on TEST_DATABASE_URL instead, so -short is
|
||||
# forward-compatible rather than load-bearing.
|
||||
test:
|
||||
go test -short ./internal/... ./cmd/...
|
||||
|
||||
# Full Go suite with race detection. Slower but catches concurrent-map
|
||||
# regressions that -short would skip; intended for the merge-to-main gate
|
||||
# (full suite, not per-PR).
|
||||
test-go:
|
||||
go test -race ./...
|
||||
@@ -117,7 +117,9 @@ func main() {
|
||||
}
|
||||
|
||||
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
|
||||
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
|
||||
bindingSvc := services.NewCalendarBindingService(pool)
|
||||
targetSvc := services.NewAppointmentTargetService(pool)
|
||||
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc, bindingSvc, targetSvc)
|
||||
// Wire the push hook so user-driven mutations sync to the external
|
||||
// calendar without waiting for the next 60-second tick.
|
||||
appointmentSvc.SetCalDAVPusher(caldavSvc)
|
||||
@@ -143,6 +145,7 @@ func main() {
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
CalDAVBindings: bindingSvc,
|
||||
Rules: rules,
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
@@ -154,6 +157,8 @@ func main() {
|
||||
courts,
|
||||
services.NewFristenrechnerService(rules, holidays, courts),
|
||||
),
|
||||
EventTrigger: services.NewEventTriggerService(pool, rules, holidays, courts),
|
||||
RuleEditor: services.NewRuleEditorService(pool, rules),
|
||||
Courts: courts,
|
||||
DeadlineSearch: services.NewDeadlineSearchService(pool),
|
||||
EventCategory: nil, // wired below; cross-link order matters
|
||||
@@ -173,43 +178,89 @@ func main() {
|
||||
UserView: services.NewUserViewService(pool),
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
DashboardLayout: services.NewDashboardLayoutService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
// t-paliad-214 Slice 1 — personal-scope data export. firm name
|
||||
// is captured into __meta of every export and printed in the
|
||||
// embedded README.
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
}
|
||||
|
||||
// Paliadin backend selection (t-paliad-146 + t-paliad-151):
|
||||
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh to mRiver)
|
||||
// else: local tmux available → LocalPaliadinService (PoC path)
|
||||
// else: DisabledPaliadinService (handlers still 404 for non-owners
|
||||
// via the gate; for m, RunTurn returns ErrPaliadinDisabled
|
||||
// which surfaces as a friendly error).
|
||||
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
|
||||
// for the inbox-approvals widget. Done post-construction to avoid
|
||||
// a circular constructor dependency (ApprovalService doesn't need
|
||||
// the dashboard, and DashboardService can render its other widgets
|
||||
// without approvals — so keeping this a setter keeps both
|
||||
// constructors simple).
|
||||
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
|
||||
|
||||
// t-paliad-215 Slice 1 — submission generator. Three services
|
||||
// stitched together by handlers/submissions.go: registry pulls
|
||||
// templates from Gitea (reuses GITEA_TOKEN env), vars builds
|
||||
// the placeholder map from project + parties + rule, renderer
|
||||
// merges {{placeholder}} tokens into the .docx.
|
||||
svcBundle.SubmissionRegistry = services.NewTemplateRegistry(giteaToken, branding.Name)
|
||||
svcBundle.SubmissionVars = services.NewSubmissionVarsService(
|
||||
pool,
|
||||
svcBundle.Project,
|
||||
svcBundle.Party,
|
||||
svcBundle.Users,
|
||||
)
|
||||
svcBundle.SubmissionRenderer = services.NewSubmissionRenderer()
|
||||
|
||||
// Paliadin backend selection.
|
||||
//
|
||||
// All three implement services.Paliadin; the per-request handler
|
||||
// gate (requirePaliadinOwner) is unchanged and applies to every
|
||||
// backend.
|
||||
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
|
||||
cfg, err := buildPaliadinRemoteConfig(remoteHost)
|
||||
// PALIADIN_BACKEND (t-paliad-194 / m/paliad#38):
|
||||
// "aichat" → AichatPaliadinService (HTTP client of the
|
||||
// centralized aichat backend on mRiver,
|
||||
// shipped in m/mAi#207 Phase A).
|
||||
// "legacy" / unset / etc → fall through to the pre-aichat tree:
|
||||
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh shim)
|
||||
// else: local tmux available → LocalPaliadinService (PoC path)
|
||||
// else → DisabledPaliadinService
|
||||
//
|
||||
// The aichat path is opt-in for the migration window so a flip
|
||||
// back is one env-var change. Once aichat soaks, legacy can be
|
||||
// retired in a follow-up slice.
|
||||
//
|
||||
// All four implementations satisfy services.Paliadin; the per-
|
||||
// request handler gate (requirePaliadinOwner) is unchanged.
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("PALIADIN_BACKEND"))) {
|
||||
case "aichat":
|
||||
cfg, err := buildAichatPaliadinConfig(jwtSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("paliadin: remote config: %v", err)
|
||||
log.Fatalf("paliadin: aichat config: %v", err)
|
||||
}
|
||||
svcBundle.Paliadin = services.NewAichatPaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: aichat mode → %s persona=%s (owner=%s, rls=%s)",
|
||||
cfg.BaseURL, cfg.Persona, services.PaliadinOwnerEmail,
|
||||
rlsModeLabel(cfg.JWTSecret))
|
||||
default:
|
||||
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
|
||||
cfg, err := buildPaliadinRemoteConfig(remoteHost)
|
||||
if err != nil {
|
||||
log.Fatalf("paliadin: remote config: %v", err)
|
||||
}
|
||||
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
||||
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
||||
} else if _, err := exec.LookPath("tmux"); err == nil {
|
||||
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||
// Late-response janitor — patches rows when Claude writes the
|
||||
// response file after the 60 s pollForResponse window expires.
|
||||
// Runs for the process lifetime; cleaned up when bgCtx
|
||||
// cancels on SIGTERM.
|
||||
local.StartJanitor(bgCtx)
|
||||
svcBundle.Paliadin = local
|
||||
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
|
||||
} else {
|
||||
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
||||
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
|
||||
services.PaliadinOwnerEmail)
|
||||
}
|
||||
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
||||
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
||||
} else if _, err := exec.LookPath("tmux"); err == nil {
|
||||
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||
// Late-response janitor — patches rows when Claude writes the
|
||||
// response file after the 60 s pollForResponse window expires.
|
||||
// Runs for the process lifetime; cleaned up when bgCtx
|
||||
// cancels on SIGTERM.
|
||||
local.StartJanitor(bgCtx)
|
||||
svcBundle.Paliadin = local
|
||||
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
|
||||
} else {
|
||||
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
||||
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
|
||||
services.PaliadinOwnerEmail)
|
||||
}
|
||||
// Wire ApprovalService into the entity services so Create / Update /
|
||||
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
|
||||
@@ -380,3 +431,49 @@ func cmpOr(s, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// buildAichatPaliadinConfig assembles an AichatPaliadinConfig from the
|
||||
// environment for PALIADIN_BACKEND=aichat (t-paliad-194 / m/paliad#38).
|
||||
//
|
||||
// Required:
|
||||
//
|
||||
// AICHAT_URL — service root (e.g. http://100.99.98.203:8765).
|
||||
// AICHAT_TOKEN — raw bearer token paliad's app_id is registered
|
||||
// under in aichat's tokens.yaml (see m/mAi
|
||||
// docs/reference/aichat-deploy.md).
|
||||
//
|
||||
// Optional:
|
||||
//
|
||||
// AICHAT_PERSONA — persona id; defaults to "paliadin".
|
||||
//
|
||||
// jwtSecret comes from the same SUPABASE_JWT_SECRET that auth.NewClient
|
||||
// already requires at boot — never empty when we reach this code path.
|
||||
// It's threaded in so the aichat service can mint per-turn user-scoped
|
||||
// JWTs (folded-in t-paliad-156 work).
|
||||
func buildAichatPaliadinConfig(jwtSecret string) (services.AichatPaliadinConfig, error) {
|
||||
cfg := services.AichatPaliadinConfig{
|
||||
BaseURL: strings.TrimRight(os.Getenv("AICHAT_URL"), "/"),
|
||||
BearerToken: os.Getenv("AICHAT_TOKEN"),
|
||||
Persona: cmpOr(os.Getenv("AICHAT_PERSONA"), services.DefaultAichatPersona),
|
||||
JWTSecret: []byte(jwtSecret),
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
return cfg, fmt.Errorf("AICHAT_URL must be set when PALIADIN_BACKEND=aichat")
|
||||
}
|
||||
if cfg.BearerToken == "" {
|
||||
return cfg, fmt.Errorf("AICHAT_TOKEN must be set when PALIADIN_BACKEND=aichat")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// rlsModeLabel labels the boot log so the operator can confirm whether
|
||||
// the per-user JWT mint is active. "per-user" means we're handing the
|
||||
// claude pane user-scoped claims; "service-role" means we're not (no
|
||||
// SUPABASE_JWT_SECRET) and the skill will reject queries rather than
|
||||
// run as supabase_admin.
|
||||
func rlsModeLabel(secret []byte) string {
|
||||
if len(secret) == 0 {
|
||||
return "service-role"
|
||||
}
|
||||
return "per-user"
|
||||
}
|
||||
|
||||
86
cmd/server/main_paliadin_backend_test.go
Normal file
86
cmd/server/main_paliadin_backend_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildAichatPaliadinConfig pins the env-driven wiring used by the
|
||||
// PALIADIN_BACKEND=aichat path in main(). It guards three things:
|
||||
//
|
||||
// 1. Required vars (AICHAT_URL, AICHAT_TOKEN) must be set — otherwise
|
||||
// boot fails fast with a clear error message.
|
||||
// 2. AICHAT_PERSONA defaults to "paliadin" so a misconfigured deploy
|
||||
// doesn't silently route to a different persona.
|
||||
// 3. The JWT secret threads through so per-turn JWT mint is on by
|
||||
// default (folded-in t-paliad-156 work).
|
||||
//
|
||||
// We can't unit-test the switch{} block in main() directly without
|
||||
// invoking the rest of boot, so this test exercises the helper that
|
||||
// branch calls — the same surface a Phase B regression would hit.
|
||||
func TestBuildAichatPaliadinConfig(t *testing.T) {
|
||||
t.Run("rejects empty URL", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "")
|
||||
t.Setenv("AICHAT_TOKEN", "tok")
|
||||
_, err := buildAichatPaliadinConfig("secret")
|
||||
if err == nil || !strings.Contains(err.Error(), "AICHAT_URL") {
|
||||
t.Errorf("err = %v; want AICHAT_URL complaint", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects empty token", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "http://aichat.test")
|
||||
t.Setenv("AICHAT_TOKEN", "")
|
||||
_, err := buildAichatPaliadinConfig("secret")
|
||||
if err == nil || !strings.Contains(err.Error(), "AICHAT_TOKEN") {
|
||||
t.Errorf("err = %v; want AICHAT_TOKEN complaint", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults persona to paliadin", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "http://aichat.test/")
|
||||
t.Setenv("AICHAT_TOKEN", "tok")
|
||||
t.Setenv("AICHAT_PERSONA", "")
|
||||
cfg, err := buildAichatPaliadinConfig("secret")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if cfg.Persona != "paliadin" {
|
||||
t.Errorf("persona = %q; want paliadin", cfg.Persona)
|
||||
}
|
||||
if cfg.BaseURL != "http://aichat.test" {
|
||||
t.Errorf("base url trailing slash leaked: %q", cfg.BaseURL)
|
||||
}
|
||||
if string(cfg.JWTSecret) != "secret" {
|
||||
t.Errorf("JWT secret not threaded; got %q", string(cfg.JWTSecret))
|
||||
}
|
||||
if cfg.BearerToken != "tok" {
|
||||
t.Errorf("BearerToken = %q; want tok", cfg.BearerToken)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("honours AICHAT_PERSONA override", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "http://aichat.test")
|
||||
t.Setenv("AICHAT_TOKEN", "tok")
|
||||
t.Setenv("AICHAT_PERSONA", "custom-paliadin")
|
||||
cfg, err := buildAichatPaliadinConfig("secret")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if cfg.Persona != "custom-paliadin" {
|
||||
t.Errorf("persona = %q; want custom-paliadin", cfg.Persona)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRLSModeLabel(t *testing.T) {
|
||||
if got := rlsModeLabel(nil); got != "service-role" {
|
||||
t.Errorf("nil → %q; want service-role", got)
|
||||
}
|
||||
if got := rlsModeLabel([]byte{}); got != "service-role" {
|
||||
t.Errorf("empty → %q; want service-role", got)
|
||||
}
|
||||
if got := rlsModeLabel([]byte("x")); got != "per-user" {
|
||||
t.Errorf("non-empty → %q; want per-user", got)
|
||||
}
|
||||
}
|
||||
210
cmd/server/main_smoke_test.go
Normal file
210
cmd/server/main_smoke_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
// Boot smoke test — assert paliad reaches a serving state.
|
||||
//
|
||||
// Three checks against TEST_DATABASE_URL:
|
||||
//
|
||||
// 1. db.ApplyMigrations does not panic and returns nil.
|
||||
// 2. paliad.applied_migrations covers every on-disk *.up.sql — no
|
||||
// migration was silently skipped, no version is missing. The set
|
||||
// contract is stronger than the old single-counter check: applied
|
||||
// set must EQUAL on-disk set, not just reach the max version.
|
||||
// 3. The handler mux (with /healthz mounted) responds 200 to GET /healthz.
|
||||
//
|
||||
// This is the lightweight cousin of the migration dry-run gate
|
||||
// (internal/db/migrate_test.go): the dry-run catches per-migration syntax
|
||||
// errors before merge; this smoke confirms the apply+bind path the
|
||||
// container actually runs at boot. Together they cover the mig-098 /
|
||||
// mig-099 class of crash-loops end-to-end, plus the mig-103 parallel-merge
|
||||
// skip-hole that t-paliad-218 closed (m/paliad#44).
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL — matches the rest of the live-DB tests.
|
||||
//
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
|
||||
// docs/design-migration-runner-applied-set-2026-05-20.md §6.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/internal/handlers"
|
||||
)
|
||||
|
||||
func TestBootSmoke(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping boot smoke")
|
||||
}
|
||||
|
||||
// (1) Apply migrations end-to-end. The same code path the prod
|
||||
// container runs at boot before `http.ListenAndServe`. A regression
|
||||
// like mig-098's digit-regex would surface here as a non-nil error.
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("db.ApplyMigrations: %v", err)
|
||||
}
|
||||
|
||||
// (2) Assert the applied set equals the on-disk set. The new runner
|
||||
// tracks applied state per-migration; a silently-skipped version
|
||||
// would surface as a row missing from paliad.applied_migrations even
|
||||
// though max(version) matches. Comparing sets — not just max —
|
||||
// catches the failure mode the t-paliad-218 post-mortem documented.
|
||||
onDisk := embeddedMigrationVersions(t)
|
||||
applied := appliedMigrationVersions(t, url)
|
||||
|
||||
if missing := setDiff(onDisk, applied); len(missing) > 0 {
|
||||
t.Errorf("paliad.applied_migrations missing %d on-disk versions: %v "+
|
||||
"(a migration was skipped — investigate before deploying)",
|
||||
len(missing), missing)
|
||||
}
|
||||
if extra := setDiff(applied, onDisk); len(extra) > 0 {
|
||||
t.Errorf("paliad.applied_migrations has %d versions with no on-disk file: %v "+
|
||||
"(orphan rows — either restore the file or DELETE the row)",
|
||||
len(extra), extra)
|
||||
}
|
||||
|
||||
// (3) Mount the public handlers (the same Register call main() makes,
|
||||
// minus the DB-backed Services bundle which the /healthz route doesn't
|
||||
// need) and assert /healthz returns 200. This is the bind-and-serve
|
||||
// half of the smoke: catches a regression that would make /healthz
|
||||
// 404 or break the mux registration order.
|
||||
//
|
||||
// We deliberately do not boot the full main() — that would require
|
||||
// SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_JWT_SECRET, an open
|
||||
// listening socket and a real auth client. The /healthz handler is
|
||||
// auth-independent by design, and Register registers it on the outer
|
||||
// mux before any DB-backed route, so this minimal setup exercises the
|
||||
// exact code path main() takes.
|
||||
mux := http.NewServeMux()
|
||||
authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret"))
|
||||
handlers.Register(mux, authClient, "", nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET /healthz: status=%d, body=%q; want 200 OK", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := strings.TrimSpace(rec.Body.String()); body != "ok" {
|
||||
t.Errorf("GET /healthz: body=%q; want \"ok\"", body)
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedMigrationVersions returns every N where N_*.up.sql exists in
|
||||
// internal/db/migrations/ on disk. The boot smoke compares this set
|
||||
// against paliad.applied_migrations to detect skipped or orphan
|
||||
// migrations.
|
||||
//
|
||||
// Read from disk (not the embed.FS inside the db package — it's unexported)
|
||||
// since the test runs from the repo. The two views must agree for the
|
||||
// build to be self-consistent; if they diverge, the smoke test is the
|
||||
// wrong place to learn about it (the build is). We trust them to match.
|
||||
func embeddedMigrationVersions(t *testing.T) []int {
|
||||
t.Helper()
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("locate repo root: %v", err)
|
||||
}
|
||||
dir := filepath.Join(root, "internal", "db", "migrations")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read migrations dir %s: %v", dir, err)
|
||||
}
|
||||
var versions []int
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".up.sql") {
|
||||
continue
|
||||
}
|
||||
base := strings.TrimSuffix(name, ".up.sql")
|
||||
underscore := strings.IndexByte(base, '_')
|
||||
if underscore <= 0 {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.Atoi(base[:underscore])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, v)
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
t.Fatalf("no *.up.sql files found in %s", dir)
|
||||
}
|
||||
sort.Ints(versions)
|
||||
return versions
|
||||
}
|
||||
|
||||
// appliedMigrationVersions reads paliad.applied_migrations and returns
|
||||
// the sorted list of versions. Fails the test if the table doesn't exist —
|
||||
// db.ApplyMigrations is supposed to have created it by this point.
|
||||
func appliedMigrationVersions(t *testing.T, url string) []int {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations ORDER BY version`)
|
||||
if err != nil {
|
||||
t.Fatalf("read applied_migrations: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// setDiff returns the elements of a that are not in b. Inputs are sorted
|
||||
// ascending; output preserves that ordering.
|
||||
func setDiff(a, b []int) []int {
|
||||
bset := make(map[int]bool, len(b))
|
||||
for _, v := range b {
|
||||
bset[v] = true
|
||||
}
|
||||
var out []int
|
||||
for _, v := range a {
|
||||
if !bset[v] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// repoRoot walks upward from the test binary's working directory until it
|
||||
// finds a go.mod. `go test` runs in the package dir, so we typically have
|
||||
// to climb a couple of levels.
|
||||
func repoRoot() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
@@ -34,5 +34,12 @@ services:
|
||||
- PALIADIN_REMOTE_USER=${PALIADIN_REMOTE_USER}
|
||||
- PALIADIN_SSH_PRIVATE_KEY=${PALIADIN_SSH_PRIVATE_KEY}
|
||||
- PALIADIN_KNOWN_HOSTS=${PALIADIN_KNOWN_HOSTS}
|
||||
# aichat Phase B (t-paliad-194 / m/paliad#38). Set PALIADIN_BACKEND=aichat
|
||||
# to route Paliadin through the centralized aichat backend on mRiver.
|
||||
# Legacy default (unset / "legacy") keeps the existing RemotePaliadinService path.
|
||||
- PALIADIN_BACKEND=${PALIADIN_BACKEND:-legacy}
|
||||
- AICHAT_URL=${AICHAT_URL:-}
|
||||
- AICHAT_TOKEN=${AICHAT_TOKEN:-}
|
||||
- AICHAT_PERSONA=${AICHAT_PERSONA:-paliadin}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
restart: unless-stopped
|
||||
|
||||
332
docs/design-approval-suggest-changes-2026-05-19.md
Normal file
332
docs/design-approval-suggest-changes-2026-05-19.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# Design — "Suggest changes" action on approval flow
|
||||
|
||||
**Author:** hertz (inventor)
|
||||
**Date:** 2026-05-19
|
||||
**Task:** t-paliad-216 (m/paliad in-flight)
|
||||
**Branch:** `mai/hertz/inventor-suggest-changes`
|
||||
**Status:** DESIGN — open questions await m before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
Add a fourth action **"Änderungen vorschlagen"** ("Suggest changes") to the approval flow, alongside Approve / Reject / Revoke. Use case: the approver doesn't want to accept the proposed change as-is, but doesn't want to reject outright — they edit the proposed values into a counter-proposal and submit it back into the same approval flow.
|
||||
|
||||
**Mental model (m, 2026-05-19):** suggest-changes is not "ping the requester to fix it" — it's the approver **authoring a counter-proposal** that gets re-injected into the approval flow as a fresh `pending` row. The original requester (now potentially an eligible approver of the counter, since they're no longer the requested_by) sees:
|
||||
- the **old row** in their /inbox as `changes_requested` ("Abgelehnt mit Vorschlag" / "Declined with changes") — historical record of their original attempt;
|
||||
- the **new row** in /inbox as `pending` — the counter, which they can approve, reject, revoke (n/a, not theirs), or suggest changes back on. Everyone else eligible sees the new row too. 4-Augen still holds: the counter's requested_by (the approver who suggested it) cannot self-approve.
|
||||
|
||||
Click flow:
|
||||
1. Approver opens an editable modal on the pending row showing the requester's proposed values. Edits any field. Writes a free-text note ("Bitte den Termin um 9:00 statt 8:00, weil der Raum sonst kollidiert").
|
||||
2. POST `/api/approval-requests/{id}/suggest-changes` with `{note, counter_payload}`.
|
||||
3. Server, in one tx: closes the old row (`changes_requested`, `decision_note=note`), reverts the entity from `pre_image`, then immediately inserts a **new** `pending` approval_requests row authored by the approver with `payload=counter_payload`, re-applies the counter to the entity, marks `pending_request_id` to the new row, emits two events (`*_approval_changes_suggested` + `*_approval_requested`). `previous_request_id` FK links new → old for chain traversal.
|
||||
|
||||
The pending audience for the new row is the same as any fresh `Submit*` — the existing notification + visibility plumbing handles it without special-casing.
|
||||
|
||||
---
|
||||
|
||||
## 0a. m's decisions (2026-05-19)
|
||||
|
||||
| # | Header | m picked | Reasoning note (when different from recommendation) |
|
||||
|---|---|---|---|
|
||||
| Q1 | State machine | **(a) New status `changes_requested`.** | As recommended. |
|
||||
| Q2 | Entity state | **(a) Reverts to pre_image, same as Reject.** | As recommended. The counter is then re-applied in the same tx by the new approval row's write-then-approve cycle. |
|
||||
| Q3 | Chain depth | **(a) Yes, across chained rows.** | As recommended. |
|
||||
| Q4 | Note shape | **Hybrid: approver can edit the proposed values (counter-proposal) AND/OR leave free-text in `decision_note`.** | Differs from (a). Inventor picked free-text-only; m's twist: the suggestion should ALSO carry concrete edits. This adds a `counter_payload jsonb` column on `approval_requests` and turns "suggest-changes" into an action that authors a real counter-proposal, not just a hint. |
|
||||
| Q5 | Surface | **(a) /inbox only — v1.** | As recommended. Email + entity-detail badge are Phase 2. |
|
||||
| Q6 | Requester actions | **Different model: the counter is a NEW pending approval_request row, not an "edit + resubmit" CTA on the requester side.** | Differs from (a). m's reframing: instead of routing back to the requester to act on, the suggestion IS the next request. Original requester sees the old row as `changes_requested` (status pill "Abgelehnt mit Vorschlag" or similar). Original requester then sees the NEW row in /inbox like any pending — and **may approve it themselves**, because they are no longer the row's requested_by (the suggesting approver is). Everyone else eligible sees it too. Cleaner workflow, removes the "edit-and-resubmit CTA" from the requester role entirely. |
|
||||
| Q7 | Notifications | **(b) Notify all eligible approvers + the original requester for the NEW pending row.** | Consistent with Q6. The counter is a fresh `pending` request, so the existing Submit*-notification audience applies. The original requester needs the ping because they're now an eligible approver of the counter — no special-case path. |
|
||||
| Q8 | Audit shape | **(a) New event_type `*_approval_changes_suggested` per entity.** | As recommended. The new row also emits a normal `*_approval_requested` event, so the Verlauf chronology naturally captures the chain. |
|
||||
|
||||
The decisions above lock the design. §3 has been rewritten to reflect them; §2 (open questions) is retained as the historical record of what was open before the decisions.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — what's already in the code (verified 2026-05-19)
|
||||
|
||||
- **State machine** in `internal/services/approval_service.go`:
|
||||
- `paliad.approval_requests.status` CHECK is already `('pending', 'approved', 'rejected', 'revoked', 'superseded')` — the `superseded` value is defined as a Go constant `RequestStatusSuperseded` but never written by the live service (reserved).
|
||||
- `paliad.{deadlines,appointments}.approval_status` CHECK is `('approved', 'pending', 'legacy')` — three values only.
|
||||
- Shared kernel `decide(requestID, callerID, finalStatus, note)` powers Approve / Reject / Revoke. Approve invokes `applyApproved`; Reject + Revoke invoke `applyRevert` (restores entity from `pre_image`).
|
||||
- Self-approval blocked at 3 layers: `canApprove` Go gate, `approval_requests_no_self_approval` DB CHECK, deadlock-check excludes requester from pool.
|
||||
- **Handlers** in `internal/handlers/approvals.go`:
|
||||
- `POST /api/approval-requests/{id}/approve`
|
||||
- `POST /api/approval-requests/{id}/reject`
|
||||
- `POST /api/approval-requests/{id}/revoke`
|
||||
- `GET /api/approval-requests/{id}` — single hydrated request
|
||||
- **Per-viewer flags** (t-paliad-202, shipped): every row carries `viewer_can_approve` + `viewer_is_requester` resolved server-side so the UI can grey out buttons the server would reject. Server still enforces — the flags are a UX hint.
|
||||
- **Frontend**:
|
||||
- `frontend/src/client/inbox.ts` wires three buttons per pending row (approve/reject/revoke). Reject opens `window.prompt()` for the note; approve+revoke don't.
|
||||
- `frontend/src/client/views/shape-list.ts` (row_action="approve") stamps the row with action buttons + diff + `decision_note` display if present.
|
||||
- **Audit**: event types `*_approval_requested`, `*_approval_approved`, `*_approval_rejected`, `*_approval_revoked` emitted to `paliad.project_events` (one per entity_type prefix).
|
||||
- **Decision note**: `paliad.approval_requests.decision_note text` — a single free-text column, last-write-wins. Already populated on Reject (Approve also accepts an optional note).
|
||||
|
||||
---
|
||||
|
||||
## 2. Design questions (the open list — see §6 for answered)
|
||||
|
||||
Pre-recommendations from inventor. m will pick via AskUserQuestion.
|
||||
|
||||
### State machine
|
||||
|
||||
**Q1 — Where does "suggest changes" sit on the lifecycle?**
|
||||
- **(a) New status `changes_requested` (RECOMMENDED).** The approval_requests row transitions pending → changes_requested. Sibling of approved/rejected/revoked/superseded. The row is terminal in that status; a re-submit creates a fresh row (linked via `previous_request_id`).
|
||||
- (b) Reuse `rejected` with `is_revisable=true` flag. Cheap, but conflates two semantically distinct outcomes ("we'll never want this" vs. "tweak X and try again").
|
||||
- (c) Auto-revoke the current row, mark the entity for edit, requester creates a new approval row when ready. Reuses existing plumbing — but loses the approver's note as a first-class thing (it'd just be a comment on the project_events row).
|
||||
- (d) Other (you'll tell us).
|
||||
|
||||
Recommend (a) — keeps the audit lifecycle clear, gives us a clean place to hang the suggestion note, and is the smallest schema change (one new value in a CHECK constraint).
|
||||
|
||||
**Q2 — What happens to the entity (deadline/appointment) while in "changes requested"?**
|
||||
- **(a) Entity reverts to pre_image — same as Reject (RECOMMENDED).** approval_status flips back to `approved`. The requester edits the entity in the normal flow; saving fires a fresh `Submit*` cycle.
|
||||
- (b) Entity stays at `approval_status=pending` carrying the proposed values; requester edits "in place" through a new "amend the pending request" endpoint that mutates the same approval_request row + entity fields.
|
||||
- (c) Entity goes to a new `approval_status=draft` (would require a new value on the entity-level CHECK + UI work to handle a third entity state).
|
||||
|
||||
Recommend (a) — minimum schema change, reuses every existing path (entity edit, Submit*, applyRevert, project_events emission). The trade-off is one extra approval_requests row per cycle; we link via `previous_request_id` so the chain stays inspectable.
|
||||
|
||||
**Q3 — Can the approver suggest changes multiple times (across a chain)?**
|
||||
- **(a) Yes, across chained rows (RECOMMENDED).** Each row is terminal after suggest-changes; the requester resubmits → new pending row → approver can suggest changes again. Chain depth unbounded.
|
||||
- (b) No — one chance per entity-lifecycle; if the requester comes back, the only options are approve or reject (the suggest-changes button is hidden for the second submission).
|
||||
|
||||
Recommend (a) — bounded by the requester's patience, not by the system. Multi-round review is the norm in legal-doc workflows.
|
||||
|
||||
**Q4 — Note shape on the suggestion**
|
||||
- **(a) Free-text — reuse `decision_note` (RECOMMENDED).** Same column the existing Reject path already populates. Last-write-wins per row (but rows are terminal after suggest-changes, so there's no real "last write").
|
||||
- (b) Thread of notes — new `paliad.approval_notes` table, ordered, multi-author. Lets the requester respond inline, the approver clarify, etc.
|
||||
- (c) Structured per-field suggestions (`[{"field": "due_date", "current": "...", "suggested": "..."}]`) — a "diff-style" view.
|
||||
|
||||
Recommend (a) — matches the existing Reject UX, no new schema. (b) is right if the team wants to discuss; (c) is over-engineered for v1.
|
||||
|
||||
### UX
|
||||
|
||||
**Q5 — Where does the requester see the suggestion?**
|
||||
- **(a) /inbox under `a_role=self_requested` (RECOMMENDED for v1).** Same surface they already use to see rejected. New status pill "Änderungen vorgeschlagen" + the note + a CTA "Bearbeiten und erneut einreichen".
|
||||
- (b) A new badge on the entity's detail page (e.g. on the deadline detail page itself).
|
||||
- (c) Email + push notification.
|
||||
- (d) All of the above.
|
||||
|
||||
Recommend (a) for v1. Email reminder is a natural Phase-2 add-on (it'd reuse the existing reminder-mail plumbing). The entity-detail badge is nice but the user is already seeing the row in /inbox.
|
||||
|
||||
**Q6 — What action(s) does the requester have on a `changes_requested` row?**
|
||||
- **(a) Edit and resubmit (RECOMMENDED).** Primary action. Opens the entity's edit form pre-populated with the original `payload`. Saving fires `Submit*` → new pending request with `previous_request_id` linking back.
|
||||
- (b) Withdraw (= dismiss the row from inbox, no DB change). Mostly UI-only — the row is already terminal; "withdraw" would just be a "mark as not-pursuing" toggle.
|
||||
- (c) Both.
|
||||
|
||||
Recommend (a). The row is already terminal once status=`changes_requested`; the requester either acts on the suggestion (a) or lets the row sit in their inbox history (no action needed). Adding a "dismiss" button is a UI nice-to-have but doesn't change the data model; can defer.
|
||||
|
||||
### Notifications
|
||||
|
||||
**Q7 — Who gets notified when "suggest changes" fires?**
|
||||
- **(a) Just the requester (RECOMMENDED for v1).** Email-reminder path is reused: requester gets a mail "X hat Änderungen vorgeschlagen für …" with the note inline + a link to /inbox.
|
||||
- (b) Requester + any other potential approvers (they need to know the request is closed, not pending).
|
||||
- (c) Requester + approval-policy-defined watchers (would require a new `approval_policies.watchers` column).
|
||||
|
||||
Recommend (a). The request is terminal so other approvers don't need a "this is now your problem" ping — they wouldn't have anything to act on. They see it in /inbox under "Alle sichtbaren" anyway if curious.
|
||||
|
||||
### Audit
|
||||
|
||||
**Q8 — Audit row shape on `project_events`**
|
||||
- **(a) New event_type `*_approval_changes_suggested` per entity (RECOMMENDED).** Parallel to the existing 4 (requested/approved/rejected/revoked). Two new event types: `deadline_approval_changes_suggested`, `appointment_approval_changes_suggested`. Note text goes in metadata.
|
||||
- (b) Bundle with the resubmission — single composite event "approved-with-revisions" when the chain eventually approves.
|
||||
|
||||
Recommend (a). Each transition gets its own event row — that's how the existing audit chain already works (one event per state change). It also gives the Verlauf timeline a row to render the approver's note.
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation sketch (decisions-locked, see §0a)
|
||||
|
||||
### 3.1 Migration `103_approval_suggest_changes.up.sql`
|
||||
|
||||
```sql
|
||||
-- 1. Extend approval_requests.status CHECK to allow 'changes_requested'.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD CONSTRAINT approval_requests_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded', 'changes_requested'));
|
||||
|
||||
-- 2. Add counter_payload — the approver's edited values, becomes the
|
||||
-- `payload` of the NEW pending row spawned in the same tx as the
|
||||
-- suggest-changes call. Stored on the OLD (now changes_requested) row
|
||||
-- too so the audit chain can show "approver edited X, Y, Z" without
|
||||
-- joining to the next row.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN counter_payload jsonb NULL;
|
||||
|
||||
-- 3. Add previous_request_id FK so the new row links back to its origin.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN previous_request_id uuid NULL
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX approval_requests_previous_idx
|
||||
ON paliad.approval_requests (previous_request_id)
|
||||
WHERE previous_request_id IS NOT NULL;
|
||||
```
|
||||
|
||||
`.down.sql`: drop the index + columns, restore the original CHECK (would reject existing `changes_requested` rows — that's normal for a breaking-change down).
|
||||
|
||||
### 3.2 Service layer
|
||||
|
||||
`SuggestChanges` is the only new public method on `ApprovalService`. It runs in **one transaction** and does five things:
|
||||
|
||||
```go
|
||||
const RequestStatusChangesRequested = "changes_requested"
|
||||
|
||||
var ErrSuggestionRequiresChange = errors.New("suggestion_requires_change")
|
||||
|
||||
// SuggestChanges closes the pending request as `changes_requested`,
|
||||
// reverts the entity, then immediately inserts a new pending
|
||||
// approval_request authored by the caller carrying `counterPayload` as
|
||||
// its new payload. The new row enters the standard pending flow — anyone
|
||||
// eligible (including the original requester) can approve, reject,
|
||||
// suggest-changes-again, etc.
|
||||
//
|
||||
// Authorization: caller satisfies canApprove on the OLD row (same gate
|
||||
// as Approve / Reject). For the NEW row, the caller is the requested_by
|
||||
// — self-approval is blocked by the standard 3-layer guard. Deadlock
|
||||
// check (qualified-approver-exists-other-than-caller) runs on the new
|
||||
// row to avoid spawning an unapprovable request.
|
||||
//
|
||||
// counterPayload must differ from the old row's payload OR a non-empty
|
||||
// note must be present. A no-op suggest (same values, no note) is
|
||||
// indistinguishable from "I have no opinion" and gets rejected with
|
||||
// ErrSuggestionRequiresChange.
|
||||
func (s *ApprovalService) SuggestChanges(
|
||||
ctx context.Context,
|
||||
requestID, callerID uuid.UUID,
|
||||
counterPayload []byte, // jsonb-marshaled
|
||||
note string,
|
||||
) (newRequestID *uuid.UUID, err error) {
|
||||
// 1. Begin tx, lock old row, validate status=pending + canApprove.
|
||||
// 2. Validate: counterPayload differs from old payload OR note != "".
|
||||
// 3. Update old row: status='changes_requested', decided_by=callerID,
|
||||
// decision_note=note, counter_payload=counterPayload.
|
||||
// 4. applyRevert on the entity (uses old row's pre_image).
|
||||
// 5. Deadlock-check on the new row's required_role + projectID,
|
||||
// excluding callerID.
|
||||
// 6. INSERT new approval_requests row: requested_by=callerID,
|
||||
// pre_image=<entity-state-as-just-reverted> (= old.pre_image),
|
||||
// payload=counterPayload, required_role=old.required_role,
|
||||
// lifecycle_event=old.lifecycle_event, entity_type=old.entity_type,
|
||||
// entity_id=old.entity_id, status='pending',
|
||||
// previous_request_id=requestID.
|
||||
// 7. Re-apply the new payload to the entity (write-then-approve):
|
||||
// apply the counter_payload's field updates + mark
|
||||
// approval_status='pending' + pending_request_id=newRequestID.
|
||||
// 8. Emit *_approval_changes_suggested project_events row
|
||||
// (metadata: note, counter_payload diff vs original).
|
||||
// 9. Emit *_approval_requested project_events row for the new
|
||||
// request (same shape Submit* normally emits).
|
||||
// 10. Commit.
|
||||
}
|
||||
```
|
||||
|
||||
Steps 6 + 7 reuse the existing `Submit*` plumbing structurally — the cleanest implementation factors out an "insert approval row + apply payload to entity" helper that both `Submit*` and `SuggestChanges` call. **decide()** does not need to know about `changes_requested` because suggest-changes is not a decision-kernel transition — it's its own end-to-end action.
|
||||
|
||||
### 3.3 HTTP layer
|
||||
|
||||
```
|
||||
POST /api/approval-requests/{id}/suggest-changes
|
||||
Body: {
|
||||
"counter_payload": { ...same shape as Submit*'s payload... },
|
||||
"note": "free-text explanation, optional iff counter_payload differs from original"
|
||||
}
|
||||
Returns: 200 { "new_request_id": "uuid" }
|
||||
Errors:
|
||||
400 "suggestion_requires_change" — counter_payload == old payload AND note empty
|
||||
400 "invalid_counter_payload" — schema validation failure
|
||||
403 "self_approval_blocked" — caller == old row's requested_by
|
||||
403 "not_authorized" — caller doesn't satisfy canApprove
|
||||
404 — request not found / not visible
|
||||
409 "request_not_pending" — old row already decided
|
||||
409 "no_qualified_approver" — deadlock on the new row (only caller is eligible)
|
||||
```
|
||||
|
||||
Register in `internal/handlers/handlers.go` alongside the existing three:
|
||||
|
||||
```go
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
|
||||
```
|
||||
|
||||
### 3.4 Frontend
|
||||
|
||||
`frontend/src/client/views/shape-list.ts` — extend the pending-row action group to four buttons:
|
||||
|
||||
```ts
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
```
|
||||
|
||||
The `action` union type gains `"suggest_changes"`. Disabled-reason logic is identical to approve/reject (`viewer_can_approve` gate). i18n: `approvals.action.suggest_changes` → DE "Änderungen vorschlagen" / EN "Suggest changes".
|
||||
|
||||
`frontend/src/client/inbox.ts` — clicking the suggest-changes button opens a **modal**, not a `window.prompt` (the existing reject prompt is OK because reject only needs a note; suggest-changes needs an editable form). The modal:
|
||||
- Renders the same fields the entity edit form would show, pre-populated from `detail.payload` (the requester's proposed values).
|
||||
- Adds a free-text "Vorschlagskommentar" textarea at the bottom (the note).
|
||||
- On submit: POST `/api/approval-requests/{id}/suggest-changes` with `{counter_payload: {...editedFields}, note}`.
|
||||
- On success: refresh the bar — the old row flips to `changes_requested`, the new row appears as `pending`.
|
||||
|
||||
Where the modal's field-editor lives: a new `client/components/approval-edit-modal.ts` that takes `entity_type` + `payload` + `pre_image` and returns the edited payload. For v1 it can be a thin wrapper over the existing entity-edit form components (Frist date picker, Termin start/end pickers). Don't build a generic field-editor framework — just deadlines + appointments, hard-coded fields per entity_type.
|
||||
|
||||
**Status pill for `changes_requested`** — i18n keys + colour:
|
||||
- `approvals.status.changes_requested` → DE "Abgelehnt mit Vorschlag" / EN "Declined with changes"
|
||||
- Reuse the existing `approval-pill--historic` style; no new colour token needed for v1.
|
||||
|
||||
**The "Edit and resubmit" CTA on the requester's row is NOT needed** (m's Q6 reframing) — the requester just sees the new pending row in /inbox, same as any other.
|
||||
|
||||
### 3.5 Inbox filter
|
||||
|
||||
The /inbox `approval_status` filter chip cluster gains `changes_requested`. The `self_requested` viewer-role default already includes terminal statuses, so the original requester sees their `changes_requested` row without changing the default filter.
|
||||
|
||||
### 3.6 Linkage from old row to new row in /inbox
|
||||
|
||||
When showing a `changes_requested` row in /inbox, add a small "→ Neuer Vorschlag von {approver}" link below the note that scrolls / filters to the new pending row (it'll be visible to anyone eligible, including the original requester). The new row has `previous_request_id` pointing at the old one — so the API response for the old row can hydrate `next_request_id` (computed: `SELECT id FROM approval_requests WHERE previous_request_id = $1 LIMIT 1`).
|
||||
|
||||
### 3.7 Email notification (Phase 2 — defer until v1 ships)
|
||||
|
||||
The new row triggers the existing `*_approval_requested` notification path (whatever that is for Submit*) — same audience, same template. No new code. The old row's transition to `changes_requested` doesn't need its own mail; the new-row mail already tells the audience "X suggested changes to your earlier submission" through the body.
|
||||
|
||||
Out of scope for v1: a bespoke "your submission was declined with a counter-proposal" email aimed at the original requester. The new-row mail covers it functionally.
|
||||
|
||||
---
|
||||
|
||||
## 4. Slice plan
|
||||
|
||||
Three reviewable slices, each one PR. Combined scope is small/medium.
|
||||
|
||||
1. **Slice A — backend.** Migration 103 (CHECK extension + `counter_payload jsonb` + `previous_request_id` FK + index) + `SuggestChanges` service method + HTTP handler + service tests (happy path, no-op-suggestion guard, deadlock on new row, self-approval block, request_not_pending). Migration is non-blocking on Postgres; safe for live deploy.
|
||||
2. **Slice B — frontend.** 4th button on /inbox + the edit modal (deadline-fields variant + appointment-fields variant) + status pill `changes_requested` ("Abgelehnt mit Vorschlag") + i18n keys (DE + EN) + the "→ Neuer Vorschlag" link from old row to new row. End-to-end browser smoke test via Playwright.
|
||||
3. **Slice C — Verlauf integration.** Make sure the `*_approval_changes_suggested` event renders on the project / deadline / appointment Verlauf timeline alongside the existing 4 approval event types. May or may not need code change depending on how generic the Verlauf row renderer is — likely just an i18n key + an icon mapping.
|
||||
|
||||
Don't ship a chain-traversal UI in v1. The `previous_request_id` FK is captured so the data is there; surfacing the full chain history (n hops back) is a Phase-2 polish.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risks / open considerations
|
||||
|
||||
- **Chain depth runaway.** Nothing stops an "I keep suggesting / they keep counter-suggesting" loop. Same risk as comment threads on GitHub PRs. Out of scope to cap; the social pressure (each round is a 4-Augen action with a name attached) is the natural brake.
|
||||
- **Concurrent suggestions on the same pending row.** Two approvers click "suggest changes" at the same time? The existing `getRequestForUpdate` row-lock serialises them; the second caller gets `ErrRequestNotPending` (the first already flipped it). Same guarantee as Approve/Reject today.
|
||||
- **Deadlock on the new row.** If the suggesting approver is the only qualified approver other than the original requester, the new row's deadlock check returns "no qualified approver" — because the original requester IS now eligible (they're no longer the requested_by), but might not have a high-enough role. The check needs to recognise: caller's pool = "anyone other than the new requester who can canApprove". Original requester counts if they hit the required-role bar. This is just the existing deadlock predicate run against the new (requester, role) tuple; no special-case logic. Surfaced as `409 "no_qualified_approver"` to the suggesting approver, with the standard global_admin override path still available.
|
||||
- **Counter-payload schema validation.** Server must validate `counter_payload` against the same schema as a normal `Submit*` for that entity_type + lifecycle_event. Otherwise a malicious approver could write garbage values via the suggestion path that wouldn't fly through `Submit*`. Reuse the existing payload-schema validator from the entity services; don't write a parallel.
|
||||
- **No-op suggestion guard.** Approver clicks suggest-changes but doesn't actually edit anything AND leaves the note empty? Server rejects with `ErrSuggestionRequiresChange`. UI guards too (the submit button stays disabled until either the form is dirty OR the note has text).
|
||||
- **Migration safety.** Non-blocking. Adding a value to a CHECK constraint is a metadata-only change; adding a NULLable column + a NULLable FK is also metadata-only.
|
||||
- **What about a structured per-field suggestion (Q4c)?** The `counter_payload` jsonb IS structured — each entity_type has fixed fields. There's no need for a separate "{field, current, suggested}" shape because the diff is computable from `pre_image → counter_payload` on the new row.
|
||||
- **What about thread-of-notes (Q4b)?** Implicit in the chain — each row's `decision_note` is one "note" by one author; following `previous_request_id` backwards reconstructs the full back-and-forth. A future "thread view" UI is layered on top of this without schema change.
|
||||
|
||||
---
|
||||
|
||||
## 6. m's decisions
|
||||
|
||||
See §0a (decisions table) — filled in after the AskUserQuestion phase on 2026-05-19.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope for this design
|
||||
|
||||
- Email + push notifications (Phase 2; see §3.7).
|
||||
- Structured per-field suggestion shape (Phase 2 enhancement).
|
||||
- Approval-policy `watchers` column for notification fan-out.
|
||||
- "Dismiss this row from my inbox" UI toggle (UX-only, not a data-model change).
|
||||
- Cross-entity suggest-changes (e.g. project, party). Same as the original approval scope — deadlines + appointments only.
|
||||
|
||||
597
docs/design-caldav-multi-calendar-2026-05-19.md
Normal file
597
docs/design-caldav-multi-calendar-2026-05-19.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# CalDAV multi-calendar sync — design
|
||||
|
||||
**Task:** t-paliad-212
|
||||
**Inventor:** leibniz (2026-05-19)
|
||||
**Branch:** mai/leibniz/inventor-caldav-multi
|
||||
**Status:** READY FOR REVIEW — m's decisions on the §8 open questions captured in the addendum below (2026-05-19).
|
||||
|
||||
---
|
||||
|
||||
## §0 — One-paragraph summary
|
||||
|
||||
Paliad's CalDAV sync today is a single-target push: every user has one
|
||||
`paliad.user_caldav_config` row, and every Appointment they can see gets
|
||||
PUT into that one calendar. m wants users to pick their own organization —
|
||||
one cal with everything, one cal per project (or per client / litigation /
|
||||
patent / case), or any hybrid. This design splits the model in two:
|
||||
**credentials stay per user** (one CalDAV server, one auth blob) and
|
||||
**bindings become first-class rows** (a join table `paliad.user_calendar_bindings`
|
||||
that points an Appointment-filter scope at a specific `calendar_path`).
|
||||
Push/pull state migrates from scalar `appointments.caldav_uid`/`caldav_etag`
|
||||
columns to a per-(appointment, binding) join table
|
||||
`paliad.appointment_caldav_targets`, so the same Appointment can live in
|
||||
N external calendars at once. The 60-second per-user sync goroutine survives
|
||||
unchanged in shape; inside it the inner loop iterates bindings instead of
|
||||
hard-coding `cfg.CalendarPath`. Sliced for safe rollout: Slice 1 introduces
|
||||
the new tables behind a backfill that auto-creates one binding per
|
||||
existing config row (zero behaviour change); Slice 2 ships the
|
||||
binding-picker UI; Slice 3 wires scope-aware filtering (one cal per project).
|
||||
Bidirectional sync stays exactly as it works today (last-write-wins on ETag,
|
||||
Paliad-owned UIDs only) — multi-calendar does not change the conflict
|
||||
model.
|
||||
|
||||
---
|
||||
|
||||
## §1 — What's already built (verified live, 2026-05-19)
|
||||
|
||||
Verified against the codebase, not the project's CLAUDE.md.
|
||||
|
||||
- **Schema** — `paliad.user_caldav_config` is one row per user with
|
||||
`(user_id PK, url, username, password_encrypted bytea, calendar_path,
|
||||
enabled, last_sync_at, last_sync_error, created_at, updated_at)`. The
|
||||
scalar `calendar_path` is the only handle on which external calendar
|
||||
receives events. Per direct `information_schema` query.
|
||||
- **Appointment binding** — `paliad.appointments` carries scalar
|
||||
`caldav_uid text` and `caldav_etag text` (nullable). Set once after a
|
||||
successful PUT via `AppointmentService.SetCalDAVMeta`. This is the
|
||||
single-target assumption baked into the row itself.
|
||||
- **Sync engine** — `internal/services/caldav_service.go:298–502`. One
|
||||
goroutine per enabled user, 60s ticker, `runSyncOnce` → `syncOnce` →
|
||||
`pushAll` (`AppointmentService.AllForUser` × `cli.PutEvent`) +
|
||||
`pullAll` (`cli.PropfindCalendar` → `cli.GetEvent` → reconcile by UID).
|
||||
`AllForUser` returns *every* personal-or-visible-project appointment
|
||||
for the user; today they all funnel into the single `calendar_path`.
|
||||
- **UID convention** — `paliad-appointment-<uuid>@paliad.de`
|
||||
(`caldav_ical.go:31–34`). Foreign UIDs are intentionally skipped on
|
||||
pull (`caldav_service.go:436–442`).
|
||||
- **Hooks** — `OnAppointmentCreated/Updated/Deleted` push directly to
|
||||
the configured `cfg.CalendarPath` on a 30s-timeout background goroutine
|
||||
so user requests don't block (`caldav_service.go:510–558`).
|
||||
- **Approval flow (t-138)** — project-attached appointments may be
|
||||
`approval_status = 'pending'`. CalDAV push already runs after approval
|
||||
in `AppointmentService.Update` paths; `ApplyRemoteUpdate` from a remote
|
||||
edit currently bypasses the approval gate. That's a pre-existing hole
|
||||
flagged here only because multi-calendar makes "which calendar's edit
|
||||
wins" more visible — fix belongs in t-138 follow-ups, not in this
|
||||
design.
|
||||
- **CalDAV verbs supported** — PUT / DELETE / GET / PROPFIND (depth 0
|
||||
and 1). No MKCALENDAR, no REPORT, no calendar-multiget. Tested
|
||||
against Nextcloud, Radicale, Baikal, mailcow SOGo per
|
||||
`caldav_client.go:22–24`.
|
||||
|
||||
**What is _not_ baked in and is therefore free to extend:**
|
||||
|
||||
- The 60s ticker is per-*user*, not per-*calendar*. Adding bindings does
|
||||
not multiply tickers.
|
||||
- `cfg.CalendarPath` is referenced in exactly two places (`pushAll`,
|
||||
`pullAll`) plus the three hooks. Replacing it with a binding loop is
|
||||
a contained edit.
|
||||
- Credentials are server-scoped, not calendar-scoped — every binding
|
||||
for the same user shares the existing decrypted credential, so the
|
||||
encryption layer (`caldav_crypto.go`) is untouched.
|
||||
|
||||
---
|
||||
|
||||
## §2 — Per-provider calendar-count limits (verified 2026-05-19)
|
||||
|
||||
Real numbers, from current docs, so the design knows its envelope.
|
||||
|
||||
| Provider | Per-account / per-user limit | Source |
|
||||
|---|---|---|
|
||||
| **iCloud** | **100** calendars + reminder-lists combined | [Apple Support 103188](https://support.apple.com/en-us/103188) |
|
||||
| **Google Calendar** | **~100 owned** (soft recommendation, post-Nov-2025 ownership model) | [Workspace Updates 2026-01](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html), [usecarly.com summary](https://www.usecarly.com/blog/how-many-calendars-google-account/) |
|
||||
| **Fastmail** | **No documented cap on calendars.** 100 000 events/user. | [Fastmail account-limits page](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) |
|
||||
| **Nextcloud** | **30 per user** default; admin-configurable, `-1` = unlimited. Rate limit: 10 calendar-creations/hour. | [Nextcloud admin manual — Calendar](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) |
|
||||
| **Radicale / Baikal / mailcow SOGo** | No published per-account cap (file-system / DB bound). | server defaults |
|
||||
|
||||
**Implications for the design:**
|
||||
|
||||
- "One calendar per project" is comfortably within all providers'
|
||||
envelopes for typical HLC caseloads. A senior PA who tracks 40
|
||||
litigations would land 40+ calendars, still inside iCloud's 100 and
|
||||
Nextcloud's default 30 (would need an admin bump on Nextcloud — flag
|
||||
in onboarding).
|
||||
- "One calendar per case" can blow past Nextcloud's default 30 fast and
|
||||
is a real risk on iCloud at the 60+ mark when combined with the
|
||||
user's existing personal calendars + reminder lists. We should
|
||||
**soft-cap** scope choices at the UI layer (warn at 20 bindings, hard
|
||||
block at 80) rather than discover the limit by 5xx on PUT.
|
||||
- Google Calendar's CalDAV endpoint does **not** support `MKCALENDAR`
|
||||
reliably — calendars must be pre-created in the Google UI. iCloud,
|
||||
Fastmail, Nextcloud, Radicale, Baikal, SOGo all accept `MKCALENDAR`.
|
||||
So the "auto-create a calendar per project" affordance is provider-
|
||||
dependent and must degrade gracefully ("we couldn't create it for
|
||||
you — please make `Project X` in your calendar app and paste its
|
||||
URL").
|
||||
|
||||
---
|
||||
|
||||
## §3 — Proposed data model
|
||||
|
||||
Three schema changes, no destructive migrations. The scalar
|
||||
`appointments.caldav_uid` / `caldav_etag` columns survive as a
|
||||
denormalised "default-binding" pointer through Slice 1 and 2; Slice 4
|
||||
drops them after telemetry confirms no path still reads them.
|
||||
|
||||
### §3.1 New table: `paliad.user_calendar_bindings`
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.user_calendar_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
calendar_path text NOT NULL, -- absolute URL or path under user_caldav_config.url
|
||||
display_name text NOT NULL DEFAULT '', -- the label discovered via PROPFIND <displayname/>; what we show in the UI
|
||||
|
||||
scope_kind text NOT NULL, -- 'all_visible' | 'personal_only' | 'project' | 'client' | 'litigation' | 'patent' | 'case'
|
||||
scope_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE, -- NULL for 'all_visible' / 'personal_only'
|
||||
include_personal boolean NOT NULL DEFAULT false, -- only meaningful when scope_kind <> 'all_visible'/'personal_only'
|
||||
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
last_sync_at timestamptz,
|
||||
last_sync_error text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (user_id, calendar_path), -- can't bind one calendar twice for the same user
|
||||
UNIQUE (user_id, scope_kind, scope_id), -- one binding per scope per user — but a project can also be covered by 'all_visible'
|
||||
CHECK ((scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
|
||||
OR (scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX user_calendar_bindings_user_idx ON paliad.user_calendar_bindings(user_id) WHERE enabled;
|
||||
-- RLS: row visible/writable only when auth.uid() = user_id (mirrors user_caldav_config).
|
||||
```
|
||||
|
||||
**Why per-scope unique but not per-appointment unique:** an Appointment in
|
||||
project P is allowed to land in both the user's `all_visible` calendar
|
||||
AND their `project=P` calendar — that's the explicit "master + per-project"
|
||||
hybrid m asked about. What we forbid is two different `project=P` bindings
|
||||
for the same user, which would have no useful semantics.
|
||||
|
||||
**`scope_kind = 'personal_only'`** is a separate scope from `'all_visible'`
|
||||
because the existing pushAll already covers both personal and visible-project
|
||||
appointments; users may want a "personal only" calendar that does *not*
|
||||
get the noisy team events. Without this, every binding either includes
|
||||
personal events or doesn't, and there's no way to say "the master
|
||||
calendar = everything except personal".
|
||||
|
||||
### §3.2 New table: `paliad.appointment_caldav_targets`
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.appointment_caldav_targets (
|
||||
appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
|
||||
binding_id uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,
|
||||
caldav_uid text NOT NULL, -- still 'paliad-appointment-<uuid>@paliad.de' — same for all bindings of one appointment
|
||||
caldav_etag text NOT NULL,
|
||||
last_pushed_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (appointment_id, binding_id)
|
||||
);
|
||||
CREATE INDEX appointment_caldav_targets_binding_idx ON paliad.appointment_caldav_targets(binding_id);
|
||||
-- RLS: visible/writable when the underlying binding's user_id = auth.uid().
|
||||
```
|
||||
|
||||
**UID stays per-appointment, not per-binding.** That keeps the iCal UID
|
||||
canonical (still `paliad-appointment-<uuid>@paliad.de`), so when a user
|
||||
removes a binding and re-adds it later, the same UID rebinds without
|
||||
spurious duplicates. The `.ics` filename in the calendar — `<uid>.ics`
|
||||
— is also identical across bindings, which means the same UUID
|
||||
shows up in different calendars on the same server but never collides
|
||||
because they're under different `calendar_path` collections.
|
||||
|
||||
### §3.3 Row examples for the four common organisations
|
||||
|
||||
| Organisation | Rows in `user_calendar_bindings` |
|
||||
|---|---|
|
||||
| **A — one cal, everything** | 1 row: `scope_kind='all_visible'`, `calendar_path='/cal/work'` |
|
||||
| **B — one cal per project** | N rows, all `scope_kind='project'`, distinct `(scope_id, calendar_path)` |
|
||||
| **C — master + per-project hybrid** | 1 row `scope_kind='all_visible'` + N rows `scope_kind='project'`. Each project event appears in both. |
|
||||
| **D — personal split from work** | 1 row `scope_kind='personal_only'` → `/cal/personal` + 1 row `scope_kind='all_visible'` (which will include the same personal events, so the user will more commonly pair `personal_only` with a `scope_kind='client'` per-client work view instead). |
|
||||
|
||||
### §3.4 What stays unchanged
|
||||
|
||||
- `paliad.user_caldav_config` — still holds the server URL, username,
|
||||
encrypted password, and a per-user `enabled` flag. The existing
|
||||
`calendar_path` column becomes a hint for the **default binding** we
|
||||
auto-create on migration and is no longer read by sync logic after
|
||||
Slice 1 ships. We keep it nullable-on-read for forwards-compat then
|
||||
drop in Slice 4.
|
||||
- `paliad.caldav_sync_log` — still per-user; sync entries gain a
|
||||
`binding_id` column (nullable for legacy rows) so the UI can show
|
||||
per-calendar last-sync state.
|
||||
- iCal serialisation (`caldav_ical.go`) — unchanged. Same VEVENT
|
||||
formatter feeds every binding.
|
||||
- AES-GCM credential encryption (`caldav_crypto.go`) — unchanged.
|
||||
|
||||
---
|
||||
|
||||
## §4 — Sync engine implications
|
||||
|
||||
The shape of the per-user goroutine stays. The body of `syncOnce`
|
||||
moves from "push to one path / pull from one path" to "for each
|
||||
enabled binding, push the scope-filtered slice / pull from that path".
|
||||
|
||||
### §4.1 Push fan-out
|
||||
|
||||
```go
|
||||
// pseudocode for the new pushAll body
|
||||
bindings := s.bindings.ListEnabled(ctx, userID) // 1..N rows
|
||||
for _, b := range bindings {
|
||||
appts := s.appointments.ForBinding(ctx, userID, b) // scope-filtered
|
||||
for _, a := range appts {
|
||||
body := formatAppointment(&a)
|
||||
etag, err := cli.PutEvent(ctx, b.CalendarPath, terminUID(a.ID), body)
|
||||
if err != nil { continue } // best-effort, per-binding error
|
||||
s.targets.Upsert(ctx, a.ID, b.ID, terminUID(a.ID), etag)
|
||||
}
|
||||
// Remove events from this calendar that no longer belong to the scope.
|
||||
for _, stale := range s.targets.DanglingForBinding(ctx, b.ID, currentIDs(appts)) {
|
||||
cli.DeleteEvent(ctx, b.CalendarPath, stale.CalDAVUID)
|
||||
s.targets.Delete(ctx, stale.AppointmentID, b.ID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ForBinding(userID, b)` is the scope filter:
|
||||
|
||||
- `all_visible` → existing `AllForUser(userID)`
|
||||
- `personal_only` → appointments with `project_id IS NULL AND created_by = userID`
|
||||
- `project` → appointments where `project_id = scope_id` AND visible to user
|
||||
- `client` / `litigation` / `patent` / `case` → appointments where the
|
||||
ancestor at the relevant hierarchy level = `scope_id` AND visible to user
|
||||
- when `include_personal = true`, union with personal events on top of the above (only for non-`all_visible`/`personal_only` scopes)
|
||||
|
||||
This reuses the existing `can_see_project()` predicate (per project
|
||||
CLAUDE.md, team-based RLS), so visibility shrinkage on a project unshare
|
||||
falls out naturally: next push sees the appointment is no longer in
|
||||
`ForBinding(...)`, sees a dangling target row, issues `DeleteEvent`.
|
||||
|
||||
### §4.2 Pull reconciliation
|
||||
|
||||
Each binding has its own pull pass against `b.CalendarPath`. The
|
||||
matching key is still `caldav_uid` — same UID across all bindings, so
|
||||
`appointments.FindByCalDAVUID(uid)` resolves the local row. The
|
||||
**ETag check is per-target row** now, not per-appointment: a remote
|
||||
edit in calendar X bumps the etag in `appointment_caldav_targets` for
|
||||
binding X only. The local Appointment is updated once (last-write-wins
|
||||
on Appointment.updated_at), the next push tick re-syncs the other
|
||||
bindings with the new payload (they see their stored etag is older
|
||||
than the appointment's `updated_at` and re-PUT).
|
||||
|
||||
**One subtle change:** the foreign-UID skip (`extractAppointmentID == ""`)
|
||||
still applies per-binding pull. That preserves the v1 "Paliad owns its
|
||||
UIDs" property — multi-calendar does not open the door to importing
|
||||
events the user creates in their calendar app. (If/when that becomes
|
||||
in-scope, it's a separate t-paliad-* design.)
|
||||
|
||||
### §4.3 Hooks (instant push)
|
||||
|
||||
`OnAppointmentCreated/Updated/Deleted` fan out across all the user's
|
||||
enabled bindings that match the appointment's scope. Same 30s-timeout
|
||||
background goroutine. The user-facing request still returns
|
||||
immediately; the failure mode is identical (best-effort per binding,
|
||||
logged on `slog.Warn`).
|
||||
|
||||
### §4.4 Bandwidth & rate limits
|
||||
|
||||
- Per user per tick: **N bindings × 1 PROPFIND + per-event GETs**.
|
||||
The pull GET is the dominant cost; a 50-binding user with 20 events
|
||||
per calendar is ~1 000 GETs/min, which is fine over HTTP/1.1 to a
|
||||
decent CalDAV server but **does** put us inside iCloud's
|
||||
~throttle-friendly band and risks Google's quota model.
|
||||
- Mitigation: switch pull to **`REPORT` `calendar-multiget`** so each
|
||||
binding's events come back in one round-trip. That's a single
|
||||
iteration on `caldav_client.go` (the same multistatus parser
|
||||
already handles the body) and pays for itself the moment a user
|
||||
has >10 events per binding. We deliberately deferred this in
|
||||
Phase F (one calendar, low volume) — multi-calendar makes it
|
||||
table-stakes. Plan to land it in **Slice 2** alongside the picker.
|
||||
- Rate limiting on the Paliad side: keep the 60s ticker, but stagger
|
||||
per-binding pulls so we never fire N concurrent PROPFINDs against
|
||||
the same provider. Sequential per binding is fine; we already do
|
||||
this implicitly with the per-user goroutine.
|
||||
|
||||
### §4.5 Server-side cleanup on binding delete
|
||||
|
||||
User deletes a binding → service:
|
||||
|
||||
1. Lists every (appointment, binding) target row for that binding.
|
||||
2. Issues `DELETE` per `.ics` on the remote calendar (best effort).
|
||||
3. Deletes the target rows.
|
||||
4. Deletes the binding row (or relies on `ON DELETE CASCADE` from
|
||||
target FK — cleaner to delete remotely first, then drop the row,
|
||||
so a half-failed cleanup leaves rows we can retry on next tick).
|
||||
|
||||
A "leave events behind in the external calendar" toggle is a real
|
||||
ask (users sometimes archive bindings without wanting their calendar
|
||||
app to suddenly empty). Plumb it as `binding.cleanup_on_delete bool`
|
||||
in Slice 2 if there's demand; default `true` (delete).
|
||||
|
||||
---
|
||||
|
||||
## §5 — Bidirectional vs one-way
|
||||
|
||||
**Recommendation: stay bidirectional, identical to today's semantics,
|
||||
per-binding.** Reasons:
|
||||
|
||||
1. **m's stated workflow expects round-trip.** Drag a deadline in
|
||||
Outlook → Paliad sees the new date → approval flow triggers
|
||||
(t-138). One-way push breaks that. Multi-calendar doesn't change
|
||||
this expectation; if anything, it strengthens it (the user picked
|
||||
the project-cal binding *because* they intend to edit there).
|
||||
2. **The conflict model is already in place.** Last-write-wins on
|
||||
ETag, foreign-UID skip, `LogConflict` audit append. Multi-calendar
|
||||
adds one new question: "if the user edits the same event in two
|
||||
different bindings between ticks, which wins?" Answer: the one
|
||||
that lands first in our pull pass. Bindings are iterated in
|
||||
`created_at` order so the behaviour is deterministic, and the
|
||||
second edit gets overwritten on the next tick when we re-push the
|
||||
resolved appointment to it. Acceptable trade-off; would only show
|
||||
up if a user actually edits the same event in two of their own
|
||||
calendars within 60s, which is vanishingly rare.
|
||||
3. **Approval-flow integration is unchanged.** Pending-approval
|
||||
events have the `[PENDING APPROVAL]` marker baked into the iCal
|
||||
summary by `caldav_ical.go:76+`. That marker survives multi-binding
|
||||
fan-out untouched; an external edit on a pending event still has
|
||||
the pre-existing bypass-the-gate hole (flagged §1, not in scope).
|
||||
|
||||
**Tee-up for m's call:** if multi-calendar is the wrong moment to
|
||||
keep bidirectional (e.g. because per-project calendars are about
|
||||
**read-only visibility for partners**, not editing), we'd add a
|
||||
`binding.read_only bool` column and skip the pull pass for that
|
||||
binding. Cheap to add now or later. **I recommend defaulting
|
||||
`read_only = false` (bidirectional like today) and only making it
|
||||
optional if m's first session with the UI surfaces the need.**
|
||||
|
||||
---
|
||||
|
||||
## §6 — User-facing config model
|
||||
|
||||
Surface on `/einstellungen/caldav` (already exists for Phase F creds).
|
||||
Two sections, in this order:
|
||||
|
||||
1. **Server** (existing) — URL, username, password, "test connection".
|
||||
Unchanged.
|
||||
2. **Calendars** (new) — list of bindings as cards / rows. For each:
|
||||
`display_name`, `calendar_path`, `scope_kind` chip (master /
|
||||
personal / project / …), `enabled` toggle, last-sync status, action
|
||||
buttons "Edit scope" / "Remove".
|
||||
3. **Add a calendar** — flow:
|
||||
- **a)** click "Add". Modal opens. We do a `PROPFIND
|
||||
<calendar-home-set>` against the user's server to discover their
|
||||
existing calendars; show as a picker. (RFC 6638 / 4791 calendar
|
||||
home set discovery — supported by iCloud, Fastmail, Nextcloud,
|
||||
Radicale, Baikal, SOGo. Google CalDAV does not expose this
|
||||
reliably; for Google users we degrade to a manual path entry box.)
|
||||
- **b)** user picks an existing calendar, or chooses "Create new
|
||||
calendar". Create-new attempts `MKCALENDAR` (works on iCloud,
|
||||
Fastmail, Nextcloud, Radicale, Baikal, SOGo; fails on Google →
|
||||
friendly error with copy-paste instruction).
|
||||
- **c)** user picks the **scope**: a radio between "Everything I can
|
||||
see", "Personal only", "One project", and (later) "One client /
|
||||
litigation / patent / case". Project picker uses the existing
|
||||
`/api/projects?…` autocomplete.
|
||||
- **d)** "Save" → POST `/api/caldav-bindings`. The next 60s tick
|
||||
starts pushing into the new calendar; the UI shows "Initial
|
||||
sync running…" with a live last-sync indicator (already polled
|
||||
by the existing `caldav-config` page).
|
||||
|
||||
4. **Quick-add affordances** (Slice 3 polish, not v1):
|
||||
- On a project's `/projects/<id>` page: "Open in calendar app" link
|
||||
if a binding already exists for that project, "Pin to a new
|
||||
calendar" if none does (deep-links to the Add-a-calendar modal
|
||||
pre-filled).
|
||||
- Bulk action "Create one calendar per active litigation" on
|
||||
`/einstellungen/caldav` (requires `MKCALENDAR` support; gated
|
||||
behind a server-capability probe at first PROPFIND).
|
||||
|
||||
5. **Soft limits in the UI:**
|
||||
- At **20 bindings**: yellow info banner "Most users keep ≤ 20
|
||||
calendars; review your list before adding more."
|
||||
- At **80 bindings**: red error, block adding new (we don't know
|
||||
the user's provider for sure; 80 is a safe ceiling for iCloud
|
||||
and Nextcloud-default).
|
||||
- Provider hint surfaced under the Server form: parsed from the
|
||||
URL host, with a "your provider's documented limit" line —
|
||||
pure courtesy, not enforced.
|
||||
|
||||
### §6.1 What the API contract looks like
|
||||
|
||||
| Verb + Path | Body / Returns | Notes |
|
||||
|---|---|---|
|
||||
| `GET /api/caldav-bindings` | array of binding rows + sync status | replaces having to interpret `user_caldav_config.calendar_path` |
|
||||
| `POST /api/caldav-bindings` | `{calendar_path, display_name, scope_kind, scope_id?, include_personal?}` → created binding | triggers immediate sync goroutine wake-up |
|
||||
| `PATCH /api/caldav-bindings/{id}` | partial; toggle `enabled` or change `scope_*` | re-runs `pushAll` for this binding |
|
||||
| `DELETE /api/caldav-bindings/{id}` | — | deletes external events first, then row |
|
||||
| `GET /api/caldav-discover` | array of `{href, displayname}` from server `<calendar-home-set>` | populates the picker; cached 5 min |
|
||||
| `POST /api/caldav-mkcalendar` | `{display_name, color?}` → `{calendar_path}` | issues `MKCALENDAR`; returns 501 on Google |
|
||||
|
||||
`GET /api/caldav-config` still works (back-compat for the server-creds
|
||||
section); its `calendar_path` field is documented as "deprecated, see
|
||||
/api/caldav-bindings".
|
||||
|
||||
---
|
||||
|
||||
## §7 — Slice plan
|
||||
|
||||
Tracer-bullet slices so each is independently shippable, safe to
|
||||
revert, and gives the user something they can see.
|
||||
|
||||
**Slice 1 — Schema + backfill (no UI change).**
|
||||
- Migration: create `user_calendar_bindings`, `appointment_caldav_targets`.
|
||||
- Backfill: for every existing `user_caldav_config` row, insert one
|
||||
`bindings` row `(user_id, calendar_path, display_name='', scope_kind='all_visible', enabled)`.
|
||||
For every Appointment with non-null `caldav_uid`, insert one
|
||||
`appointment_caldav_targets` row pointing at the user's new default
|
||||
binding.
|
||||
- Refactor `CalDAVService.syncOnce` / `pushAll` / `pullAll` to drive
|
||||
off bindings (loop of length 1 per existing user). Behaviour
|
||||
observably identical: same calendars, same events, same logs.
|
||||
- `appointments.caldav_uid` / `caldav_etag` columns still exist and
|
||||
are written for compatibility (treat them as denormalised pointers
|
||||
to the default binding's target row). UI unchanged.
|
||||
- **Exit criterion:** existing users see no change in their calendar;
|
||||
`caldav_sync_log.binding_id` is populated for all new rows; manually
|
||||
inserted second binding via SQL syncs correctly end-to-end on a
|
||||
staging account.
|
||||
|
||||
**Slice 2 — Binding-picker UI + multi-binding support.**
|
||||
- `/api/caldav-bindings` CRUD + `/api/caldav-discover` (PROPFIND
|
||||
`calendar-home-set`) + `/api/caldav-mkcalendar`.
|
||||
- New "Calendars" section on `/einstellungen/caldav` with the modal
|
||||
flow from §6.
|
||||
- **Land `REPORT calendar-multiget` pull** alongside (per §4.4).
|
||||
Required, not optional, for the bandwidth profile multi-binding
|
||||
introduces.
|
||||
- Scope kinds enabled in v1: `all_visible`, `personal_only`, `project`.
|
||||
Hierarchy scopes (`client`, `litigation`, `patent`, `case`) parked
|
||||
for Slice 3.
|
||||
- **Exit criterion:** m can pin a second calendar via the UI on
|
||||
staging; events for project X appear only in the X-bound calendar
|
||||
if his master binding is disabled, and in both if it's enabled.
|
||||
|
||||
**Slice 3 — Hierarchy scopes + project-page quick-adds.**
|
||||
- Enable `scope_kind ∈ {client, litigation, patent, case}` — pure
|
||||
filter-predicate change in `ForBinding(...)` using the existing
|
||||
project-tree walker.
|
||||
- "Pin to a new calendar" button on `/projects/<id>` and on the
|
||||
/einstellungen page.
|
||||
- Bulk "calendar-per-active-litigation" provisioner (with
|
||||
`MKCALENDAR` capability probe).
|
||||
- **Exit criterion:** real HLC PA can set up "one cal per
|
||||
litigation" in <5 min on first try without inventor help.
|
||||
|
||||
**Slice 4 — Polish + cleanup.**
|
||||
- Drop `appointments.caldav_uid` / `caldav_etag` after instrumentation
|
||||
shows zero readers outside `CalDAVService` (`grep` + a one-week
|
||||
query-log audit on the read replica).
|
||||
- Soft-limit banners (20 / 80).
|
||||
- `binding.read_only` and `binding.cleanup_on_delete` toggles if
|
||||
asked for by then.
|
||||
- **Exit criterion:** schema is final; no legacy paths remain in
|
||||
`caldav_service.go`.
|
||||
|
||||
**(Out of scope across all four slices:** foreign-UID import, custom
|
||||
event types per binding, per-binding colour mapping, MKCALENDAR for
|
||||
Google. These are easy to add later if the data says so.)
|
||||
|
||||
---
|
||||
|
||||
## §8 — Open questions for m
|
||||
|
||||
1. **Bidirectional default for new bindings: yes/no?** I recommend
|
||||
**yes** (matches today's single-cal behaviour and the round-trip
|
||||
workflow expectation). A `read_only` per-binding flag is cheap to
|
||||
add later if a real use case shows up. Decide now → Slice 1; decide
|
||||
later → Slice 4.
|
||||
2. **`personal_only` scope — keep or drop?** It's useful for users
|
||||
who want a "noisy team master + clean personal" split, but it's
|
||||
redundant for users who only use the master calendar. I'd keep
|
||||
it; trivial to remove if m disagrees.
|
||||
3. **`MKCALENDAR` (auto-create calendar) — ship in Slice 2 or defer
|
||||
to Slice 3?** Shipping it in Slice 2 means we need the
|
||||
capability-probe + Google-degrade UX up-front. Deferring means
|
||||
Slice 2 users have to pre-create the calendar in their app and
|
||||
paste the URL — workable but clunky. Default plan: **Slice 2,
|
||||
with a clean Google-degrade message**.
|
||||
4. **Soft cap numbers (20 / 80) — sensible?** Picked from §2
|
||||
provider limits + "most paliad users will pick 1–5". m may
|
||||
want different numbers — easy to tune.
|
||||
5. **`/admin/caldav-bindings` view for support debugging?** Not in
|
||||
the slice plan; useful if a user calls confused about which
|
||||
calendar holds which event. Add if m wants it.
|
||||
6. **Approval-flow + remote-edit gap (§1, the bypass) — fix scope?**
|
||||
Pre-existing in single-cal Phase F. Multi-cal makes it more
|
||||
visible. Should this be a follow-up under t-138, or folded into
|
||||
Slice 3? I'd file as a separate task.
|
||||
|
||||
---
|
||||
|
||||
## §9 — Why this is the right shape
|
||||
|
||||
- **Single CalDAV server per user, N bindings.** Matches every real
|
||||
provider's auth model (one auth blob covers all the user's
|
||||
calendars) and keeps `caldav_crypto.go` and `user_caldav_config`
|
||||
untouched.
|
||||
- **Binding scope is a row, not a static config.** Users compose
|
||||
the organisation they want without us guessing; defaults (one
|
||||
master binding on migration) preserve current behaviour.
|
||||
- **UID stays per-appointment.** Means an event re-binding (move
|
||||
from project-cal to master-cal) is just shuffling target rows,
|
||||
not minting new UIDs. Re-importing into the same calendar later
|
||||
rebinds cleanly.
|
||||
- **Sync engine shape is unchanged.** Same per-user goroutine, same
|
||||
60s tick, same hooks. The blast radius of multi-binding is one
|
||||
inner loop, gated behind a feature that backfills to a no-op for
|
||||
every existing user.
|
||||
- **Slices give m a vertical demo at each step.** Slice 1 is
|
||||
invisible-but-shippable; Slice 2 is the first user-facing change
|
||||
("you can pin a second calendar"); Slice 3 is "now organise by
|
||||
project tree"; Slice 4 is cleanup.
|
||||
- **No new external dependencies.** Same hand-rolled CalDAV client.
|
||||
Adds one new verb (`MKCALENDAR`) and one new report
|
||||
(`calendar-multiget`) — both small, both already half-tested
|
||||
against `caldav_client.go`'s patterns.
|
||||
|
||||
---
|
||||
|
||||
## §10 — Sources
|
||||
|
||||
- [Apple Support — Limits for iCloud Contacts, Calendars, Reminders, Bookmarks, and Maps](https://support.apple.com/en-us/103188) — iCloud 100 combined calendars + reminder lists.
|
||||
- [Google Workspace Updates — Automatic addition of owned secondary calendars, Jan 2026](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html) — Google ~100 owned recommendation.
|
||||
- [Fastmail — Account limits](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) — 100k events/user, no documented calendar count cap.
|
||||
- [Nextcloud admin manual — Calendar / CalDAV](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) — default 30, configurable, 10/hr rate limit.
|
||||
- Live verification against `internal/services/caldav_*.go` and `paliad.user_caldav_config` / `paliad.appointments` schema on the youpc Supabase instance.
|
||||
|
||||
---
|
||||
|
||||
## Addendum — m's decisions (2026-05-19)
|
||||
|
||||
Walked through §8.1–§8.6 with m via AskUserQuestion. Decisions are
|
||||
locked in for the coder shift; revisit only on Slice-3 feedback.
|
||||
|
||||
| Q | Decision | Implication for the slice plan |
|
||||
|---|---|---|
|
||||
| **§8.1 — Bidirectional default** | **Yes — bidirectional by default** | No `read_only` flag in Slice 1–3. Multi-cal inherits Phase F's last-write-wins / foreign-UID-skip semantics unchanged. Per-binding `read_only` only added later if a real use case shows up. |
|
||||
| **§8.2 — `personal_only` scope** | **Keep — first-class scope** | Ships in Slice 2 as one of the picker's radio options (`Everything I can see` / `Personal only` / `One project`). One enum value, one `ForBinding()` branch. |
|
||||
| **§8.3 — MKCALENDAR timing** | **Slice 2 with Google-degrade UX** | Slice 2 includes `POST /api/caldav-mkcalendar` + capability probe. Google users get a friendly "create the calendar in your Google UI, paste the URL" fallback. iCloud / Fastmail / Nextcloud / Radicale / Baikal / SOGo get one-click "Create new calendar". |
|
||||
| **§8.4 — Soft caps** | **No caps in v1, add later if data warrants** | Drop the 20-warn / 80-block UI guards from §6. Instrument `count(*)` on `user_calendar_bindings` per user as a Slice 2 telemetry add. Revisit if/when real distributions land. |
|
||||
| **§8.5 — `/admin/caldav-bindings` view** | **Don't ship in v1** | Stays out of the slice plan. Support debugging goes via Supabase SQL until a real ticket lands. Frees Slice 4 polish for the legacy-column drop only. |
|
||||
| **§8.6 — Approval-flow remote-edit gap** | **Separate task under t-138** | Out of scope for all four multi-cal slices. File the gap as a new `t-paliad-*` follow-up under t-138 so multi-cal stays clean and reverter-friendly. Pre-existing hole, surfaced not fixed. |
|
||||
|
||||
### Net effect on §7 slice plan
|
||||
|
||||
- **Slice 1** unchanged — schema + backfill, behaviour-equivalent.
|
||||
- **Slice 2** = picker UI + `REPORT calendar-multiget` + **MKCALENDAR
|
||||
with capability probe + Google-degrade message** + binding-count
|
||||
telemetry. No `read_only` flag, no soft caps, no admin view.
|
||||
Scopes enabled: `all_visible`, `personal_only`, `project`.
|
||||
- **Slice 3** = hierarchy scopes (`client` / `litigation` / `patent` / `case`)
|
||||
+ per-project quick-adds. **No** approval-gap fix folded in.
|
||||
- **Slice 4** = drop legacy `appointments.caldav_uid` / `caldav_etag`.
|
||||
Soft-cap banners only if Slice 2 telemetry says we need them.
|
||||
|
||||
### Net effect on §3 schema
|
||||
|
||||
No change. `user_calendar_bindings` still ships with the full
|
||||
`scope_kind` enum (including `personal_only`). `appointment_caldav_targets`
|
||||
unchanged. No `read_only` column in v1.
|
||||
|
||||
### Follow-ups to file as separate tasks
|
||||
|
||||
1. **`t-paliad-*` (under t-138):** approval-flow + CalDAV remote-edit
|
||||
gap. `ApplyRemoteUpdate` bypasses the approval gate when an external
|
||||
client edits a pending-approval event. Pre-existing in single-cal
|
||||
Phase F. Owner: t-138 maintainer.
|
||||
2. **(maybe) `t-paliad-*`:** soft-cap UI if Slice 2 telemetry shows
|
||||
any user near the iCloud-100 / Nextcloud-30 envelope. Not pre-filed
|
||||
— only opens if data warrants.
|
||||
603
docs/design-paliad-data-export-2026-05-19.md
Normal file
603
docs/design-paliad-data-export-2026-05-19.md
Normal file
@@ -0,0 +1,603 @@
|
||||
# Paliad data export — Excel-first, scoped (org / project-subtree / personal)
|
||||
|
||||
Design: archimedes (inventor), 2026-05-19.
|
||||
Task: **t-paliad-214**.
|
||||
Branch: `mai/archimedes/inventor-excel-data`.
|
||||
Status: READY FOR REVIEW — no code yet, awaiting m go/no-go on §11 open questions.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premise check (live state, 2026-05-19)
|
||||
|
||||
Verified directly against the youpc Postgres `paliad` schema rather than against memory or older design docs.
|
||||
|
||||
**Migration tracker.** Latest applied is `100_ccr_visible_rule`; next is **101**.
|
||||
|
||||
**Row counts (org-wide today):**
|
||||
|
||||
| table | rows |
|
||||
|------------------------|-----:|
|
||||
| users | 47 |
|
||||
| projects | 11 |
|
||||
| deadlines | 26 |
|
||||
| appointments | 5 |
|
||||
| parties | 0 |
|
||||
| notes | 4 |
|
||||
| documents | 0 |
|
||||
| project_events (audit) | 93 |
|
||||
| project_teams | 3 |
|
||||
| approval_requests | 8 |
|
||||
| approval_policies | 160 |
|
||||
| checklist_instances | 4 |
|
||||
| deadline_rules | 254 |
|
||||
| user_views | 2 |
|
||||
| partner_units | 11 |
|
||||
|
||||
A full org export today is **< 600 rows of user content** plus reference data — synchronous streamed download is plausible for every scope. We design for an order-of-magnitude head-room.
|
||||
|
||||
**Auth.** Passwords live in Supabase Auth (separate `auth` schema, not `paliad`). The `paliad.users` table has **no `password_hash` column** — so the "don't export credentials" rule from the brief is enforced by absence, not by a column-deny list. Good.
|
||||
|
||||
**Visibility.** Row-level via `paliad.can_see_project(project_id)` (subtree-aware through ltree path). Already used as the predicate that gates every list endpoint. We reuse it for the **personal** and **project** scopes; the **org** scope bypasses it under `global_admin`.
|
||||
|
||||
**Documents.** Table exists, 0 rows. Phase H (AI Frist-Extraktion) is deferred per m's 2026-04-16 call. No `ANTHROPIC_API_KEY` on Dokploy. Therefore **this design does not concern itself with binary attachments** — only with the metadata row when documents start landing.
|
||||
|
||||
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
|
||||
|
||||
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) — admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
|
||||
|
||||
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
Two motivations, both load-bearing:
|
||||
|
||||
1. **Safety / backup.** A workbook on disk is a portable artifact independent of the running app. If paliad.de is down, a partner needs the matter file. If the Dokploy compose corrupts, IT needs a recent dump. If a deadline gets accidentally deleted, we want a recoverable snapshot.
|
||||
|
||||
2. **No lock-in.** A team or an entire org choosing to leave paliad must be able to walk away with their entire dataset in a format anyone can open. We promise this in writing as a trust signal — exactly because the alternative (silently locking customers in) is what we built paliad to *not* be.
|
||||
|
||||
The export is therefore not a "nice analytics feature" — it is **a contractual guarantee that the data is yours**. That framing shapes the design: completeness > convenience, portability > polish, every export auditable.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope definitions (precise)
|
||||
|
||||
Three scopes. The boundary is **what the caller is allowed to see**, joined with **what makes the artifact interpretable standalone**.
|
||||
|
||||
### 2.1 `org` scope
|
||||
|
||||
**Caller:** `global_role='global_admin'` only. There is no firm-admin role distinct from global_admin in paliad today (see §4).
|
||||
|
||||
**Content:** literally everything in the `paliad` schema that is user content or reference data the workbook needs to be readable. Specifically:
|
||||
|
||||
| sheet | source table(s) | notes |
|
||||
|------------------------|-------------------------------------------------------------------|-------|
|
||||
| `projects` | `paliad.projects` (all rows) | Full project tree including soft-deleted (status='deleted' / 'closed' if any). |
|
||||
| `project_teams` | `paliad.project_teams` | profession + responsibility (post-t-148). |
|
||||
| `project_partner_units`| `paliad.project_partner_units` | Derivation grants. |
|
||||
| `deadlines` | `paliad.deadlines` | Including completed, cancelled. |
|
||||
| `appointments` | `paliad.appointments` | Including completed. |
|
||||
| `parties` | `paliad.parties` | All client / opposing-party data. |
|
||||
| `notes` | `paliad.notes` | All four polymorphic targets resolved into the `target_kind`/`target_id` columns. |
|
||||
| `documents` | `paliad.documents` metadata (file_path, file_size, mime_type, ai_extracted) | Binaries excluded (open Q1). |
|
||||
| `audit_events` | `paliad.project_events` | Full audit trail per project. |
|
||||
| `approval_requests` | `paliad.approval_requests` | Including completed / rejected, with `requester_kind` + `agent_turn_id`. |
|
||||
| `approval_policies` | `paliad.approval_policies` | Both project-scoped and partner-unit-defaults. |
|
||||
| `policy_audit_log` | `paliad.policy_audit_log` | Source #5 of the audit union. |
|
||||
| `partner_units` | `paliad.partner_units` | Org chart. |
|
||||
| `partner_unit_members` | `paliad.partner_unit_members` | Including unit_role. |
|
||||
| `partner_unit_events` | `paliad.partner_unit_events` | Org-chart audit. |
|
||||
| `checklist_instances` | `paliad.checklist_instances` | Per-project completion state. |
|
||||
| `invitations` | `paliad.invitations` (status, role, expires_at) | Without raw tokens (open Q7). |
|
||||
| `users` | `paliad.users` (id, email, display_name, office, profession, …) | Excludes `email_preferences` jsonb only if it carries channel secrets — none do today, but checked at export time. |
|
||||
| `user_views` | `paliad.user_views` | Saved filters / custom layouts. |
|
||||
| `user_card_layouts` | `paliad.user_card_layouts` | Project-card layouts. |
|
||||
| `user_pinned_projects` | `paliad.user_pinned_projects` | Per-user pins. |
|
||||
| `user_caldav_config` | `paliad.user_caldav_config` **without** the ciphertext column | URL + calendar IDs + last_sync; passwords NEVER exported. |
|
||||
| `reminder_log` | `paliad.reminder_log` | Outbound digest history. |
|
||||
| `caldav_sync_log` | `paliad.caldav_sync_log` | Per-user sync runs. |
|
||||
| `paliadin_turns` | `paliad.paliadin_turns` | **Excluded by default** in org export (privacy — see §6) — admins opt in per Q5. |
|
||||
| `email_broadcasts` | `paliad.email_broadcasts` | Outbound broadcast history. |
|
||||
| `email_templates` + `_versions` | both | Custom firm templates. |
|
||||
| **reference (read-only):** | `proceeding_types`, `event_types`, `event_categories`, `deadline_rules`, `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_category_concepts`, `trigger_events`, `holidays`, `courts`, `countries` | One sheet per table, prefixed `ref__`. Embedded so the workbook is interpretable without paliad context. |
|
||||
| **deferred audit (admin opt-in):** | `deadline_rule_audit`, `policy_audit_log`, `partner_unit_events`, `caldav_sync_log`, `paliadin_turns` | Behaviour per Q5/Q6. |
|
||||
|
||||
**Excluded unconditionally:**
|
||||
- `auth.*` (Supabase Auth schema — not ours; the user can request their auth record from Supabase directly).
|
||||
- `paliad_schema_migrations` (operational, no business meaning).
|
||||
- `*_pre_NNN` shadow / pre-migration backup tables (rows are duplicates; the live table is canonical).
|
||||
- Any future `*_secret` / `*_token` columns (see §6 deny-list mechanism).
|
||||
|
||||
**Edge cases:**
|
||||
- **Soft-deleted rows:** paliad currently has no soft-delete columns (`deleted_at` etc.). When that lands, the org export includes them by default with a `deleted_at` column populated. Until then, this is a no-op.
|
||||
- **Archived projects:** `projects.status` can be `'closed'` or future `'archived'` — export includes them (the whole point of backup is recoverability of closed matters).
|
||||
- **Counterclaims:** `projects.counterclaim_of` is a self-FK. Export carries the column as-is; the relationship is reconstructable via the `id` column.
|
||||
|
||||
### 2.2 `project` scope
|
||||
|
||||
**Caller:** any team member of the project who passes the §4 profession-tier gate.
|
||||
|
||||
**Content:** one project + **all descendants** along the ltree path. The descendant walk is `WHERE path <@ root.path` (subtree-inclusive of root). Every entity gets filtered through `WHERE project_id IN (subtree_ids)`.
|
||||
|
||||
Per-sheet inclusion:
|
||||
|
||||
- `projects` (root + descendants, one row each)
|
||||
- `project_teams` (membership for those projects)
|
||||
- `project_partner_units` (derivation attachments)
|
||||
- `deadlines`, `appointments`, `parties`, `notes`, `documents` (metadata), `audit_events`, `approval_requests`, `checklist_instances` — all scoped to subtree
|
||||
- **users sheet — restricted columns:** only `id, email, display_name, office, profession` for users referenced by any FK in the export (created_by, assigned, etc.). Don't dump all 47 users when you only need 4. (Avoids accidental org-chart leak in a project-scope export shared externally.)
|
||||
- **reference data:** `ref__proceeding_types`, `ref__event_types`, `ref__deadline_rules`, `ref__deadline_concepts`, `ref__courts`, `ref__countries`, `ref__holidays`. Same as org but a smaller universe is acceptable too — the v1 ships the full reference tables for simplicity (every row count is ≤ 300; size is moot).
|
||||
- **Cross-project references** (e.g., a party referenced by a project outside the subtree): out of scope by the predicate. The export carries the foreign UUID so a re-import or merge could re-link, but the foreign row itself is not in the workbook. Edge case is rare — `counterclaim_of` is the only known cross-project pointer today.
|
||||
|
||||
**Edge cases:**
|
||||
- **Partner-unit data:** `partner_units` is org-wide; project export carries only the unit ids attached via `project_partner_units`. The unit name + membership are loaded into the workbook on `partner_units` and `partner_unit_members` sheets (filtered to the attached units only).
|
||||
- **Policies:** `approval_policies` rows include both project-scoped (the project + ancestors) **and** partner-unit-defaults attached to this project. Same MAX-of-sources logic as runtime.
|
||||
- **Audit:** `project_events` for the subtree + (admin opt-in only) `deadline_rule_audit` rows whose rule was used by any deadline in the subtree. Default off — these are firm-wide curation logs and don't belong in a per-project handoff.
|
||||
|
||||
### 2.3 `personal` scope
|
||||
|
||||
**Semantics:** "everything I can see right now in paliad, framed as my data."
|
||||
|
||||
That definition resolves the ambiguity in the brief: personal scope is **not** "rows where I am `created_by`" — that misses everything I see by being on a team. It is **the RLS-visible projection of the schema for caller=me**, plus a handful of explicitly-personal sidecars (caldav config, my pins, my views).
|
||||
|
||||
Per-sheet inclusion:
|
||||
|
||||
| sheet | rows |
|
||||
|---|---|
|
||||
| `projects` | `WHERE paliad.can_see_project(id)` for the caller. |
|
||||
| `project_teams` | Rows where `user_id = me` OR the row's project is in my visible set. |
|
||||
| `deadlines` | Same project-visibility filter. |
|
||||
| `appointments` | Same. |
|
||||
| `parties`, `notes`, `documents` metadata, `audit_events`, `checklist_instances` | Same. |
|
||||
| `approval_requests` | Rows where `requested_by = me` OR `decided_by = me` OR project ∈ visible set. |
|
||||
| `me` (single-row sheet) | Caller's `users` row (id, email, display_name, office, profession, reminder_*, lang, escalation_contact_id). |
|
||||
| `my_caldav_config` | The caller's `user_caldav_config` row **without** the encrypted password column — sync URL, calendar IDs, last_sync_at. |
|
||||
| `my_views` | Caller's `user_views` rows. |
|
||||
| `my_pinned_projects` | Caller's `user_pinned_projects` rows. |
|
||||
| `my_card_layouts` | Caller's `user_card_layouts` rows. |
|
||||
| `my_paliadin_turns` | Caller's `paliadin_turns` rows (currently restricted to `PaliadinOwnerEmail` = m, so this sheet is empty for everyone else). Sensitive: AI prompts + responses. **Default on for personal scope** — it's literally the caller's data. |
|
||||
| `users_referenced` | Restricted: id + display_name + email for users referenced as FKs in the export. |
|
||||
| reference tables | Same set as project scope. |
|
||||
|
||||
**Edge cases:**
|
||||
- **Caller leaves a team:** the export reflects the moment-in-time visibility. A `generated_at` timestamp in the workbook header (`__meta` sheet) anchors this.
|
||||
- **Caller is a global_admin:** their personal export is the entire org (because their visible set = all projects). This is by design — but we surface a banner ("Sie sehen alles, weil Sie global_admin sind. Ein org-scope-Export wäre identisch.") so they don't get confused thinking the personal-scope endpoint is broken.
|
||||
- **Caller has no team memberships:** export contains the empty workbook + the `me` row + their caldav config + views/pins. Still useful — they can save their preferences.
|
||||
|
||||
### 2.4 Common columns across all scopes
|
||||
|
||||
Every export workbook contains a `__meta` sheet:
|
||||
|
||||
```
|
||||
schema_version: 1
|
||||
firm_name: HLC # from internal/branding.Name
|
||||
scope: org | project | personal
|
||||
scope_root_id: uuid or NULL # the project id for project-scope, NULL otherwise
|
||||
generated_at: 2026-05-19T14:23:00Z
|
||||
generated_by_user: <uuid> <email> # the caller
|
||||
generated_by_label: archimedes / m / ... # display_name
|
||||
row_counts: JSON {"projects": 11, ...}
|
||||
paliad_version: <git sha at server build>
|
||||
notes: free-form, e.g., "documents binaries excluded by design"
|
||||
```
|
||||
|
||||
This pins provenance + reproducibility + diffability.
|
||||
|
||||
---
|
||||
|
||||
## 3. Format choices
|
||||
|
||||
### 3.1 xlsx as the primary format
|
||||
|
||||
**Library: `github.com/xuri/excelize/v2`.** De-facto Go xlsx library, pure-Go (no cgo, no external libreoffice), MIT, streaming writer for large workbooks, broad format-feature support (number formats, freeze panes, hyperlinks, sheet hide). The streaming writer (`NewStreamWriter`) is what we use — it writes rows one at a time without holding the whole sheet in memory. At 11-projects scale this is unnecessary; at 11k-projects scale it's essential, so we set the pattern now.
|
||||
|
||||
**Why not the alternatives:**
|
||||
- `tealeg/xlsx` — older, unmaintained, no streaming.
|
||||
- `qax-os/excelize` — same project as xuri/excelize (the github org renamed); xuri is the upstream.
|
||||
- `360EntSecGroup-Skylar/excelize` — defunct fork.
|
||||
|
||||
**Workbook structure:** one **sheet per entity type**, *never* a mixed-type sheet with conditional columns. Reasons:
|
||||
- Excel users sort + filter by column; a column that means "deadline due_date" on row 4 and "appointment start_at" on row 12 is unusable.
|
||||
- The "self-describing" promise (no-lock-in) is satisfied by a workbook where every sheet is a flat table with stable column headers, not by a polymorphic blob.
|
||||
- Cross-sheet relationships are represented by **UUIDs in foreign-key columns** + a `__lookup` sheet pairing UUID → display label (project title, user email) for the workbook's lifetime. This makes the workbook self-joining in Power Query / pivot tables.
|
||||
|
||||
**Sheet conventions:**
|
||||
- Sheet names use `snake_case` matching SQL table names (`deadlines`, not `Fristen`). Reference tables prefixed `ref__`. Personal sidecars prefixed `my_`. Meta sheet `__meta`. The `__lookup` sheet sits last.
|
||||
- Row 1 = column headers; frozen.
|
||||
- Column 1 of every entity sheet is `id` (uuid).
|
||||
- Dates: ISO 8601 UTC for timestamptz; `YYYY-MM-DD` for `date`. Always as Excel strings (not Excel date types) — Excel-date interpretation differs by locale (DE: `Tag.Monat.Jahr`, EN: `Month/Day/Year`) and silently corrupts on round-trip. A pinned ISO string is unambiguous and re-importable. Open Q4 covers whether to *also* mirror to native Excel dates for human convenience.
|
||||
- Booleans: literal `TRUE` / `FALSE` strings, same reason.
|
||||
- `jsonb` columns: serialised as compact JSON one-liners in the cell. Cell type = string. Power Query can `Json.Document` them.
|
||||
- Arrays (e.g., `additional_offices text[]`): semicolon-joined string. Excel's CSV-array convention is the comma but our office codes use commas; semicolon avoids the collision.
|
||||
- `text[uuid[]]` paths (the projects.path ltree): exported as the canonical dotted-uuid string.
|
||||
|
||||
**Encoding:** UTF-8 always. Excelize handles the xlsx packaging which is unicode-native. Umlaute round-trip correctly (verified pattern with tesla's CSV export in t-paliad-177).
|
||||
|
||||
### 3.2 CSV + JSON siblings
|
||||
|
||||
Per the no-lock-in promise, **xlsx is not enough on its own** — Excel is a proprietary format owned by Microsoft, and a workbook is opaque without a tool that understands it. For genuine portability we also produce:
|
||||
|
||||
- **CSV:** one file per entity sheet (no reference sheets — those go as JSON), UTF-8 with BOM (`\xEF\xBB\xBF`) for Excel-DE compat, RFC 4180 quoting, headers row 1. Identical column shape to the xlsx sheet.
|
||||
- **JSON:** a single `paliad-export.json` per scope, top-level `{"meta": {...}, "tables": {"projects": [...], "deadlines": [...], ...}}`. Easiest for programmatic re-ingest. Reference tables included.
|
||||
|
||||
**Delivery shape:** all three formats live inside one `.zip` per export:
|
||||
```
|
||||
paliad-export-<scope>-<timestamp>.zip
|
||||
├── README.txt # human-readable: what this is, how to read it
|
||||
├── paliad-export.xlsx # canonical workbook
|
||||
├── paliad-export.json # JSON twin (machine-readable)
|
||||
├── csv/
|
||||
│ ├── projects.csv
|
||||
│ ├── deadlines.csv
|
||||
│ ├── ...
|
||||
│ └── ref/
|
||||
│ ├── proceeding_types.csv
|
||||
│ └── ...
|
||||
└── __meta.json # standalone meta (same content as __meta sheet)
|
||||
```
|
||||
|
||||
The `.zip` is the artifact users download. Default content is "all three" — there's no UI knob to pick (open Q1: should there be? Inventor pick = no, zip-only).
|
||||
|
||||
**Filename convention:**
|
||||
```
|
||||
paliad-export-{scope}-{timestamp}.zip
|
||||
scope = org | project-<root-short> | personal
|
||||
timestamp = YYYY-MM-DDTHHMMZ # UTC, no colons (Windows-safe)
|
||||
```
|
||||
Examples: `paliad-export-org-2026-05-19T1423Z.zip`, `paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip`, `paliad-export-personal-2026-05-19T1423Z.zip`. The project-short is `slugify(root.title)` capped 40 chars.
|
||||
|
||||
**Determinism (Q6 question).** Two exports of the same scope at the same row state must produce **byte-identical** workbooks. xlsx is internally a zip of XML — file order in the zip is significant; excelize's default zip writer is non-deterministic. We can make this deterministic by sorting the file list before writing. JSON: keys sorted alphabetically. CSV: rows ordered by `id ASC` (stable). The only inherently non-deterministic field is `generated_at`; we externalise it to the filename and the `__meta` sheet, but the rest of the workbook is byte-stable. **Inventor pick: yes, deterministic.** Lets users diff exports and prove "nothing changed between Tuesday and Thursday."
|
||||
|
||||
### 3.3 Future-proofing — schema_version
|
||||
|
||||
`__meta.schema_version = 1`. When we add columns (e.g., projects.archived_at lands), we bump to 2 and note the additions in a `docs/export-schema-changelog.md`. Importers (us in the future, or a re-importer at a different firm) read schema_version first.
|
||||
|
||||
---
|
||||
|
||||
## 4. Authorization model
|
||||
|
||||
**Tightly mirrored to existing paliad role surfaces.** No new roles introduced.
|
||||
|
||||
| Scope | Required auth |
|
||||
|---|---|
|
||||
| `org` | `paliad.users.global_role = 'global_admin'`. Same gate as `/admin/*` pages (`auth.RequireAdminFunc` in `handlers.go:417`). |
|
||||
| `project` | Caller must (a) pass `can_see_project(root_id)`, AND (b) have effective project profession ≥ **associate** on the root. The associate floor mirrors the conservative seed in `approval_policies` (t-154); paralegals + PA can see data but not extract it. m-tunable per Q2. |
|
||||
| `personal` | Any authenticated user. No additional gate. |
|
||||
|
||||
**Profession ladder check** for project scope uses the existing `DerivationService.EffectiveProjectRole` (t-139 phase 2) — direct membership > ancestor > derived via partner-unit. Same surface that gates approvals; same surface gates extracts.
|
||||
|
||||
**Audit row written on every export run.** A new event_type into `paliad.project_events` for project-scope (so it appears on the project's Verlauf), `partner_unit_events` for org-scope (so it appears on the partner-unit audit log of the firm-admin's home unit), and `policy_audit_log` is too narrow — we likely want a **new** audit table for org-wide actions, OR we widen `project_events` to allow `project_id = NULL` org-wide rows. **Inventor pick: new table `paliad.system_audit_log`** — clean separation, integrates into the existing 5-source AuditService union as source #6. Migration 101 adds it.
|
||||
|
||||
`system_audit_log` columns:
|
||||
```sql
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_type text NOT NULL, -- 'data_export'
|
||||
actor_id uuid REFERENCES paliad.users(id),
|
||||
actor_email text NOT NULL, -- captured at write time, survives user deletion
|
||||
scope text NOT NULL, -- 'org' | 'project' | 'personal'
|
||||
scope_root uuid, -- project_id for project scope, NULL otherwise
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"formats":["xlsx","json","csv"], "row_counts":{...}, "file_size_bytes":12345, "filename":"..."}
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
```
|
||||
|
||||
The audit row is written **before** the export runs (so failed exports are still recorded) and **updated** with `file_size_bytes` + final `row_counts` on success. Failure case: separate `event_type='data_export_failed'` row with the error string in metadata. **The audit chain is the trust signal** — m sees who exfiltrated what, when, and how much.
|
||||
|
||||
**Headers on the response:**
|
||||
- `Content-Disposition: attachment; filename="paliad-export-<scope>-<ts>.zip"`
|
||||
- `X-Paliad-Export-Audit-Id: <system_audit_log.id>` — so an automated client can reference the audit row.
|
||||
|
||||
---
|
||||
|
||||
## 5. Trigger model
|
||||
|
||||
Three trigger surfaces:
|
||||
|
||||
### 5.1 On-demand button
|
||||
|
||||
- **Personal:** `/settings` → "Daten exportieren" card → button. POST `/api/me/export` → 200 with `Content-Type: application/zip`. Synchronous.
|
||||
- **Project:** `/projects/{id}` → settings/cog menu → "Daten dieses Projekts exportieren". POST `/api/projects/{id}/export` → 200 zip. Synchronous. Includes a "Inkl. Unterprojekte" toggle hint (it's always subtree-inclusive — the toggle is purely informational, no off switch).
|
||||
- **Org:** `/admin/data-export` (new page, card on `/admin`) → "Org-Export erstellen" button. POST `/api/admin/export/org` → **async** by default (see §6.1). Returns 202 + `job_id`. UI polls `/api/admin/export/org/jobs/{id}` for status.
|
||||
|
||||
**Why org is async even at today's scale:** the principle isn't "is it slow now" — it's "the trigger model should not change as the firm grows." If the partner with the firm-wide button gets a different UX from the associate with the project button, we'd retrofit later. Sync at 600 rows works fine; the wrapping is `goroutine + channel + Server-Sent Events for live progress`, no new infra needed. See §6.1.
|
||||
|
||||
### 5.2 Scheduled exports
|
||||
|
||||
**Inventor pick — defer to slice 4.** Out of v1 scope. The reasoning: scheduling sits on storage + delivery + retention, all of which are *also* deferred to slice 3+. Building the scheduler before we know how + where the artifact lives is premature.
|
||||
|
||||
When it lands (slice 4), the model is:
|
||||
- A new `paliad.scheduled_exports` table: `(id, scope, scope_root_id, owner_user_id, cadence, last_run_at, next_run_at, delivery)` where `delivery` is `{kind: 'email-link' | 'caldav-style-webdav', config: jsonb}`.
|
||||
- A daily cron (mai cron or a `time.Ticker` goroutine) checks `next_run_at < now()`, runs the export, posts the link via the configured delivery channel.
|
||||
- Cadence: weekly + monthly + on-status-change (e.g., "export when project closes" — a webhook from `projects.status` triggers).
|
||||
|
||||
For now (slice 1-2), users can right-click the on-demand button and bookmark the URL — that's the **only** scheduled-export-y thing we offer, and it's intentional: get the manual flow rock-solid before adding cadence.
|
||||
|
||||
### 5.3 API endpoint
|
||||
|
||||
Same endpoints as §5.1, callable directly with the standard cookie / bearer auth. We don't add a separate "API key" surface in v1 — paliad doesn't have personal access tokens today. If a user wants to script their personal export weekly, they can use cookie auth from `m/paliad` automation; that's enough until power-user volume justifies a real PAT surface.
|
||||
|
||||
For machine ergonomics: the `/api/...export` endpoints accept `?format=zip` (default), `?format=xlsx`, `?format=json`, `?format=csv-zip` query params. Only `zip` is documented; the others are internal but reachable for automation.
|
||||
|
||||
---
|
||||
|
||||
## 6. Storage + delivery
|
||||
|
||||
### 6.1 Synchronous vs async — per-scope picks
|
||||
|
||||
**Personal, project:** **Synchronous, streamed.** The handler holds the HTTP connection open, writes the zip directly to `http.ResponseWriter`. For 1MB-class exports (today's reality at every scale up to thousands of rows per entity) this is the right call — no persistence, nothing to garbage-collect, nothing leaking onto disk. Excelize's `NewStreamWriter` flushes rows as they're written so RAM stays bounded.
|
||||
|
||||
**Org:** **Asynchronous, in-process queue, on-disk artifact.**
|
||||
- Submit (`POST /api/admin/export/org`) writes a `system_audit_log` row with status `pending` and dispatches a goroutine.
|
||||
- The goroutine writes the zip to `/var/lib/paliad/exports/{audit_id}.zip` (configurable via `PALIAD_EXPORT_DIR`; on Dokploy this is a mounted volume).
|
||||
- The goroutine updates the audit row's metadata with progress, then status `done` with `file_size_bytes` on success.
|
||||
- The user polls `GET /api/admin/export/org/jobs/{audit_id}` (SSE or simple JSON) — when ready, a download link `GET /api/admin/export/org/jobs/{audit_id}/download` serves the file.
|
||||
- Download deletes the file by default (one-shot link), or keeps it per Q3.
|
||||
|
||||
**Why not S3-style bucket?** Paliad already has a `documents` table that *will* need a binary store, eventually. Coupling export storage to that future store is right — but the future store doesn't exist yet, and we don't want to provision MinIO on mlake purely for exports. **Inventor pick: local disk in `PALIAD_EXPORT_DIR`** until/unless we provision a real object store; at that point the export storage moves there transparently.
|
||||
|
||||
### 6.2 Retention (Q3)
|
||||
|
||||
**Inventor pick: 7 days, then auto-delete.** Justifications:
|
||||
1. Exports contain sensitive client data — minimising the retention window minimises blast radius if the Dokploy host is compromised.
|
||||
2. 7 days covers a holiday-week round-trip ("I exported Friday, want to look at it Monday next week, missed the day-1 link").
|
||||
3. The audit row in `system_audit_log` persists forever — you can always tell that an export happened, even after the artifact is deleted.
|
||||
|
||||
A cleanup goroutine runs daily, lists `system_audit_log` rows older than 7 days with non-NULL `file_path`, deletes the file, sets `metadata.deleted_at`. Audit row stays.
|
||||
|
||||
The `PALIAD_EXPORT_RETENTION_DAYS` env var is the knob (default `7`). m-tunable per firm.
|
||||
|
||||
### 6.3 PII / GDPR
|
||||
|
||||
This is where the design gets serious.
|
||||
|
||||
**At-rest encryption.** Files in `PALIAD_EXPORT_DIR` are plaintext on the Dokploy volume. The volume itself is encrypted at the host layer (Hostinger VPS disk encryption). We **do not** layer additional file-level encryption on the artifact — that would require a per-user key, key escrow, key rotation, all of which is over-engineered for a 7-day-retention exfil where the link is single-use behind cookie auth. The disk encryption + 7-day TTL + audit log is the trust boundary.
|
||||
|
||||
**In-transit encryption.** TLS via Dokploy + Traefik — paliad.de is Let's Encrypt-served. No raw HTTP path.
|
||||
|
||||
**Download authentication.** The download link `/api/admin/export/org/jobs/{audit_id}/download` requires the same cookie auth as the submit. No public signed URLs in v1 (deferred per Q8). When we add scheduled exports + email delivery (slice 4), we'll need expiring signed URLs — that design is captured then, not now.
|
||||
|
||||
**Data-subject requests.** A user invoking `/api/me/export` is, in effect, performing a self-serve GDPR Art. 15 data-portability request. Audit row records the request. If the firm receives a *third-party* DSR ("export the data my client Mr. Müller asked for"), a global_admin can run a project-scope export filtered to projects involving that client; this is a manual workflow we don't automate in v1 (open Q9).
|
||||
|
||||
**Right-to-erasure.** Out of scope. Erasure is a write path; export is read-only. They share no code.
|
||||
|
||||
**External sharing of export files.** A user who downloads an export and emails it to an external party has done so on their own authority and outside paliad's protection. We don't watermark the file (debated and rejected: watermarking introduces non-determinism, breaks diffability, and gives false security — anyone reading the zip can strip metadata). What we *do* document in the embedded `README.txt`:
|
||||
|
||||
> Diese Datei enthält möglicherweise vertrauliche Mandantsdaten. Sie wurde
|
||||
> erzeugt am {generated_at} durch {actor_email} aus Paliad ({firm_name}).
|
||||
> Die Weitergabe an Dritte erfolgt in eigener Verantwortung des Empfängers.
|
||||
|
||||
A simple "you broke the seal" notice is what we offer. It's a contract, not a control.
|
||||
|
||||
**PII column deny-list.** Hard-coded in `internal/services/export_service.go`:
|
||||
- `paliad.users.password_hash` — doesn't exist, but the deny-list is the safety net if it ever does.
|
||||
- `paliad.user_caldav_config.encrypted_password` — explicit drop.
|
||||
- Any column whose name matches `(?i)secret|token|password|api[_-]?key|private[_-]?key` — caught at column-discovery time, errors loudly into `system_audit_log.metadata.warnings`.
|
||||
- `paliadin_turns.assistant_response` — present in personal export of caller's own data; **off** in org export by default (m's call per Q5).
|
||||
|
||||
### 6.4 GDPR-completeness note
|
||||
|
||||
The export of one user's personal scope is **a partial Art. 15 disclosure** — it contains what's *in paliad's* control. Other systems (Supabase Auth row, mlake logs, CalDAV provider) are out of paliad's scope and not in the export. The embedded README states this explicitly so the user knows the workbook is the paliad-side answer, not a complete personal-data dump from "the firm."
|
||||
|
||||
---
|
||||
|
||||
## 7. Slice plan
|
||||
|
||||
Tracer-bullet shipping. Each slice is independently shippable and reviewable. The first slice closes the no-lock-in promise for the smallest, lowest-risk scope; later slices widen.
|
||||
|
||||
### Slice 1 — personal export, synchronous, xlsx + JSON
|
||||
|
||||
- Adds `excelize/v2` to `go.mod`.
|
||||
- New `internal/services/export_service.go` with the column-discovery + writer plumbing for xlsx + JSON.
|
||||
- New `internal/handlers/export.go` with `POST /api/me/export`.
|
||||
- New `/settings` UI: "Daten exportieren" card + button.
|
||||
- Migration 101: `paliad.system_audit_log` + `AuditService.ListEntries` 6th union branch.
|
||||
- i18n keys (`settings.export.*`, `__meta.*`).
|
||||
- Tests: `export_service_test.go` covers xlsx structure (one row each kind), JSON shape, PII deny-list.
|
||||
|
||||
Ships the no-lock-in promise for every user immediately. ~600-800 LoC + ~25 i18n keys.
|
||||
|
||||
### Slice 2 — project export, synchronous, xlsx + JSON + CSV-zip
|
||||
|
||||
- Generalises the export_service to scope-aware queries (the visibility predicate gets injected per scope).
|
||||
- New `POST /api/projects/{id}/export`, gated by §4.
|
||||
- Adds CSV writer alongside xlsx + JSON; bundles all three into `.zip`.
|
||||
- Project-detail UI gets the export menu entry.
|
||||
- README.txt template embedded.
|
||||
- Tests + e2e (Playwright) on the project page button.
|
||||
|
||||
~800-1000 LoC. The CSV path generalises the xlsx column-discovery so the marginal cost is low. After this slice, two of three scopes are shipped and synchronous serves both.
|
||||
|
||||
### Slice 3 — org export, async with job tracking
|
||||
|
||||
- Adds the goroutine + on-disk artifact path + `PALIAD_EXPORT_DIR` env.
|
||||
- `POST /api/admin/export/org` + job status + download endpoints.
|
||||
- New `/admin/data-export` page (card on `/admin/`).
|
||||
- Cleanup goroutine (daily, deletes artifacts > `PALIAD_EXPORT_RETENTION_DAYS`).
|
||||
- Refactor: extract the now-common "writeExportToWriter" core from the synchronous path so async re-uses it.
|
||||
|
||||
~600-800 LoC. After this slice, all three scopes ship + audit trail is complete.
|
||||
|
||||
### Slice 4 — scheduled exports (deferred, not v1)
|
||||
|
||||
Designed in §5.2; building deferred until at least 2 firms ask. The contract surface is the `scheduled_exports` table + cadence + delivery channel.
|
||||
|
||||
### Slice 5 — API ergonomics (deferred)
|
||||
|
||||
Personal Access Tokens (the "I want to cron my own export" surface). Until there's a customer, we don't build the PAT issuer + revocation + audit.
|
||||
|
||||
### Slice 6 — GDPR DSR helpers (deferred)
|
||||
|
||||
A `/admin/data-subject-request` workflow to assemble a per-natural-person export across projects. Built on Slice 1-3 primitives; not blocked by them.
|
||||
|
||||
### Slice 7 — document binary inclusion (deferred until documents have rows)
|
||||
|
||||
When the `documents` table starts holding real files, the export adds a `documents/` subdir in the zip with the actual files, keyed by filename = `{document_id}.{ext}`. The metadata sheet links by id. Adds ~150 LoC + an env var for the file backend.
|
||||
|
||||
**Critical-path slices for v1: 1 + 2 + 3.** Everything after is layered, optional, m-prioritised when there's a real customer pull.
|
||||
|
||||
---
|
||||
|
||||
## 8. Trade-offs flagged
|
||||
|
||||
1. **xlsx-first means we own the `excelize` dependency forever.** Mitigation: excelize is the canonical Go xlsx — replacing it would be a multi-thousand-LoC migration, but the upstream is healthy (MIT, 17k+ stars, monthly releases). Acceptable lock-in.
|
||||
|
||||
2. **Determinism (sorted file order, sorted JSON keys, row-id-ordered CSV) is implementation discipline, not a library default.** Test that breaks if any future change introduces non-determinism is essential (helps reviewers + prevents regressions).
|
||||
|
||||
3. **Synchronous personal + project means a runaway export can block a request goroutine for seconds.** At today's data shape this is sub-second. Watchdog: a 30s context deadline on synchronous exports; over that, return 503 with "export too large — contact admin for async." Triggers slice 3 → slice 4 of the user's mental model.
|
||||
|
||||
4. **Per-scope endpoints triplicate similar code paths.** Mitigated by the shared `ExportSpec` struct + scope-aware predicate injection. Read carefully in code review — this is the place subtle scope leaks creep in.
|
||||
|
||||
5. **JSON twin is genuinely redundant for human users.** It's there for the no-lock-in promise (a Python script can re-ingest without Excel). The cost is one extra file in the zip + one extra serialisation pass. Acceptable.
|
||||
|
||||
6. **No diff tooling — yet.** Determinism enables `diff -r` between two extracted zips, but no in-app surface. Slice 4+ may layer "show me what changed between Monday's and Friday's export" once exports are scheduled and stored.
|
||||
|
||||
7. **`paliadin_turns` privacy default.** Currently restricted to `PaliadinOwnerEmail` so the table is empty for every other user. Personal export carries them by default ("your AI history"); org export by default does NOT (admin opt-in via `?include=paliadin_turns`). When Paliadin opens past owner-only (post-API cutover), revisit.
|
||||
|
||||
8. **Reference-data inclusion bloats every export.** 254 deadline_rules + 102 trigger_events + 56 concepts + … = ~1000 reference rows in every workbook regardless of scope. At zip-compressed sizes this is < 100KB and worth the standalone-interpretability. If the workbook gets too large later, ship reference data as a separate "paliad-reference-snapshot.zip" once + reference it from each export's README.
|
||||
|
||||
9. **Org export volume at firm-scale.** A 10k-project firm has ~50k deadlines and ~200k audit events. Even at 200 bytes/row average that's < 100MB — comfortable for the async path with 4GB Dokploy RAM. Threshold concerns kick in at 1M+ rows, which is firm-class-of-100-attorneys territory. Designed for, not blocked on.
|
||||
|
||||
10. **Audit-log explosion.** A nightly cron + 47 users self-exporting = 50 audit rows / day. At a year that's 18k rows. Still trivial. No retention on the audit chain (the artifact retention does NOT touch audit-log retention — the audit chain is the trust signal, see §4).
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommended implementer
|
||||
|
||||
**Single PR, layered slices 1 → 2 → 3 as separate commits.** No DB-heavy migrations; the only schema add is `system_audit_log` (one table, one trigger if any). The hard work is in the writer abstraction.
|
||||
|
||||
- **Slice 1:** pattern-fluent Sonnet coder. ~600-800 LoC, mostly bookkeeping. Tests pin the shape.
|
||||
- **Slice 2:** same hands as slice 1 (continuity matters here — the writer abstraction is set in slice 1 and the project scope generalises it).
|
||||
- **Slice 3:** same hands again. The async path is its own subsystem but the writer is unchanged.
|
||||
|
||||
**NOT cronus** per memory directive 2026-05-06 (retired from paliad).
|
||||
**NOT m** — this is a coder task end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## 10. Inventor → coder transition (GATED per project CLAUDE.md)
|
||||
|
||||
Per `.claude/CLAUDE.md`: design phase ends here. No code touches the tree from inventor. Head's `mai-head` skill gates the coder shift after m's go on §11 open questions.
|
||||
|
||||
When approved, the coder shift opens on `mai/<coder-name>/data-export-slice-1` (fresh branch off main, NOT off the design branch — design doc commit is the only artifact this branch carries forward via cherry-pick).
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for m
|
||||
|
||||
The brief lists 8 candidate questions. After live-state verification I've collapsed + sharpened to 9, each with an inventor pick + reasoning. Will be asked sequentially via AskUserQuestion (paliad dogma — no `## §X.Y` markdown dump on m, per t-paliad-154 lesson).
|
||||
|
||||
### Q1 — Bundle xlsx + CSV + JSON in one zip, or let user pick format?
|
||||
|
||||
**Inventor pick: bundle all three in one zip, no UI knob.**
|
||||
|
||||
Reasoning: the no-lock-in promise *requires* the JSON twin (Excel-independent re-ingest); the xlsx is the human-readable default; CSV is the universal lingua franca. Picking only one breaks the promise for some user. Bundle size at today's scale is < 1MB; even at firm-scale it's well under the email-attachment limit. The cost of a checkbox UI is more than the cost of three extra files.
|
||||
|
||||
Alternative: offer `?format=xlsx-only|json-only|csv-only` query params for the API surface, default to bundle. Documented in README only. We do this in v1 anyway since multi-format is what generates the zip in the first place.
|
||||
|
||||
### Q2 — Project-scope profession floor: associate (inventor pick) or member?
|
||||
|
||||
**Inventor pick: associate floor.**
|
||||
|
||||
A project export carries party names, addresses, decision-history, draft strategy notes. That's "I can write a paper for the partner" data, not "I can see the deadline calendar" data. Member is the bare-visibility tier (you got added to the team). Export is exfiltration — needs the next tier up.
|
||||
|
||||
Alternative: gate by `responsibility ∈ {lead, member}` (no profession floor, only the project-team responsibility check). Cleaner architecturally — separates the "can see" axis from the "can extract" axis using the same fields. Less restrictive in practice.
|
||||
|
||||
Worth choosing now because the gate text in the audit row mentions the tier.
|
||||
|
||||
### Q3 — Org-export artifact retention: 7 days (pick) or 30 / 90?
|
||||
|
||||
**Inventor pick: 7 days.**
|
||||
|
||||
Default conservative. m-tunable per firm via env var.
|
||||
|
||||
### Q4 — Excel dates: ISO strings only (pick) or also a mirrored native-Excel-date column?
|
||||
|
||||
**Inventor pick: ISO strings only.**
|
||||
|
||||
Native Excel dates are locale-poisoned (DE vs EN epoch interpretation flips, round-trip corruption when re-saved). ISO is the universal answer. Power users who want a sortable native-date column can derive it once in their workbook — but the canonical export stays unambiguous.
|
||||
|
||||
### Q5 — `paliadin_turns` in org export: opt-in only (pick), or include by default?
|
||||
|
||||
**Inventor pick: opt-in via `?include=paliadin_turns` query.**
|
||||
|
||||
Today it's m-only data (`PaliadinOwnerEmail` gate), so the privacy stakes are low — but the *moment* Paliadin opens beyond owner-only, the AI conversation history per user is the most sensitive personal data we carry. Setting the off-by-default precedent now means we don't accidentally start dumping it later.
|
||||
|
||||
### Q6 — Deterministic byte-for-byte exports: yes (pick) or accept timestamp drift in zip metadata?
|
||||
|
||||
**Inventor pick: yes, deterministic.**
|
||||
|
||||
Lets users diff exports across time. Cost: ~50 lines of `sort.Strings` + a custom zip writer with stable ordering. Worth it.
|
||||
|
||||
### Q7 — Invitation tokens in org export: drop them entirely (pick) or include as hash?
|
||||
|
||||
**Inventor pick: drop entirely.**
|
||||
|
||||
Tokens grant signup access. Including them in a backup creates a vulnerability surface — an exfiltrated backup could be used to sign up as someone-else with their pending invite. Hashing doesn't help because the hash is what the URL contains. The invitation **row** (recipient, role, expiry, sent_at) is in the export; the token is not. If you need to re-issue, you do so from paliad's invite UI.
|
||||
|
||||
### Q8 — Public signed-URL downloads (for scheduled/email delivery): yes / not in v1 (pick)?
|
||||
|
||||
**Inventor pick: not in v1.**
|
||||
|
||||
Defer to slice 4. v1's download is cookie-authenticated only. Signed URLs are useful when the recipient is asynchronously notified (email link), which is the scheduled-export model — and that whole subsystem ships later.
|
||||
|
||||
### Q9 — GDPR Art. 15 DSR helper UI: not in v1 (pick)?
|
||||
|
||||
**Inventor pick: not in v1.**
|
||||
|
||||
A global_admin can already assemble a DSR manually using project-scope exports filtered by client. v1 ships the primitives; v2 ships the workflow.
|
||||
|
||||
### Closing question for m: implementer
|
||||
|
||||
> Recommend pattern-fluent Sonnet for all three slices, same hands across (continuity matters for the writer abstraction). Specific name = your call.
|
||||
|
||||
---
|
||||
|
||||
## 12. m's decisions (addendum, 2026-05-19)
|
||||
|
||||
m walked the §11 questions live via AskUserQuestion. Results below — these supersede the inventor picks where they differ.
|
||||
|
||||
- **Q1 — Bundle format:** Bundle xlsx + JSON + CSV in one `.zip` per export. ✓ matches pick.
|
||||
- **Q2 — Project-scope floor:** **Any team member** (`responsibility ∈ {lead, member}`). ⚠ **Deviation** from associate-floor pick — m chose the looser axis-split gate. **Implementation update for §4:** project-scope auth becomes `(a) can_see_project(root_id) AND (b) caller is on project_teams for the root with responsibility ∈ {lead, member}`. The DerivationService profession check is dropped from the export gate; observers + externals + derived-only members still cannot extract. `system_audit_log.metadata` records the responsibility value the caller held at export time.
|
||||
- **Q3 — Org-export retention:** **90 days**. ⚠ **Deviation** from 7-day pick. **Implementation update for §6.2:** `PALIAD_EXPORT_RETENTION_DAYS` default flips from `7` to `90`. The cleanup goroutine still runs daily; the threshold is just longer. Audit row unaffected (still persists forever).
|
||||
- **Q4 — Date format:** ISO 8601 strings only. ✓ matches pick.
|
||||
- **Q5 — paliadin_turns in org export:** **Never include in org export.** ⚠ **Tighter** than opt-in pick. **Implementation update for §2.1 + §6.3:** the `paliadin_turns` row drops from the org-scope sheet table entirely — no `?include=paliadin_turns` query param. Personal scope still carries the caller's own paliadin_turns (it's literally their data). The hard exclusion is enforced in `export_service.go`'s scope-aware sheet registry, not just in column-discovery, so a future schema addition can't accidentally re-include it.
|
||||
- **Q6 — Deterministic exports:** Yes. ✓ matches pick. (m answered freeform "1" alongside the batching request — first option = deterministic.)
|
||||
- **Q7 — Invitation tokens:** Drop entirely. ✓ matches pick.
|
||||
- **Q8 — Signed URLs in v1:** Not in v1. ✓ matches pick.
|
||||
- **Q9 — GDPR DSR helper UI in v1:** Not in v1. ✓ matches pick.
|
||||
|
||||
**Net effect on slice plan:** unchanged shape, three modifications:
|
||||
- Slice 2 gate logic uses `project_teams.responsibility` only (no profession lookup).
|
||||
- Slice 3 default retention is 90 days (one env-var value change).
|
||||
- Slice 1 + 3 sheet registry omits `paliadin_turns` from org scope entirely.
|
||||
|
||||
No other slice deltas. v1 still ships slices 1+2+3.
|
||||
|
||||
**Coder shift gating:** head still gates the implementation handoff; m's decisions here close §11 but don't auto-trigger coder work.
|
||||
|
||||
---
|
||||
|
||||
## 13. Adjacent / out-of-scope
|
||||
|
||||
- **Import path** — explicitly out per brief. A round-trip "export then re-import" is appealing but is its own design (rebinding UUIDs, conflict resolution, schema_version migrations). Don't conflate.
|
||||
- **Postgres replacement** — the Excel workbook is a *backup* + *portability artifact*, not a data-model alternative. Postgres stays canonical.
|
||||
- **t-paliad-212 (leibniz, CalDAV multi-calendar):** personal export already carries the caller's caldav config (minus ciphertext). When leibniz designs multi-calendar, the personal export's `my_caldav_config` sheet becomes a list rather than a single row — handled by column-discovery automatically. No design conflict; flagged for confirmation when leibniz's design lands.
|
||||
- **t-paliad-213 (mendel, test strategy):** export service warrants pure-function tests for column discovery, deny-list, scope predicate, plus one e2e (Playwright) per scope endpoint. Slice tests pin the contract; mendel's overall strategy decides framework choice.
|
||||
|
||||
---
|
||||
|
||||
## 14. References
|
||||
|
||||
- `docs/design-data-model-v2.md` — projects + mandanten + ltree path + can_see_project predicate.
|
||||
- `docs/design-approval-policy-ui-2026-05-07.md` — 5-source audit union (this design adds the 6th source).
|
||||
- `docs/design-profession-vs-project-role-2026-05-07.md` — profession ladder for the §4 project gate.
|
||||
- `internal/handlers/admin_rules.go:303` — `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
|
||||
- `internal/services/project_service.go:15` — visibility predicate.
|
||||
- `internal/services/derivation_service.go` — `EffectiveProjectRole` for the project gate.
|
||||
- `github.com/xuri/excelize/v2` — chosen xlsx library.
|
||||
|
||||
---
|
||||
|
||||
**END OF DESIGN. Status: READY FOR REVIEW.**
|
||||
|
||||
Inventor parks until m's go/no-go on §11. No code touches the tree from this branch.
|
||||
582
docs/design-paliad-test-strategy-2026-05-19.md
Normal file
582
docs/design-paliad-test-strategy-2026-05-19.md
Normal file
@@ -0,0 +1,582 @@
|
||||
# Design — Paliad Test Strategy (production-grade)
|
||||
|
||||
**Author:** mendel (inventor)
|
||||
**Date:** 2026-05-19
|
||||
**Task:** t-paliad-213
|
||||
**Branch:** `mai/mendel/inventor-test-strategy`
|
||||
**Status:** DESIGN READY FOR REVIEW. No test files / Make targets / CI configs touched. Awaiting m go/no-go on §5 slice plan + §6 open questions before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
Paliad has accidental test discipline today: 59 `_test.go` files / 323 test functions in Go (≈45 % of services tested, ≈12 % of handlers tested) and 4 frontend test files for 90+ client modules (≈4 %). There is no committed end-to-end suite and no CI — every smoke pass is human-driven via the manual reports in `tests/`. The `mig 098` prod crash-loop, the `t-paliad-036` triple-bug after the German→English rename, and a long tail of UX regressions (deadline-done modal, calendar column drift) would all have been caught by a 10-test boot-and-click smoke pass.
|
||||
|
||||
This design proposes a six-layer test pyramid with a concrete tool per layer (stdlib `testing` + bun's built-in `bun:test` + `playwright` for E2E — nothing third-party we don't already use). It pins three lessons paliad has paid for in commits:
|
||||
|
||||
1. **No mocks at the service↔DB boundary.** Live-DB tests against a per-developer Postgres are the floor; in-memory mocks for `paliad.*` would have hidden every rename-after-DROP-CASCADE bug. Project preference is already in this direction (27/44 service tests are live-DB-gated); we double down rather than reverse.
|
||||
2. **Migrations must dry-run before they merge.** Every recent prod-down (mig 098, mig 020-after-rename, mig 099 audit_reason gap) was a migration that compiled, passed `go test ./...` (which skips without `TEST_DATABASE_URL`), and broke on first apply against the real schema. A `make verify-migrations` target that does BEGIN/apply/ROLLBACK in CI fixes the entire failure mode.
|
||||
3. **Browser-shaped bugs need a browser.** The fristenrechner cascade, shape-timeline render, calendar grid, inline paliadin widget — these are JS state machines. Bun's stdlib `bun:test` covers the pure parser/codec code; Playwright covers the auth-gated DOM. Don't try to substitute one for the other.
|
||||
|
||||
Six slices roll the strategy out as tracer-bullet PRs, each independently shippable. Slice 1 (migration dry-run harness) and Slice 4 (Playwright golden-path smoke) buy the most outage-prevention per LoC; the rest is widening proven patterns.
|
||||
|
||||
Six open questions for m at §6. Most surface a coverage-vs-cost trade-off — the picks that need m's call before any code lands are CI infrastructure choice (Q2), per-PR run-time budget (Q1), and live-DB-vs-dockerised Postgres (Q3).
|
||||
|
||||
---
|
||||
|
||||
## 1. Audit — what exists today
|
||||
|
||||
Counts taken on `mai/mendel/inventor-test-strategy` @ HEAD (2026-05-19, 100 migrations applied).
|
||||
|
||||
### 1.1 Go test inventory
|
||||
|
||||
| Package | Source files | Test files | Test functions | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `internal/services` | 56 | 44 | ~200 | 26 live-DB-gated (`TEST_DATABASE_URL`), 18 pure-Go. 24 services have **no test file at all** — see §1.4. |
|
||||
| `internal/handlers` | 59 | 7 | ~30 | Only auth-domain check, search, audit-parse, approval-error-mapping, redirects, verfahrensablauf-redirect, chart-404 covered. **53 handlers have no test file.** |
|
||||
| `internal/auth` | small | 2 | ~10 | Session middleware + require-admin. |
|
||||
| `internal/branding` | small | 1 | small | Firm-name override. |
|
||||
| `internal/offices` | small | 1 | small | Office enum. |
|
||||
| `internal/changelog` | small | 1 | small | Pure parser. |
|
||||
| `internal/calc` | small | 1 | small | Fees / fee tables. |
|
||||
| `cmd/server` | 1 | 1 | small | `main_paliadin_backend_test.go` covers env-gate selection. |
|
||||
| **Total** | **133** | **58** | **323** | |
|
||||
|
||||
`go test ./...` runs all 58 files. Without `TEST_DATABASE_URL` set, 27 of them silently skip their live-DB cases — the suite still passes, but coverage of mutation paths drops to near zero.
|
||||
|
||||
### 1.2 Frontend test inventory
|
||||
|
||||
| Path | Test files | Tested |
|
||||
|---|---|---|
|
||||
| `frontend/src/client/filter-bar/url-codec.test.ts` | 1 | FilterBar URL codec round-trip. |
|
||||
| `frontend/src/client/views/format.test.ts` | 1 | Date/time formatters (regression for t-paliad-153). |
|
||||
| `frontend/src/client/views/shape-timeline-chart.test.ts` | 1 | Chart layout pure function. |
|
||||
| `frontend/src/client/views/shape-timeline-cv.test.ts` | 1 | Continuous-view shape layout. |
|
||||
| **Total** | **4** | Out of ~90 client modules (`frontend/src/client/*.ts`). |
|
||||
|
||||
All four use bun's built-in `bun:test` (no extra dep). No DOM/jsdom tests. No Playwright. No `bun test` script in `package.json` (`bun run build` is the only script).
|
||||
|
||||
### 1.3 End-to-end / smoke
|
||||
|
||||
- `tests/smoke-2026-04-25.md`, `tests/smoke-auth-2026-04-25.md`, `tests/smoke-auth-2026-04-26-cleanup.md` — human-written reports with screenshots committed under `tests/screenshots-*`. No code. No re-runnable script.
|
||||
- `mai-tester` skill uses Playwright for ad-hoc runs; nothing committed.
|
||||
- No `e2e/`, no `.gitea/workflows/`, no `.github/workflows/`, no `Makefile`.
|
||||
|
||||
### 1.4 Critical service paths with no test file
|
||||
|
||||
These are `internal/services/*.go` for which no `*_test.go` sibling exists:
|
||||
|
||||
| Service | Risk class | Why it matters |
|
||||
|---|---|---|
|
||||
| `caldav_service.go`, `caldav_client.go`, `caldav_crypto.go`, `caldav_ical.go` | High | Per-user push/pull goroutines + AES-GCM at rest. One pure parser test (`caldav_ical_timeline_test.go`) exists but the service + crypto + WebDAV client are blind. |
|
||||
| `agenda_service.go` | High | Dashboard agenda query; reused by `/agenda` page. Exercised transitively by visibility tests but no direct test. |
|
||||
| `dashboard_service.go` | High | Traffic-light + summary counts. Same story — transitively covered via visibility, no direct test. |
|
||||
| `derivation_service.go` | Medium | Project-tree derivation (the new t-paliad-194-era subtree machinery). |
|
||||
| `team_service.go` | Medium | Team membership / inheritance. |
|
||||
| `partner_unit_service.go` | Medium | Dezernat replacement (t-paliad-070). |
|
||||
| `party_service.go`, `note_service.go`, `link_service.go`, `checklist_instance_service.go` | Medium | All do project-scoped CRUD with the same RLS+audit pattern that `t-paliad-036` proved easy to break. |
|
||||
| `appointment_service.go` | High | Hot — every calendar mutation. Exercised through approval tests but has no own test file. |
|
||||
| `view_service.go` | Medium | Powers the substrate (`/views/*`). |
|
||||
| `paliadin_jwt.go` | Medium | Per-turn JWT mint for the aichat path (`t-paliad-194`). No call sites in tests today. |
|
||||
| `markdown.go` | Low | Glossary + checklist content render. |
|
||||
|
||||
### 1.5 Handlers with no test file
|
||||
|
||||
53 of 59. Notably: **`auth.go` itself** (login / logout / session creation), **`projects.go`** (the most-mutated entity), **`deadlines.go` / `appointments.go`** (writes), **`paliadin.go` / `paliadin_suggest.go`** (m-only routes — never click-tested), **`fristenrechner.go` / `fristenrechner_search.go` / `fristenrechner_event_categories.go`** (the cascade users live in), **`dashboard.go` / `agenda.go`** (landing), **`onboarding.go` / `onboarding_gate.go`** (every new user's first three minutes), **`invite.go`** (rate-limited write path). The currently-tested handlers (search, audit-parse, approval error mapping, etc.) are the cheap pure-Go ones; every handler that touches the DB is untested at handler level.
|
||||
|
||||
### 1.6 Live-DB test scaffold — is it sound?
|
||||
|
||||
The pattern (read from `internal/services/visibility_test.go`):
|
||||
|
||||
```go
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" { t.Skip("TEST_DATABASE_URL not set — skipping live DB test") }
|
||||
if err := db.ApplyMigrations(url); err != nil { t.Fatalf(...) }
|
||||
pool, _ := sqlx.Connect("postgres", url)
|
||||
defer pool.Close()
|
||||
// per-test seed + cleanup via DELETE + defer cleanup()
|
||||
```
|
||||
|
||||
Verdict: **sound, but has rough edges that need addressing before we widen.**
|
||||
|
||||
- ✅ Migrations apply at test startup against the test DB — catches every "you forgot to add a CHECK" / "you reference a column that doesn't exist" before a real-DB-touching test runs.
|
||||
- ✅ Per-test cleanup via `DELETE FROM ... WHERE id IN ($1,...)` is explicit and idempotent.
|
||||
- ✅ The `paliad.paliad_schema_migrations` tracker collision noted in memory `0b900afa…` is a pre-existing issue, not introduced by this design.
|
||||
- ⚠️ Cleanup-via-DELETE is fragile: a test that creates a row referenced by FK from another table needs to remember to clean both. A few existing tests (see `audit_service_test.go`) already chain 5+ DELETEs.
|
||||
- ⚠️ Tests can't run in parallel against the same `TEST_DATABASE_URL` because they share schema state. `go test ./...` defaults to `-parallel` per-package; same-package tests with overlapping cleanup IDs can interfere.
|
||||
- ⚠️ No CI today actually exercises `TEST_DATABASE_URL` — so every live-DB test is effectively run only on the author's laptop or not at all. Half the value is paid-for but unbilled.
|
||||
|
||||
### 1.7 Migration tooling
|
||||
|
||||
- `internal/db/migrate.go` embeds `migrations/*.sql` and applies on server boot via `golang-migrate/v4` with the `paliad_schema_migrations` tracker in `public` schema.
|
||||
- 100 migrations on disk (`001` → `100`).
|
||||
- **No dry-run gate today.** A bad migration breaks `paliad.de` at boot (Dokploy crash-loops the container). Recent prod incidents: mig 098 (submission code rename), mig 099 (with_po flag drop missed audit_reason gap), mig 020 (function rename without body rewrite — see memory `49a05cfa…`).
|
||||
- `down.sql` exists for every migration but no test ever exercises it.
|
||||
|
||||
### 1.8 CI / deploy loop
|
||||
|
||||
- No CI. Push-to-main → Gitea webhook → Dokploy auto-builds the Dockerfile and replaces the container. The Dockerfile runs `bun run build` then `go build`. **Neither `go test` nor `bun test` runs in the build pipeline.**
|
||||
- Pre-commit hooks: none in repo. Each worker runs `go build / go vet / go test / bun run build` by convention (see memories — every shipped task report ends with "build hygiene held").
|
||||
|
||||
---
|
||||
|
||||
## 2. Test pyramid — recommended shape
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ E2E (Playwright)│ ~10 flows
|
||||
│ L6 │
|
||||
└─────────────────┘
|
||||
┌─────────────────────────┐
|
||||
│ Handler integration │ ~30 routes
|
||||
│ L5 (httptest + real DB)│
|
||||
└─────────────────────────┘
|
||||
┌──────────────────────────────────┐
|
||||
│ Service-layer (live DB) │ ~60 tests
|
||||
│ L4 (BEGIN/ROLLBACK harness) │
|
||||
└──────────────────────────────────┘
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Frontend DOM / cascade (bun:test+jsdom) │ ~15 modules
|
||||
│ L3 │
|
||||
└──────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Frontend unit (bun:test pure TS) │ ~30 modules
|
||||
│ L2 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Go unit (stdlib testing, table-driven, pure functions) │ ~150 tests
|
||||
│ L1 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Migration dry-run (make verify-migrations) │ 100 mig
|
||||
│ L0 — gate on every PR │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layer 0 — Migration dry-run
|
||||
|
||||
**What:** Every `*.up.sql` in `internal/db/migrations/` is applied inside a single `BEGIN ... ROLLBACK` transaction against a scratch Postgres, in numeric order. The harness asserts each statement succeeds *and* asserts no statement leaves the schema in a `paliad_schema_migrations.dirty=true` state. A second pass applies all up-migrations end-to-end (no rollback) and then re-applies the latest up-migration to assert idempotency (every paliad migration since `t-paliad-070` has been written to be idempotent — this enforces it).
|
||||
|
||||
**Tool:** stdlib `testing` package, no third-party. Pattern: `internal/db/migrate_test.go` with a `TestMigrations_DryRun` driven from `TEST_DATABASE_URL`. A `make verify-migrations` target wraps it.
|
||||
|
||||
**Why this layer matters most:** Every recent prod-down was a migration. Catching them on a CI run before merge is the highest-leverage test investment paliad can make. Cost: one ~100-line Go file + one Postgres in CI.
|
||||
|
||||
**Coverage target:** 100 % of `*.up.sql` files. Hard gate on PR — no exceptions.
|
||||
|
||||
### Layer 1 — Go unit (pure)
|
||||
|
||||
**What:** `go test ./...` against pure functions — formatters, parsers, validators, calculators, fee tables, deadline calculators, projection lookahead clamping, codec round-trips. No DB, no HTTP.
|
||||
|
||||
**Tool:** stdlib `testing`. Table-driven `cases := []struct{...}{...}` style is already the house pattern (see `auth_test.go` / `projection_anchor_test.go`). **Do not introduce testify or any matcher library** — the current code reads cleanly without one, and 323 existing test functions don't need a rename pass.
|
||||
|
||||
**What's already there:** 19 pure-Go test files (calculator, mapping, codec, holiday, fees, etc.). Density is good; targeted infill rather than re-architecture.
|
||||
|
||||
**Coverage target:** Every pure function in `internal/services/`, `internal/handlers/`, `internal/calc/`, `internal/changelog/`. Aim for "every branch in a decision table has at least one test row." Don't chase % — chase "the obvious edge that would burn a coworker".
|
||||
|
||||
### Layer 2 — Frontend unit (pure)
|
||||
|
||||
**What:** `bun test` against pure TS modules — URL codecs (`filter-bar/url-codec`), formatters, parsers, i18n key correctness (every `data-i18n` attribute used in TSX has a key in `i18n.ts`), view-spec parsers, projection-row mapping helpers.
|
||||
|
||||
**Tool:** `bun:test` (built into bun, no install). Already in use in 4 files — extend the same pattern. Add `bun test` to `package.json` `scripts`.
|
||||
|
||||
**What to add:**
|
||||
- i18n key audit (every `t("foo.bar")` and `data-i18n="foo.bar"` resolves in both `de` and `en`).
|
||||
- `filter-bar/` types + render helpers (paliad has shipped 4 FilterBar slices; coverage is one codec test).
|
||||
- `paliadin-context.ts` route table + entity extraction (the `[ctx …]` envelope is a stable contract paliadin's SKILL.md depends on; any drift here is a silent failure).
|
||||
- `paliadin-starters.ts` registry — every route maps to ≥1 starter; every starter is bilingual.
|
||||
- View-spec parsers in `views/`.
|
||||
|
||||
**Coverage target:** Every pure TS module in `frontend/src/client/`. Pages (TSX renderers) are E2E concern, not unit concern.
|
||||
|
||||
### Layer 3 — Frontend DOM (cascade / jsdom)
|
||||
|
||||
**What:** `bun test` with jsdom global, exercising the interactive cascade modules — the fristenrechner cascade builder, the shape-timeline render, the FilterBar UI (chips, panels), the calendar grid, the inline Paliadin widget message stream, the inbox-row click handler, the dashboard activity item navigation.
|
||||
|
||||
These modules contain enough state that pure-function tests miss real bugs (e.g. the t-paliad-098 `.entity-table` row-cursor lie was a CSS+DOM bug; t-paliad-099's modal close was a DOM-event bug; t-paliad-103's `::before` overlay click-swallow was a DOM bug).
|
||||
|
||||
**Tool:** bun + `happy-dom` is the lighter choice; if it can't handle event ordering, fall back to `jsdom`. Both are ESM-clean and bun-friendly. **Pick one and stick with it — running both means twice the dependency surface.** Default pick: `happy-dom` (smaller, paliad doesn't need legacy IE semantics).
|
||||
|
||||
**Pattern:** import the cascade module, build a minimal DOM (`document.body.innerHTML = …`), dispatch synthetic events, assert resulting state. Reuses the production renderers — no test-only fakes.
|
||||
|
||||
**Coverage target:** ~15 modules. Specifically:
|
||||
- `client/filter-bar/index.ts` chip render + active-state.
|
||||
- `client/fristenrechner.ts` cascade — most complex JS in the codebase; depend chains light up every UPC bug we know.
|
||||
- `client/shape-timeline.ts` lane mode + track mode (envelope wire shape brittle to refactor).
|
||||
- `client/projects-detail.ts` row click + Verlauf render.
|
||||
- `client/paliadin-widget.ts` + `paliadin-context.ts` interaction.
|
||||
- `client/inbox.ts` row-action click routing.
|
||||
- `client/dashboard.ts` activity-item nav.
|
||||
- `client/deadlines-calendar.ts` / `appointments-calendar.ts` column layout (the calendar-column-drift bug class).
|
||||
|
||||
Not unit tests; not E2E. They are the missing middle.
|
||||
|
||||
### Layer 4 — Service-layer (live DB)
|
||||
|
||||
**What:** Go service methods against a real Postgres, using the existing `TEST_DATABASE_URL` pattern. Two improvements:
|
||||
|
||||
1. **Replace per-test DELETE cleanup with a per-test transaction harness** — open a transaction, run the test inside it, ROLLBACK. Faster, isolating, no cleanup forgotten. Already viable because the service layer accepts `*sqlx.DB`-or-tx-shaped interfaces in many places; needs a small `internal/services/internal/testdb` package that exposes `WithTx(t *testing.T, fn func(*sqlx.Tx))`. Migration is mechanical, can happen alongside infill.
|
||||
|
||||
*Caveat:* some service methods open their own transactions internally (`approval_service.submit` is one). Those keep DELETE cleanup; the tx harness is a default, not a mandate.
|
||||
|
||||
2. **Make `TEST_DATABASE_URL` mandatory in CI.** Today these tests are skipped on every machine that doesn't `export TEST_DATABASE_URL=…` — i.e. they don't run on autoatic pipelines because there's no pipeline. Once CI exists (§3.5), it becomes a required env var.
|
||||
|
||||
**Tool:** stdlib `testing` + `sqlx` (already in `go.mod`). **No mocks at the service↔DB boundary.** This is m's hardest line — see global CLAUDE.md memory pattern and `t-paliad-036` (the bug that masked two other bugs would have been caught instantly by a real-DB test).
|
||||
|
||||
**Where to invest first:** Approval (already heavy), Projection (already heavy), Fristenrechner (already heavy), DeadlineService Create/Update/Complete/Delete with `pending_request_id` interplay, AppointmentService same, ProjectService visibility predicate, CalDAV push (the four CalDAV `*.go` files have zero direct test).
|
||||
|
||||
**Coverage target:** Every service method that mutates the DB has at least one happy-path live-DB test. RLS predicate (`visibilityPredicatePositional`) has one test per role (global_admin, member, non-member).
|
||||
|
||||
### Layer 5 — Handler integration (httptest + real DB)
|
||||
|
||||
**What:** Spin a real `services.DBService`, mount the protected mux, drive `httptest.NewRequest` + `ServeHTTP` against it. Auth via a fake session cookie produced by a `testauth.Login(t, userID)` helper that mints the same Supabase JWT shape `auth.UserIDFromContext` expects.
|
||||
|
||||
**Why:** The 53 untested handlers are where the request shape ↔ service interaction lives. Examples that would have caught real bugs:
|
||||
- `t-paliad-036`'s "`/projects/{id}` 404 while `/api/projects/{id}` 200" mismatch — a 5-line handler test would have failed before the migration ran.
|
||||
- mig 020's three-stacked bug — a handler test that POSTs a deadline and asserts a 200 + read-back row would have failed at submit-time, not boot-time.
|
||||
- The audit-log query timezone bug — handler test asserts the JSON contains the expected `event_date`.
|
||||
|
||||
**Tool:** stdlib `net/http/httptest`. **No new framework.** Pattern: handler tests live next to the handler file (`internal/handlers/deadlines_test.go` next to `deadlines.go`).
|
||||
|
||||
**Coverage target:** Every handler that gates a state-changing route — `POST/PATCH/DELETE` flavour. Plus `GET` handlers that compose a non-trivial query (dashboard, agenda, search, audit-log).
|
||||
|
||||
### Layer 6 — End-to-end (Playwright)
|
||||
|
||||
**What:** A small Playwright suite (~10 flows) committed at `e2e/` with a `bun run e2e` entry. Targets a local `./paliad` against a scratch Postgres (the same `TEST_DATABASE_URL`). Each test logs in, drives the UI through one user journey, asserts visible state.
|
||||
|
||||
**Why ~10 not 100:** Per-PR budget caps at ~2 min total (§6 Q1). Playwright tests are the most expensive minute-per-confidence in this stack; they pay for themselves on the *golden path* and nothing else. The deep-coverage layer is L5; E2E is *"is the app still alive end to end?"*.
|
||||
|
||||
**Tool:** `playwright` (npm; bun installs cleanly). No third-party test runner — Playwright ships its own. Tests live in `e2e/*.spec.ts`. **Not bun:test.** Playwright's runner is purpose-built for browser-driving and integrates with their tracing — don't fight it.
|
||||
|
||||
**Cap:** 10 flows. If a new test wants in, an existing one must drop out (or we have a real reason to widen). This is the cheapest discipline available: it forces the suite to remain a smoke pass, not a regression-test dumping ground.
|
||||
|
||||
**Coverage target:** See §4.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tooling — concrete picks per layer
|
||||
|
||||
| Layer | Tool | Already in deps? | Install? |
|
||||
|---|---|---|---|
|
||||
| L0 — migration dry-run | stdlib `testing` + `migrate/v4` | yes | no |
|
||||
| L1 — Go unit | stdlib `testing` | yes | no |
|
||||
| L2 — Frontend unit | `bun:test` | yes (built into bun) | no |
|
||||
| L3 — Frontend DOM | `bun:test` + `happy-dom` | bun yes, happy-dom **new** | `bun add -d happy-dom` (one dep, ~200 KB) |
|
||||
| L4 — Service live-DB | stdlib + sqlx | yes | no |
|
||||
| L5 — Handler integration | stdlib `net/http/httptest` + sqlx | yes | no |
|
||||
| L6 — E2E | `@playwright/test` | **new** | `bun add -d @playwright/test` + `npx playwright install chromium` |
|
||||
|
||||
Net new deps: **2** (happy-dom + playwright). Both are mainstream, both have small surface area, both align with bun's ecosystem.
|
||||
|
||||
Explicit rejects:
|
||||
- ❌ **testify** — current tests read cleanly with stdlib; adding it forces a rename pass nobody wants.
|
||||
- ❌ **vitest** — bun's built-in test runner is faster and the tests are already in `bun:test` shape.
|
||||
- ❌ **dockertest / testcontainers-go** — m's preference is real-DB tests against the existing Postgres; spinning ephemeral Docker Postgres per package run adds latency and surface area for marginal isolation gain. See Q3.
|
||||
- ❌ **sqlmock / gomock for DB** — banned by §0 lesson 1.
|
||||
- ❌ **cypress** — Playwright is the better tool today, and the team's existing skill (`/mai-tester`) already uses it.
|
||||
|
||||
### 3.1 Per-PR run-time budget
|
||||
|
||||
Target (subject to m's call in Q1): **≤ 90 s for the gating tier (L0+L1+L2+L4 subset+L5 happy-path)**, ≤ 4 min for the full suite (add L3+L4 full+L6). The gating tier blocks merge; the full suite blocks deploy.
|
||||
|
||||
Indicative times (estimated, validate when slice 1 lands):
|
||||
|
||||
| Tier | Layers | Est. time | Blocks |
|
||||
|---|---|---|---|
|
||||
| **Gate (every PR)** | L0 + L1 + L2 + L5 happy-path + L4 critical | 60–90 s | merge |
|
||||
| **Full (every merge to main)** | + L4 full + L3 + L6 | 3–4 min | deploy |
|
||||
|
||||
### 3.2 CI — proposal, not commitment
|
||||
|
||||
paliad has no CI today. Two routes:
|
||||
|
||||
- **Gitea Actions** (m's stack already runs `mgit.msbls.de`). Self-hosted; same auth model as the rest of mAi. Adds a `.gitea/workflows/test.yml`. Postgres comes from a service container.
|
||||
- **Stay click-deploy.** No CI. Workers run tests locally; Dokploy auto-deploys on green-main convention.
|
||||
|
||||
Recommendation: **Gitea Actions for the gate tier only** (L0 + L1 + L2), driven by a single short workflow. The L3-L6 expansion can be a follow-up once the gate tier proves stable. Deferred to Q2 for m's call.
|
||||
|
||||
### 3.3 Test DB — live YouPC vs ephemeral
|
||||
|
||||
The `paliad` schema lives on the shared YouPC Postgres (port 11833). Three options:
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|---|---|---|
|
||||
| **Per-developer separate DB on YouPC** (`TEST_DATABASE_URL` per laptop) | Closest to prod; existing pattern. | Cleanup discipline matters; cross-developer contention possible. |
|
||||
| **Ephemeral docker postgres per CI run** | Full isolation; parallel-safe; reset for free. | New infra; ~5 s container startup per CI invocation. |
|
||||
| **Dedicated test DB on a paliad-only Postgres** | Isolated; cheap. | New infra to maintain. |
|
||||
|
||||
Recommendation: **option 1 for developers (no-op change), option 2 for CI** (Gitea Actions postgres service container). Deferred to Q3 for m's call.
|
||||
|
||||
### 3.4 Coverage targets
|
||||
|
||||
Don't gate on percentage. Gate on critical-path coverage (§4). Add `go test -coverprofile=` output to CI for visibility, not as a merge gate. Coverage % gating produces tests-for-tests'-sake; we want the tests that catch the bugs we've shipped.
|
||||
|
||||
---
|
||||
|
||||
## 4. Critical journeys — what MUST be covered
|
||||
|
||||
These are the golden-path flows. Anything not on this list is L1-L5 territory, not L6. The list is intentionally short; if it grows beyond 10, we are doing E2E wrong.
|
||||
|
||||
| # | Flow | Why it's critical | Layer mix |
|
||||
|---|---|---|---|
|
||||
| 1 | **Login → dashboard renders → traffic-light counts match** | Every user does this every day; broken auth = paliad is offline. | L6 (Playwright) + L5 handler (auth.go) |
|
||||
| 2 | **Create project (Client → Litigation → Patent → Case)** | Hierarchy with team inheritance — the data model's spine. | L6 + L5 + L4 (project_service) |
|
||||
| 3 | **Submit deadline → routes to /inbox → approver approves → state flips** | The 4-eye flow (t-paliad-138). Most-mutated paliad surface. | L6 + L5 (deadlines, approvals) + L4 (approval_service) |
|
||||
| 4 | **Fristenrechner: pick proceeding → cascade fires → result shows** | The platform's flagship interactive tool. JS cascade. | L6 + L3 (fristenrechner cascade) + L4 (fristenrechner) |
|
||||
| 5 | **SmartTimeline: anchor a projected row → predecessor-missing-error handled** | Recent Slice-2 work (t-paliad-173 / #31). High-touch surface. | L6 + L3 (shape-timeline) + L4 (projection_service) |
|
||||
| 6 | **CalDAV sync: PUT a Termin → external client sees it, edits there → pull reconciles** | Owned-event semantics + foreign-UID skip rule from Phase F. Untested today. | L4 (caldav_service push/pull) — gated on Q3 (live YouPC vs ephemeral) |
|
||||
| 7 | **Paliadin chat: anon visit hits 404; m's session opens widget; turn renders** | Owner-gated `/paliadin` is the only m-only surface. Quiet failures here are silent. | L6 (smoke) + L5 (paliadin_suggest) + L4 (paliadin / aichat_paliadin) |
|
||||
| 8 | **/admin/rules: filter → edit one rule → lifecycle transition → audit log row** | Rules drive the cascade; bad edits break every user's fristenrechner. | L6 + L5 (admin_rules) + L4 (rule_editor_service) |
|
||||
| 9 | **Onboarding: new user with allowed email → onboarding form → first project membership** | The new-user funnel; gateOnboarded middleware traps. | L6 + L5 (onboarding, invite) |
|
||||
| 10 | **Migration boot smoke: spin paliad against an empty DB → server binds 8080** | Catches every mig-N crash-loop. | L0 (migration dry-run) + L4 boot-smoke variant |
|
||||
|
||||
Picks 1, 3, 4 and 10 are the highest-value-per-cost — they cover the routes most regressions land on (auth, mutation, cascade, boot).
|
||||
|
||||
---
|
||||
|
||||
## 5. Slice plan — tracer-bullet roll-out
|
||||
|
||||
Each slice is a shippable PR with a concrete deliverable, in order of expected outage-prevention payoff. Sized for a single coder shift unless flagged. No slice depends on a later one being merged. Hour estimates intentionally omitted (per global CLAUDE.md).
|
||||
|
||||
### Slice 1 — Migration dry-run harness + boot smoke (highest leverage)
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-1-migrations`
|
||||
|
||||
**Deliverable:**
|
||||
- `internal/db/migrate_test.go` — `TestMigrations_DryRun` (per-mig BEGIN/ROLLBACK), `TestMigrations_EndToEnd` (full apply, then re-apply latest to assert idempotency), `TestMigrations_Down` (apply N→0).
|
||||
- `Makefile` with `make verify-migrations` (the gate target), `make test` (run everything), `make test-go`, `make test-frontend`.
|
||||
- `cmd/server/main_paliadin_backend_test.go` already exists; extend with a `TestMain_BindsHTTPAfterMigrate` that boots the full server against `TEST_DATABASE_URL`, asserts `:8080` is listening, then shuts down. Catches the mig-098-class crash-loop in a single test.
|
||||
- README section: how to set `TEST_DATABASE_URL` locally.
|
||||
|
||||
**Catches:** Every mig-98-class crash-loop; every drop-cascade-with-stale-policy-name regression (t-paliad-036).
|
||||
|
||||
### Slice 2 — Service-layer infill: critical mutators
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-2-services`
|
||||
|
||||
**Deliverable:**
|
||||
- Test files for the three highest-impact untested services:
|
||||
- `internal/services/agenda_service_test.go` (live-DB, dashboard agenda query)
|
||||
- `internal/services/dashboard_service_test.go` (traffic-light counts)
|
||||
- `internal/services/team_service_test.go` (membership + inheritance — RLS-load-bearing)
|
||||
- Tighten existing `approval_service_test.go` + `deadline_service_test.go` coverage of the create/update/complete/delete × pending-request matrix where there are demonstrable gaps.
|
||||
- Add `internal/services/internal/testdb/withtx.go` — the per-test tx harness (optional adoption; existing tests stay).
|
||||
|
||||
**Catches:** RLS regressions, approval interplay regressions, dashboard count drift after schema renames.
|
||||
|
||||
### Slice 3 — Frontend bun:test setup + L2 infill
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-3-frontend-unit`
|
||||
|
||||
**Deliverable:**
|
||||
- `frontend/package.json` `scripts.test = "bun test"`.
|
||||
- New tests under `frontend/src/client/`:
|
||||
- `paliadin-context.test.ts` (route table, entity extraction, selection truncation).
|
||||
- `paliadin-starters.test.ts` (every route ≥1 starter, every starter bilingual).
|
||||
- `filter-bar/index.test.ts` (chip render + active state — pure DOM-less helpers).
|
||||
- i18n key audit: `frontend/scripts/i18n-audit.test.ts` parses every `data-i18n="…"` from `dist/` HTML and every `t("…")` call from `src/`, asserts both `de` and `en` resolve. Runs as part of `bun test`.
|
||||
- `make test-frontend` wires `cd frontend && bun test`.
|
||||
|
||||
**Catches:** i18n drift (untranslated key shipped to user), context-envelope contract drift (paliadin SKILL.md depends on it), starter-registry regressions.
|
||||
|
||||
### Slice 4 — Playwright golden-path smoke
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-4-e2e`
|
||||
|
||||
**Deliverable:**
|
||||
- `e2e/` directory at repo root.
|
||||
- `playwright.config.ts` pointing at `http://localhost:8080` (paliad started by the test, not assumed).
|
||||
- Five Playwright `*.spec.ts` files covering critical journeys 1, 3, 4, 7, 9 from §4.
|
||||
- `make e2e` target that:
|
||||
1. starts paliad against `TEST_DATABASE_URL`,
|
||||
2. waits for `:8080` to be live,
|
||||
3. runs `npx playwright test`,
|
||||
4. tears the server down.
|
||||
- `bun add -d @playwright/test` + `npx playwright install chromium`.
|
||||
|
||||
**Catches:** Auth regressions, deadline-mutation regressions, fristenrechner cascade regressions, owner-gated /paliadin leaks, onboarding-gate misbehaviour.
|
||||
|
||||
### Slice 5 — Handler integration tests for the 5 most-touched routes
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-5-handlers`
|
||||
|
||||
**Deliverable:**
|
||||
- `internal/handlers/auth_test.go` extended with `TestLogin_HappyPath` + `TestLogout_ClearsCookie` (real DB).
|
||||
- `internal/handlers/projects_test.go` — `TestProjectsCreate` (POST 200, row inserted, audit emitted), `TestProjectsGetByID_RespectsVisibility` (404 for non-member).
|
||||
- `internal/handlers/deadlines_test.go` — `TestDeadlinesCreate_TriggersApproval` (verifies pending pill).
|
||||
- `internal/handlers/appointments_test.go` — same shape.
|
||||
- `internal/handlers/paliadin_test.go` — `TestPaliadinPage_404ForNonOwner`, `TestPaliadinPage_200ForOwner`.
|
||||
- Shared `internal/handlers/testauth/testauth.go` — mints a session cookie for `userID` so handler tests don't reinvent auth seeding.
|
||||
|
||||
**Catches:** Handler ↔ service wiring drift, visibility-predicate handler-side bugs (t-paliad-036 bug 2 was exactly this), owner-gate bypass.
|
||||
|
||||
### Slice 6 — Frontend L3 (DOM) cascade tests
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-6-frontend-dom`
|
||||
|
||||
**Deliverable:**
|
||||
- `bun add -d happy-dom`.
|
||||
- DOM-driven tests for the three most-touched cascades:
|
||||
- `client/fristenrechner.test.ts` (cascade activate → row appears → date-set fires fetch).
|
||||
- `client/shape-timeline.test.ts` (lane render, track render, projected-row click).
|
||||
- `client/filter-bar/index.test.ts` (chip click toggles state, URL params update).
|
||||
|
||||
**Catches:** The whole class of "the function exists and is unit-tested but the cascade in the browser doesn't fire it" bugs. This is the layer that catches t-paliad-098 / 099 / 102 / 103.
|
||||
|
||||
### Slice 7 — CI wiring (deferred — Q2 dependent)
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-7-ci` (gated on m's Q2 pick)
|
||||
|
||||
**Deliverable:**
|
||||
- `.gitea/workflows/test.yml` (or stay click-deploy if m picks that).
|
||||
- Gate tier runs on every PR; full suite runs on merge to main.
|
||||
- Postgres service container provides `TEST_DATABASE_URL`.
|
||||
- Slack/Gotify ping on red main.
|
||||
|
||||
**Catches:** Drift between "tests pass on my laptop" and prod reality.
|
||||
|
||||
### Slice 8 — Coverage reporting + dashboard (lowest priority)
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-8-coverage`
|
||||
|
||||
**Deliverable:**
|
||||
- `go test -coverprofile=` aggregated into a single `coverage.html`.
|
||||
- Bun's coverage output similarly.
|
||||
- A `docs/coverage.md` index updated by CI.
|
||||
- **Not a merge gate.** Visibility only.
|
||||
|
||||
**Catches:** Slow drift; nice-to-have once the floor is in.
|
||||
|
||||
### Slice order rationale
|
||||
|
||||
1, 4, 5 are the highest outage-prevention per LoC: migration dry-run kills crash-loops, E2E kills regressions, handler tests kill wiring drift. 2, 3, 6 widen the floor; 7-8 are infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions for m
|
||||
|
||||
These need m's call before any coder shift starts (or before specific slices start, where noted).
|
||||
|
||||
### Q1 — Per-PR test-run budget
|
||||
|
||||
How long is acceptable to wait on the gate tier before merge?
|
||||
|
||||
- 30 s — only L0 + L1 (no L2+ on the gate).
|
||||
- **60–90 s (recommended)** — L0 + L1 + L2 + L5 happy-path + L4 critical.
|
||||
- 2 min — add L3 + L4 full.
|
||||
- 4+ min — add L6 (E2E on gate).
|
||||
|
||||
The pick determines whether E2E gates merge or only deploy.
|
||||
|
||||
### Q2 — CI infrastructure
|
||||
|
||||
- **Gitea Actions** (self-hosted, gate tier only, recommended) — minimal new infra; aligns with m's existing stack.
|
||||
- **Stay click-deploy** — workers run tests locally; merge discipline enforced by convention. Today's reality; we keep it.
|
||||
- **Both:** start with click-deploy, add Gitea Actions in Slice 7 once gate tier proves stable.
|
||||
|
||||
### Q3 — Live-DB vs ephemeral docker Postgres for tests
|
||||
|
||||
- **Per-developer YouPC DB (current pattern)** — closest to prod; existing tests work unchanged.
|
||||
- **Ephemeral docker postgres in CI, YouPC for devs (recommended hybrid)** — keeps local-dev simple, gives CI deterministic isolation.
|
||||
- **YouPC everywhere** — simplest, but parallel CI runs would contend.
|
||||
|
||||
### Q4 — Coverage targets — % or critical-path?
|
||||
|
||||
- **Critical-path only (recommended)** — §4's 10 flows + every state-mutating service method has a test. No % gate.
|
||||
- **% gate** — set a floor (e.g. 60 % lines, 50 % branches) and refuse merges below it.
|
||||
- **Both** — critical-path is mandatory, % is informational.
|
||||
|
||||
m's prior preference (memory pattern: "tests that catch real bugs > coverage theatre") points at critical-path-only. Confirming.
|
||||
|
||||
### Q5 — Which slices land before paliad is "production-grade"?
|
||||
|
||||
paliad is already live at `paliad.de` and being used by HLC colleagues. "Production-grade" here means "next time someone ships, we don't go down."
|
||||
|
||||
Picks:
|
||||
- **Slices 1 + 4 + 5 are the production-grade floor (recommended).** Migration dry-run + golden-path E2E + handler integration tests cover the failure modes that hit prod since the rebrand.
|
||||
- Add Slice 2 + 3 + 6 as widening passes, on their own cadence.
|
||||
- Slice 7-8 are nice-to-haves.
|
||||
|
||||
Confirming the floor pick — and whether m wants all three to land before any new feature work, or whether they roll out alongside.
|
||||
|
||||
### Q6 — Who owns each slice?
|
||||
|
||||
Recommendation: rotate coder slots so the same person isn't on every slice. Suggested assignment (head can override):
|
||||
|
||||
| Slice | Profile fit |
|
||||
|---|---|
|
||||
| 1 — migrations | Backend-heavy coder (knuth, gauss, cronus). |
|
||||
| 2 — service infill | Backend-heavy coder; whoever owns approval/projection. |
|
||||
| 3 — frontend unit | Frontend-heavy coder. |
|
||||
| 4 — Playwright E2E | Cross-stack coder; ideally one familiar with `/mai-tester`. |
|
||||
| 5 — handler integration | Backend coder. |
|
||||
| 6 — frontend DOM | Frontend coder (same person as 3 makes sense). |
|
||||
|
||||
Inventor does **not** decide assignments; head + m do.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope (explicit)
|
||||
|
||||
- **No rewrite of any existing test.** The 323 existing test functions stay. New tests use the new patterns; old tests are migrated only when their files are touched for unrelated reasons.
|
||||
- **No third-party framework where stdlib + bun:test suffice** (testify, vitest, etc. — see §3).
|
||||
- **No mocks at the service↔DB boundary.** This is the lock-in. Mocks lie; the live-DB tests we already have are paliad's most useful safety net.
|
||||
- **No new feature work in this strategy.** The doc proposes infra; feature scope is unchanged.
|
||||
- **No retirement of the `tests/smoke-*.md` human-written reports.** Those are great for one-shot regression hunts; they coexist with the automated suite.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation notes for the eventual coder
|
||||
|
||||
(For whichever coder picks up a slice. Not exhaustive.)
|
||||
|
||||
- **Test-name collisions in Go's flat package namespace bite when a service grows N implementations.** Memory note from `t-paliad-194` already records this. Prefix tests with the service name (e.g. `TestAichatPaliadin_RunTurn_…` not `TestRunTurn_…`).
|
||||
- **`httptest.NewRequest` does not URL-encode** — use `url.QueryEscape` for any `?q=…` argument. Memory note from `t-paliad-026`.
|
||||
- **sqlx v1.4.0 `Named` parser strips one colon from `::uuid[]`** — known pitfall, repro lives at `internal/services/project_service.go`. Use `CAST(... AS uuid[])` in new query strings.
|
||||
- **Live-DB cleanup must DELETE FKs first.** Order matters (auth.users last). Look at `audit_service_test.go` for the chain pattern.
|
||||
- **`paliad.paliad_schema_migrations` tracker collision** is documented but unresolved. Slice 1 should add a `make reset-test-db` target that drops both `public.paliad_schema_migrations` *and* `paliad.paliad_schema_migrations` to keep developers unblocked.
|
||||
- **`bun:test` matchers are Jest-compatible** — `expect().toEqual()`, `expect().toHaveBeenCalled()`, etc. No deps needed.
|
||||
- **happy-dom does not implement** every DOM method (notably some `<dialog>` semantics). If a cascade test fails on something missing, jsdom is the escape hatch.
|
||||
|
||||
---
|
||||
|
||||
## 9. Decision summary — pick list for m
|
||||
|
||||
| # | Question | Inventor recommends |
|
||||
|---|---|---|
|
||||
| Q1 | Per-PR budget | 60–90 s gate, 3–4 min full |
|
||||
| Q2 | CI infra | Gitea Actions, gate tier only |
|
||||
| Q3 | Test DB | YouPC for devs, ephemeral docker for CI |
|
||||
| Q4 | Coverage target | Critical-path only, no % gate |
|
||||
| Q5 | Production-grade floor | Slices 1 + 4 + 5 before new feature work |
|
||||
| Q6 | Slice ownership | Rotate per profile; head decides |
|
||||
|
||||
If m's calls match inventor's, the implementer's brief writes itself: Slice 1 first, then 4 + 5 in parallel, then 2/3/6 as widening passes.
|
||||
|
||||
---
|
||||
|
||||
**Status:** DESIGN READY FOR REVIEW. Awaiting m go/no-go on §5 slice plan + §6 open questions before any coder shift starts.
|
||||
|
||||
---
|
||||
|
||||
## 10. m's decisions (2026-05-19, locked)
|
||||
|
||||
Walked through §6 with m via the AskUserQuestion interview (per head's 2026-05-19 workflow rule: inventor questions are resolved before parking, not after). Six picks locked, all matching inventor's recommendation.
|
||||
|
||||
| # | Question | m's answer | Effect on plan |
|
||||
|---|---|---|---|
|
||||
| Q1 | Per-PR test-run budget | **Inventor's call** (m deferred). Pick: **60–90 s gate, 3–4 min full.** | Gate tier = L0 + L1 + L2 + L5 happy-path + L4 critical. L6 E2E gates deploy, not merge. |
|
||||
| Q2 | CI infrastructure | **Gitea Actions, gate tier only.** | Slice 7 adds `.gitea/workflows/test.yml` running the gate tier; full suite stays on merge-to-main. |
|
||||
| Q3 | Test DB topology | **YouPC for devs + ephemeral docker for CI.** | Local dev unchanged. Slice 7 wires Postgres service container in Gitea Actions. |
|
||||
| Q4 | Coverage target | **Critical-path only, no % gate.** | §4's 10 flows + every state-mutating service method gets a test. Coverage % output is informational in Slice 8, never a merge gate. |
|
||||
| Q5 | Production-grade floor | **Slices 1 + 4 + 5 before new feature work.** | These three land before any new paliad feature gets a coder shift. Slices 2, 3, 6 widen the floor on their own cadence. Slices 7-8 are nice-to-haves. |
|
||||
| Q6 | Slice ownership | **Head decides + rotate per profile.** | Backend slices (1, 2, 5) → backend-heavy coder. Frontend slices (3, 6) → frontend-heavy coder. E2E (4) → cross-stack. Head picks at dispatch time. |
|
||||
|
||||
**Implementer brief (post-m-decisions):**
|
||||
|
||||
1. **Slice 1 starts first** — migration dry-run harness + `make verify-migrations` + boot-smoke variant of `cmd/server/main_paliadin_backend_test.go`. Backend-heavy coder.
|
||||
2. **Slice 4 + Slice 5 in parallel** once Slice 1 is merged — Playwright golden-path (cross-stack coder, 5 specs) and handler integration (backend coder, auth/projects/deadlines/appointments/paliadin).
|
||||
3. Slice 7 (Gitea Actions wiring) follows once Slice 1 gate tier is proven locally.
|
||||
4. Slices 2, 3, 6 enter rotation alongside feature work — not blocking.
|
||||
5. Slice 8 (coverage reporting) lowest priority.
|
||||
|
||||
**Status:** DESIGN APPROVED — awaiting head's dispatch of Slice 1 coder shift.
|
||||
172
docs/design-proceeding-code-taxonomy-2026-05-18.md
Normal file
172
docs/design-proceeding-code-taxonomy-2026-05-18.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Proceeding-code taxonomy (t-paliad-204 ratified 2026-05-18)
|
||||
|
||||
> Source of truth for `paliad.proceeding_types.code`. Every active row's
|
||||
> `code` MUST conform to the convention below. This document anchors
|
||||
> migration 096 (`internal/db/migrations/096_proceeding_code_rename.up.sql`)
|
||||
> and the post-migration determinator + fristenrechner mapping in
|
||||
> `internal/services/proceeding_mapping.go`.
|
||||
|
||||
## 0. Why we renamed
|
||||
|
||||
The historical `code` strings (`UPC_INF`, `DE_INF`, `EPA_OPP`, …) were
|
||||
UPPER_SNAKE jurisdiction-glued-to-acronym slugs. They were structurally
|
||||
opaque and the taxonomy grew unevenly as more proceedings entered the
|
||||
fristenrechner — `UPC_APP` covers all UPC appeals, `DE_INF_OLG` /
|
||||
`DE_INF_BGH` carry the instance hint inline, `EP_GRANT` is the only EPA
|
||||
row with no `EPA_` prefix at all. The mapping in
|
||||
`internal/services/proceeding_mapping.go` had to special-case appeal
|
||||
ambiguities (no instance hint on UPC_APP, none on the DE side either).
|
||||
After mig 095 landed the t-paliad-205 fristen gap-fill, m and paliadin
|
||||
ratified a uniform convention for the corpus, captured here.
|
||||
|
||||
## 0.1 Convention
|
||||
|
||||
Active proceeding codes are lowercase, dot-separated, three positions:
|
||||
|
||||
<jurisdiction>.<X>.<Y>
|
||||
|
||||
* **`<jurisdiction>`** — one of `upc`, `de`, `epa`, `dpma`.
|
||||
* **`<X>` / `<Y>`** — contextual; for first-instance proceedings they are
|
||||
`<substantive-type>.<forum>` (e.g. `de.inf.lg` for Verletzungsklage am
|
||||
Landgericht). For appeals they are `<appeal-type>.<scope>` (e.g.
|
||||
`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`).
|
||||
* The CHECK constraint installed by mig 096 enforces
|
||||
`code ~ '^[a-z]+\.[a-z]+\.[a-z]+$'` on every active row, with a
|
||||
carve-out for the legacy `_archived_litigation` bucket
|
||||
(`code ~ '^_archived_'`).
|
||||
|
||||
The convention is forward-looking: any new fristenrechner row added
|
||||
after mig 096 MUST conform — no further UPPER_SNAKE codes.
|
||||
|
||||
## 0.2 Ratified taxonomy
|
||||
|
||||
### UPC
|
||||
|
||||
| New code | Old code | id | Notes |
|
||||
|--------------------|------------------|----|------------------------------------------------------------------------|
|
||||
| `upc.inf.cfi` | `UPC_INF` | 8 | Verletzungsverfahren, CFI |
|
||||
| `upc.rev.cfi` | `UPC_REV` | 9 | Nichtigkeitsverfahren, CFI |
|
||||
| `upc.ccr.cfi` | _new_ | _new_ | Widerklage auf Nichtigkeit — illustrative peer of `upc.inf.cfi`. Rules live on `upc.inf.cfi` with `with_ccr=true`. See §1 sub-decision S1. |
|
||||
| `upc.pi.cfi` | `UPC_PI` | 10 | Einstweilige Maßnahmen |
|
||||
| `upc.dmgs.cfi` | `UPC_DAMAGES` | 17 | Schadensbemessung |
|
||||
| `upc.disc.cfi` | `UPC_DISCOVERY` | 18 | Bucheinsicht |
|
||||
| `upc.apl.merits` | `UPC_APP` | 11 | Hauptberufung — covers inf + rev + ccr + damages-merits appeals |
|
||||
| `upc.apl.order` | `UPC_APP_ORDERS` | 20 | 15-Tage-Beschwerde gegen Anordnungen (R.220 (1)(c)) |
|
||||
| `upc.apl.cost` | `UPC_COST_APPEAL`| 19 | Kostenbeschwerde |
|
||||
|
||||
### DE
|
||||
|
||||
| New code | Old code | id | Notes |
|
||||
|---------------------|------------------------|----|-------------------------------------------------------------|
|
||||
| `de.inf.lg` | `DE_INF` | 12 | Verletzungsklage am Landgericht |
|
||||
| `de.inf.olg` | `DE_INF_OLG` | 25 | Berufung am OLG |
|
||||
| `de.inf.bgh` | `DE_INF_BGH` | 26 | Revision + NZB merged — `with_nzb` flag on NZB-detour rules |
|
||||
| `de.null.bpatg` | `DE_NULL` | 13 | Nichtigkeitsverfahren am BPatG |
|
||||
| `de.null.bgh` | `DE_NULL_BGH` | 27 | Nichtigkeitsberufung am BGH |
|
||||
|
||||
### EPA
|
||||
|
||||
| New code | Old code | id | Notes |
|
||||
|---------------------|--------------|----|------------------------------------------------|
|
||||
| `epa.grant.exa` | `EP_GRANT` | 16 | EP-Erteilungsverfahren |
|
||||
| `epa.opp.opd` | `EPA_OPP` | 14 | Einspruchsverfahren |
|
||||
| `epa.opp.boa` | `EPA_APP` | 15 | Einspruchsbeschwerde (Board of Appeal) |
|
||||
|
||||
### DPMA
|
||||
|
||||
| New code | Old code | id | Notes |
|
||||
|-----------------------|-------------------------|----|----------------------------------------------------------------|
|
||||
| `dpma.opp.dpma` | `DPMA_OPP` | 28 | Einspruch beim DPMA |
|
||||
| `dpma.appeal.bpatg` | `DPMA_BPATG_BESCHWERDE` | 29 | Beschwerde am BPatG (generic — source differentiated at rule level) |
|
||||
| `dpma.appeal.bgh` | `DPMA_BGH_RB` | 30 | Rechtsbeschwerde am BGH (generic — source differentiated at rule level) |
|
||||
|
||||
### Archived
|
||||
|
||||
| Code | id | Notes |
|
||||
|-------------------------|----|----------------------------------------|
|
||||
| `_archived_litigation` | 32 | Unchanged — Pipeline-A retired corpus |
|
||||
|
||||
IDs are stable. Only the `code` STRING changes. The FKs
|
||||
`deadline_rules.proceeding_type_id`, `projects.proceeding_type_id`, and
|
||||
`deadline_rules.spawn_proceeding_type_id` reference IDs, so the existing
|
||||
rule corpus and spawn wiring (incl. mig 095's `spawn_proceeding_type_id=11`)
|
||||
continue to work unchanged.
|
||||
|
||||
## 0.3 Sub-decisions (m's calls, 2026-05-18)
|
||||
|
||||
### S1 — `upc.ccr.cfi` visibility
|
||||
|
||||
`is_active=true`, visible in the determinator + dropdowns. **No rules
|
||||
attached.** When the determinator surfaces it, the UI shows the hint:
|
||||
|
||||
> "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
|
||||
> weiter."
|
||||
|
||||
Routing logic lands in `internal/services/proceeding_mapping.go` — when
|
||||
the cascade resolves to `upc.ccr.cfi`, the mapping returns the
|
||||
`upc.inf.cfi` id (=8) with `with_ccr=true` as a default flag. The peer
|
||||
exists for taxonomic completeness so users searching for
|
||||
"Widerklage" find an entry; it is not a separate rule namespace.
|
||||
|
||||
### S2 — Abbreviations
|
||||
|
||||
`dmgs` for damages, `disc` for discovery. m's call: short form keeps the
|
||||
codes terse and the dot-separated shape readable.
|
||||
|
||||
### S3 — Damages appeal
|
||||
|
||||
**NO separate code.** `upc.apl.merits` covers damages appeals — the
|
||||
spawn rules from `upc.dmgs.cfi` (none seeded today) would carry their
|
||||
own `spawn_label`. Avoids a code like `upc.apl.dmgs` whose rules would
|
||||
be empty for the foreseeable future.
|
||||
|
||||
### S4 — NZB at BGH
|
||||
|
||||
Single bucket `de.inf.bgh`. Rules diverging in the NZB-detour-path
|
||||
(Nichtzulassungsbeschwerde when the OLG didn't grant Revision) use a
|
||||
`with_nzb` flag instead of a separate proceeding type. Keeps the dropdown
|
||||
list shorter and matches how m practitioners think about the BGH
|
||||
instance — same destination, two ways to arrive.
|
||||
|
||||
### S5 — DPMA appeals
|
||||
|
||||
Generic `dpma.appeal.bpatg` / `dpma.appeal.bgh` — source-of-decision
|
||||
differentiation (was it a DPMA decision being appealed? a BPatG
|
||||
decision being further appealed to BGH?) lives at the rule level, not
|
||||
the proceeding-type level. Keeps the code namespace flat.
|
||||
|
||||
## 0.4 Spawn-FK invariant
|
||||
|
||||
After mig 096, the spawn FK invariant from mig 095 still holds:
|
||||
|
||||
deadline_rules.spawn_proceeding_type_id = 11
|
||||
↔ paliad.proceeding_types[id=11].code = 'upc.apl.merits'
|
||||
|
||||
Spawn rules from `upc.inf.cfi` / `upc.rev.cfi` chain to the appeal-merits
|
||||
proceeding without code-string awareness. Same for any future spawn FK.
|
||||
|
||||
## 0.5 Not in scope
|
||||
|
||||
* `paliad.event_categories.slug` segments (`upc-inf`, `de-bgh-null`, …)
|
||||
are NOT renamed. They are stable identifiers in a separate taxonomy and
|
||||
their kebab form is presentation-layer (it appears in URL fragments).
|
||||
Mig 096 only updates the `proceeding_type_code` text column on
|
||||
`paliad.event_category_concepts` rows so the soft join through
|
||||
`event_category_concepts → proceeding_types.code` keeps resolving.
|
||||
* Fee-table keys (`EPA_OPPOSITION`, `UPC_APPEAL`, …) in
|
||||
`internal/calc/fees.go` are NOT proceeding codes — they are fee-table
|
||||
bucket keys with their own naming. Untouched.
|
||||
* Forum bucket slugs (`upc_cfi`, `de_lg`, …) in
|
||||
`ForumToProceedingCodes` are presentation buckets, not codes. The
|
||||
values inside (`UPC_INF`, …) are the codes being renamed.
|
||||
|
||||
## 0.6 References
|
||||
|
||||
* `internal/db/migrations/096_proceeding_code_rename.up.sql` — the
|
||||
migration that lands this rename.
|
||||
* `internal/services/proceeding_mapping.go` — post-mig 096 mapping with
|
||||
the ccr-routing helper (S1).
|
||||
* `internal/services/proceeding_codes_shape_test.go` — Go test asserting
|
||||
every active fristenrechner-category code matches the new shape regex.
|
||||
* mig 095 (`internal/db/migrations/095_fristen_gap_fill.up.sql`) — the
|
||||
immediate predecessor; spawn_proceeding_type_id=11 carries through.
|
||||
784
docs/design-submission-generator-2026-05-19.md
Normal file
784
docs/design-submission-generator-2026-05-19.md
Normal file
@@ -0,0 +1,784 @@
|
||||
# Design — Submission generator (t-paliad-215)
|
||||
|
||||
**Author:** copernicus (inventor)
|
||||
**Date:** 2026-05-19
|
||||
**Issue:** m/paliad (task t-paliad-215, no Gitea issue filed yet)
|
||||
**Branch:** `mai/copernicus/inventor-submission`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Each row in `paliad.deadline_rules` represents a SUBMISSION — a filing,
|
||||
hearing, or decision inside a proceeding (`submission_code` shape
|
||||
`de.inf.lg.erwidg`, `upc.inf.cfi.soc`, …). The submission generator
|
||||
takes a project + a submission_code, pulls a `.docx` template from
|
||||
Gitea, merges in project variables (party names, court, case number,
|
||||
patent number, our_side, deadline date, legal_source citation, firm
|
||||
header), and streams the result to the browser as a download.
|
||||
|
||||
- **Scope (locked by m):** template-render to `.docx`. No LLM in v1.
|
||||
- **Template registry (locked):** Gitea — same proxy pattern as the
|
||||
existing HL Patents Style `.dotm` in `internal/handlers/files.go`.
|
||||
- **Output (locked):** direct download, NO server-side binary
|
||||
persistence. One audit row per generation; the bytes themselves are
|
||||
regenerable from inputs on demand.
|
||||
- **Lookup (locked):** fallback chain — firm-specific override →
|
||||
base for the exact `submission_code` → generic for the proceeding
|
||||
family → ultra-generic skeleton.
|
||||
- **Slice 1 (locked):** one template, end-to-end, on one project.
|
||||
Pick `de.inf.lg.erwidg` (Klageerwiderung) as the proof template.
|
||||
- **AI-drafted body:** explicitly OUT of scope for this task. Lives
|
||||
in §11 as a follow-up sketch only.
|
||||
|
||||
This design is read-only. No code, no migrations, no schema
|
||||
additions. Implementation gate is m's go/no-go on this doc.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-19)
|
||||
|
||||
Anchored against the running paliad codebase + youpc Supabase, not
|
||||
against CLAUDE.md or memory. Where a claim load-bears the design, it
|
||||
was checked against the live system.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| Migration tracker at **102** (next is 103) | `ls internal/db/migrations/` — `102_system_audit_log` is the latest applied. |
|
||||
| `paliad.documents` table exists, is empty, no code writes to it yet | `SELECT COUNT(*) FROM paliad.documents` → 0 rows. Columns: `id, title, doc_type, file_path NULLABLE, file_size, mime_type, ai_extracted jsonb, uploaded_by, created_at, updated_at, project_id NOT NULL`. `grep` shows only `export_service.go` (audit-export only) and a comment in `render_spec.go`. No `document_service.go`, no `/api/documents` handler. |
|
||||
| `paliad.deadline_rules` carries the submission corpus | 254 total rows, 158 unique `submission_code`s, 214 `published`. Per-row fields used by the generator: `name`, `name_en`, `submission_code`, `primary_party` (claimant/defendant/court/both), `event_type` (filing/hearing/decision), `legal_source` (e.g. `DE.ZPO.276.1`, `UPC.RoP.23.1`), `is_bilateral`. |
|
||||
| Slice 1 target row exists in published state | `SELECT … WHERE submission_code='de.inf.lg.erwidg'` → `{name:"Klageerwiderung", name_en:"Statement of Defence", primary_party:"defendant", legal_source:"DE.ZPO.276.1"}`. |
|
||||
| Project rows carry all variables we need to merge | `paliad.projects` has `case_number, court, patent_number, filing_date, grant_date, our_side, instance_level, proceeding_type_id, title, reference, client_number, matter_number`. |
|
||||
| Party rows carry party variables | `paliad.parties` has `name, role, representative, contact_info jsonb` and is project-scoped via `project_id`. |
|
||||
| The HL Patents Style proxy pattern is reusable | `internal/handlers/files.go`: `fileRegistry` map → Gitea raw URL + SHA-based cache + 5-min refresh check + binary download response with `Content-Disposition`. Cache is in-process (`sync.Mutex` over a `map[string]*cacheEntry`). Single web replica today (`docker-compose.yml`), so in-process cache is fine. |
|
||||
| Email templates already use `{{.VarName}}` placeholders + a "variable contract" sidebar pattern | `internal/services/email_template_variables.go` — `EmailTemplateVariable{Name, Type, Description, SampleDE, SampleEN}` rendered in `/admin/email-templates`. Submission generator can copy this contract pattern. |
|
||||
| Audit infrastructure landed in mig 102 | `paliad.system_audit_log(id, event_type, actor_id, actor_email, scope, scope_root, metadata jsonb, created_at, updated_at)` — submission_generated events slot straight in. |
|
||||
| Branding source is `internal/branding.Name` | Default `"HLC"`, overridable via `FIRM_NAME`. Inlined into client bundles by `frontend/build.ts`. Submission templates honour this via the `{{firm.name}}` placeholder. |
|
||||
| `paliad.can_see_project(project_id)` is the canonical visibility predicate | mig 055; `internal/services/visibility.go` mirrors it. Generator gates on this; no new auth surface. |
|
||||
| Paliadin runs on the aichat backend (mRiver) with persona system | `internal/services/aichat_paliadin.go` + `personas.yaml` in `m/mAi/internal/aichat/persona/`. Owner-gated to `PaliadinOwnerEmail = matthias.siebels@hoganlovells.com`. A future AI-drafted body would be a new persona, not a new Go service. |
|
||||
|
||||
**Doc-vs-live conflicts found:** none material for this design.
|
||||
`docs/project-status.md` still lists "Phase H AI Frist-Extraktion
|
||||
deferred" — this design does NOT revive Phase H (different surface;
|
||||
this is template merge, not document understanding).
|
||||
|
||||
---
|
||||
|
||||
## §2 m's decisions (2026-05-19)
|
||||
|
||||
Locked via AskUserQuestion before drafting the rest of the design.
|
||||
|
||||
| # | Question | m's pick | Inventor recommended? |
|
||||
|---|---|---|---|
|
||||
| Q1 | Generator scope (template / AI-draft / brief / other) | **Template-render to `.docx`** | ✅ yes |
|
||||
| Q2 | Template registry (Gitea / paliad DB / hybrid) | **Gitea** | ✅ yes |
|
||||
| Q3 | Output flow (download-only / persist binary / attach to Frist) | **Direct download, no server-side binary** | ✅ yes |
|
||||
| Q4 | Mapping (fallback chain / 1:1 / 1:N user picks) | **Fallback chain** | ✅ yes |
|
||||
| Q5 | Slice 1 scope (1 template / 3–5 templates / full corpus / skeleton-only) | **One template, end-to-end on one project** (`de.inf.lg.erwidg` Klageerwiderung) | ✅ yes |
|
||||
|
||||
Inventor-defaulted (not asked because there's a clear right answer or
|
||||
because the question is implementation-level, not architecture-level):
|
||||
|
||||
| # | Topic | Default | Reasoning |
|
||||
|---|---|---|---|
|
||||
| D1 | Variable engine | `{{path.dot.notation}}` placeholders in the .docx body, replaced via a Go library that handles run-fragmentation | Matches the existing email-template `{{.Var}}` shape lawyers already see in `/admin/email-templates`. See §6. |
|
||||
| D2 | Authorization | Project-team visibility only (`paliad.can_see_project`) + audit row | Matches every other write surface in paliad. No profession floor (generation is read-only on source data and produces a draft, not a binding action). |
|
||||
| D3 | Naming convention | `{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx`, slashes → underscores, FIRM_NAME-aware | Mirrors how lawyers name files manually. See §7. |
|
||||
| D4 | Missing-variable behaviour | Render `[KEIN WERT: {field}]` / `[NO VALUE: {field}]` marker inline | Lets the lawyer see the gap in Word, fix in paliad, regenerate. Better than 400ing. |
|
||||
| D5 | Editor surface | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo) | A paliad uploader UI is Phase 2 affordance if Gitea round-trip is painful. |
|
||||
| D6 | AI-drafted body | OUT of scope for this task | §11 sketches the natural follow-up shape (new aichat persona) but does not commit to it. |
|
||||
|
||||
---
|
||||
|
||||
## §3 Architecture overview
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Project detail page │
|
||||
│ ├─ "Submissions" panel (or button row) │
|
||||
│ │ [Generate Klageerwiderung] [Generate Klageerhebung] [...] │
|
||||
│ │ Each button enabled iff a template exists for that │
|
||||
│ │ submission_code AND user passes paliad.can_see_project. │
|
||||
│ └─ Click → POST /api/projects/{id}/submissions/{code}/generate │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ handlers/submissions.go (NEW) │
|
||||
│ 1. Auth: UserIDFromContext + can_see_project gate │
|
||||
│ 2. Load deadline_rule by submission_code │
|
||||
│ 3. Resolve template via fallback chain (TemplateRegistry) │
|
||||
│ 4. Build variable bag (services/submission_vars.go) │
|
||||
│ 5. Render via SubmissionRenderer (services/submission_render.go) │
|
||||
│ 6. Write paliad.documents audit row (NO file_path) │
|
||||
│ 7. Write paliad.system_audit_log entry (event_type= │
|
||||
│ 'submission.generated') │
|
||||
│ 8. Stream .docx bytes with Content-Disposition: attachment │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│ (template fetch)
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ TemplateRegistry (services/submission_templates.go) — NEW │
|
||||
│ • In-process cache (same shape as handlers/files.go cacheEntry) │
|
||||
│ • Lookup path: │
|
||||
│ (1) templates/{FIRM_NAME}/{submission_code}.docx │
|
||||
│ (2) templates/_base/{submission_code}.docx │
|
||||
│ (3) templates/_base/{proceeding_family}.docx (e.g. upc.inf.cfi) │
|
||||
│ (4) templates/_base/_skeleton.docx │
|
||||
│ • Fetched from m/mWorkRepo via Gitea raw URL │
|
||||
│ • 5-min SHA refresh check (identical pattern to files.go) │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Gitea: m/mWorkRepo
|
||||
templates/HLC/de.inf.lg.erwidg.docx
|
||||
templates/_base/de.inf.lg.erwidg.docx
|
||||
templates/_base/de.inf.lg.docx
|
||||
templates/_base/_skeleton.docx
|
||||
```
|
||||
|
||||
**No new tables.** `paliad.documents` already exists; we write audit
|
||||
rows there but leave `file_path` NULL. The fallback chain uses
|
||||
filesystem-style paths inside the existing Gitea repo; no
|
||||
`submission_templates` table needed for Slice 1.
|
||||
|
||||
---
|
||||
|
||||
## §4 Slice 1 — what ships first
|
||||
|
||||
Locked by Q5: **one template, end-to-end, on one project.**
|
||||
|
||||
### 4.1 Target submission
|
||||
|
||||
**`de.inf.lg.erwidg`** — Klageerwiderung (DE Verletzungs-LG).
|
||||
Reasoning:
|
||||
|
||||
- High-frequency submission in patent practice; lawyers draft these
|
||||
often enough that the tool earns its keep on day 1.
|
||||
- `primary_party='defendant'` — exercises the our_side variable.
|
||||
- `legal_source='DE.ZPO.276.1'` — exercises citation injection.
|
||||
- Pure-DE (no UPC complexity); easier first template for HLC's
|
||||
Munich/Düsseldorf practice to author and review.
|
||||
- Klageerhebung (`de.inf.lg.klage`) is an obvious alternative; either
|
||||
works. m can flip the target in his decision review if Klageerhebung
|
||||
is the better proof case.
|
||||
|
||||
### 4.2 Surfaces in Slice 1
|
||||
|
||||
- **Project detail page** — new "Submissions" panel listing every
|
||||
submission_code from the project's `proceeding_type` (via existing
|
||||
`DeadlineRuleService`) with a `[Generieren]` button per row. Button
|
||||
is enabled iff a template resolves AND `event_type='filing'` (no
|
||||
`[Generieren]` on hearings/decisions — those don't have submissions).
|
||||
- **Project detail API** — `GET /api/projects/{id}/submissions` returns
|
||||
the list of (submission_code, name, has_template) so the frontend
|
||||
can render enabled/disabled state.
|
||||
- **Generate endpoint** — `POST /api/projects/{id}/submissions/{code}/generate`
|
||||
returns `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
|
||||
with `Content-Disposition: attachment; filename="..."`.
|
||||
|
||||
Slice 1 does NOT add:
|
||||
|
||||
- A `/admin/submission-templates` editor (Gitea is the editor).
|
||||
- A Frist-detail "Generate" button (project-detail only in Slice 1;
|
||||
Frist-level surface is a Slice 2 affordance).
|
||||
- A "Submissions" tab as a dedicated page (project-detail panel only).
|
||||
- Per-firm overrides beyond `templates/HLC/...` (the fallback chain is
|
||||
WIRED but the only override directory exercised in Slice 1 is HLC).
|
||||
- The variable-contract sidebar UI (mirrors email-template editor) —
|
||||
the contract is documented in §6 as code constants, surfaced as a
|
||||
Slice 2 admin affordance.
|
||||
|
||||
### 4.3 Slice 1 LoC estimate (informational, no time estimate)
|
||||
|
||||
| File | Approx |
|
||||
|---|---|
|
||||
| `internal/handlers/submissions.go` (NEW) | 180 |
|
||||
| `internal/services/submission_templates.go` (NEW — registry + Gitea proxy, reuses files.go cache idea) | 200 |
|
||||
| `internal/services/submission_vars.go` (NEW — variable bag builder) | 220 |
|
||||
| `internal/services/submission_render.go` (NEW — docx merge engine wrapper) | 120 |
|
||||
| `internal/services/submission_render_test.go` (placeholder coverage + missing-var marker) | 180 |
|
||||
| `frontend/src/components/SubmissionsPanel.tsx` (NEW) | 80 |
|
||||
| `frontend/src/client/submissions.ts` (NEW — fetch + download) | 60 |
|
||||
| Wiring in `cmd/server/main.go` + `internal/handlers/handlers.go` | 30 |
|
||||
| i18n keys (`submissions.*`) DE+EN | 20 |
|
||||
| **Total** | **~1090 LoC** |
|
||||
|
||||
Plus: ONE `.docx` template authored by HLC at
|
||||
`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`, lawyer-reviewed
|
||||
before Slice 1 closes.
|
||||
|
||||
---
|
||||
|
||||
## §5 Template registry (Gitea-backed)
|
||||
|
||||
### 5.1 Gitea layout
|
||||
|
||||
```
|
||||
m/mWorkRepo (existing repo)
|
||||
└── templates/
|
||||
├── HLC/ # FIRM_NAME-keyed override dir
|
||||
│ └── de.inf.lg.erwidg.docx # Slice 1 ships THIS file
|
||||
├── _base/ # Cross-firm baseline
|
||||
│ ├── de.inf.lg.erwidg.docx # (Phase 2+)
|
||||
│ ├── de.inf.lg.docx # proceeding-family fallback
|
||||
│ ├── upc.inf.cfi.docx # (Phase 2+)
|
||||
│ └── _skeleton.docx # ultra-generic fallback
|
||||
└── README.md # placeholder reference for authors
|
||||
```
|
||||
|
||||
Naming convention is the submission_code with a `.docx` suffix.
|
||||
Proceeding-family fallback is the submission_code's first two
|
||||
dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`).
|
||||
|
||||
### 5.2 Lookup algorithm
|
||||
|
||||
```go
|
||||
// services/submission_templates.go
|
||||
func (r *TemplateRegistry) Resolve(ctx context.Context, code string) (Template, error) {
|
||||
firm := branding.Name // "HLC", or whatever FIRM_NAME is
|
||||
family := familyOf(code) // "de.inf.lg" from "de.inf.lg.erwidg"
|
||||
candidates := []string{
|
||||
fmt.Sprintf("templates/%s/%s.docx", firm, code),
|
||||
fmt.Sprintf("templates/_base/%s.docx", code),
|
||||
fmt.Sprintf("templates/_base/%s.docx", family),
|
||||
"templates/_base/_skeleton.docx",
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if tmpl, ok := r.fetch(ctx, path); ok {
|
||||
return tmpl, nil
|
||||
}
|
||||
}
|
||||
return Template{}, ErrNoTemplate
|
||||
}
|
||||
```
|
||||
|
||||
`fetch` does the same SHA-cache dance `handlers/files.go` already
|
||||
does, scoped to the templates subtree.
|
||||
|
||||
### 5.3 Gitea auth
|
||||
|
||||
Reuses `GITEA_TOKEN` env var that already exists for the HL Patents
|
||||
Style proxy. `m/mWorkRepo` is the same repo, same access token. No
|
||||
new secret to configure.
|
||||
|
||||
### 5.4 What happens when no template resolves
|
||||
|
||||
The fallback chain ends at `_skeleton.docx`. The skeleton is an
|
||||
intentionally bare-bones .docx (firm letterhead + party block + court
|
||||
address + case number + signature stub) that ships as part of the
|
||||
initial template set. In practice every Generate request resolves to
|
||||
something — but if even the skeleton 404s (misconfigured repo), the
|
||||
generator returns `503` with a clear error, the SubmissionsPanel
|
||||
button surfaces "Vorlagen-Repository nicht erreichbar".
|
||||
|
||||
---
|
||||
|
||||
## §6 Variable interpolation
|
||||
|
||||
### 6.1 Engine
|
||||
|
||||
Plain text replacement of `{{path.dot.notation}}` placeholders in the
|
||||
.docx body. Whitespace inside braces is trimmed
|
||||
(`{{ project.case_number }}` ≡ `{{project.case_number}}`).
|
||||
|
||||
Implementation: a Go library that handles Word's run-fragmentation
|
||||
correctly (Word may split `{{project.case_number}}` across multiple
|
||||
`<w:r>` runs during editing; naive find/replace breaks). Candidates:
|
||||
|
||||
- **`github.com/lukasjarosch/go-docx`** (~2k stars, MIT, pure Go,
|
||||
maintained). Handles run-merging before replacement. **Inventor
|
||||
recommendation.**
|
||||
- `github.com/nguyenthenguyen/docx` — older, less active.
|
||||
- Custom in-house implementation — ~200 LoC for a minimal robust
|
||||
replacer that walks the document XML and merges runs that fall
|
||||
inside a `{{…}}` span. Fallback if the library doesn't pan out.
|
||||
|
||||
Slice 1: try `lukasjarosch/go-docx` first; if it has dealbreaker bugs
|
||||
(e.g. blows up on Word's autocorrect runs), fall back to the in-house
|
||||
~200 LoC walker. The library choice is an implementation detail; the
|
||||
placeholder syntax stays the same either way.
|
||||
|
||||
### 6.2 Variable contract (v1 placeholder set)
|
||||
|
||||
```
|
||||
{{firm.name}} — HLC (or whatever FIRM_NAME is)
|
||||
{{firm.signature_block}} — Phase 2; v1 renders empty string
|
||||
|
||||
{{today}} — 2026-05-19 (ISO)
|
||||
{{today.long_de}} — "19. Mai 2026"
|
||||
{{today.long_en}} — "19 May 2026"
|
||||
|
||||
{{user.display_name}} — "Maria Schmidt"
|
||||
{{user.email}} — "maria.schmidt@hlc.com"
|
||||
{{user.office}} — "Munich"
|
||||
|
||||
{{project.title}} — paliad.projects.title
|
||||
{{project.reference}} — paliad.projects.reference
|
||||
{{project.case_number}} — paliad.projects.case_number
|
||||
{{project.court}} — paliad.projects.court
|
||||
{{project.patent_number}} — paliad.projects.patent_number
|
||||
{{project.filing_date}} — ISO date
|
||||
{{project.grant_date}} — ISO date
|
||||
{{project.our_side}} — "claimant" | "defendant"
|
||||
{{project.our_side_de}} — "Klägerin" | "Beklagte"
|
||||
{{project.instance_level}} — "lg" | "olg" | "bgh" | ...
|
||||
{{project.proceeding.code}} — e.g. "de.inf.lg"
|
||||
{{project.proceeding.name}} — Verletzungsklage am Landgericht
|
||||
{{project.client_number}} — paliad.projects.client_number
|
||||
{{project.matter_number}} — paliad.projects.matter_number
|
||||
|
||||
{{parties.claimant.name}} — first paliad.parties row with role='claimant'
|
||||
{{parties.claimant.representative}} — paliad.parties.representative
|
||||
{{parties.defendant.name}} — first row with role='defendant'
|
||||
{{parties.defendant.representative}} — paliad.parties.representative
|
||||
{{parties.other.name}} — first row with role NOT IN ('claimant','defendant') — court, intervener, etc.
|
||||
|
||||
{{rule.submission_code}} — "de.inf.lg.erwidg"
|
||||
{{rule.name}} — "Klageerwiderung"
|
||||
{{rule.name_en}} — "Statement of Defence"
|
||||
{{rule.legal_source}} — "DE.ZPO.276.1"
|
||||
{{rule.legal_source_pretty}} — "§ 276 Abs. 1 ZPO"
|
||||
{{rule.primary_party}} — "defendant"
|
||||
{{rule.event_type}} — "filing"
|
||||
|
||||
{{deadline.due_date}} — date of the next pending deadline for this rule on this project
|
||||
{{deadline.due_date_long_de}} — "26. Juni 2026"
|
||||
{{deadline.computed_from}} — anchor description (e.g. "Klageerhebung am 12.05.2026 +6 Wochen")
|
||||
```
|
||||
|
||||
Per-firm extensions (e.g. `{{firm.signature_block}}` filled from a
|
||||
table) are Phase 2.
|
||||
|
||||
### 6.3 Variable bag construction
|
||||
|
||||
`services/submission_vars.go` builds a flat `map[string]string`
|
||||
keyed by the dotted-path placeholders above. One pass over:
|
||||
|
||||
1. `branding.Name` for `{{firm.*}}`
|
||||
2. `time.Now()` (with `Europe/Berlin` locale for the long forms) for
|
||||
`{{today.*}}`
|
||||
3. `userService.GetByID()` for `{{user.*}}`
|
||||
4. `projectService.GetByID()` for `{{project.*}}`
|
||||
5. `partyService.ListByProject()` for `{{parties.*}}`
|
||||
6. `deadlineRuleService.GetByCode()` for `{{rule.*}}`
|
||||
7. `deadlineService.NextByRuleOnProject()` for `{{deadline.*}}`
|
||||
|
||||
Missing values render as `[KEIN WERT: {dotted.path}]` (DE) or
|
||||
`[NO VALUE: {dotted.path}]` (EN) based on user locale. This is by
|
||||
design — the lawyer sees the gap in Word, fixes it (either in Word
|
||||
or in paliad and regenerates), rather than getting a 400 with a list
|
||||
of missing fields they then have to chase.
|
||||
|
||||
### 6.4 Pretty-printing the legal_source
|
||||
|
||||
`legal_source` in the rule corpus is shorthand
|
||||
(`DE.ZPO.276.1`, `UPC.RoP.23.1`). Lawyers don't want that in a brief;
|
||||
they want `§ 276 Abs. 1 ZPO` or `Rule 23.1 RoP UPC`.
|
||||
|
||||
Slice 1 ships a small pretty-printer (`legalSourcePretty`) that knows
|
||||
the families we currently use:
|
||||
|
||||
| Prefix | Pretty form (DE) | Pretty form (EN) |
|
||||
|---|---|---|
|
||||
| `DE.ZPO.<§>.<Abs>` | `§ <§> Abs. <Abs> ZPO` | `Section <§>(<Abs>) ZPO` |
|
||||
| `DE.ZPO.<§>` | `§ <§> ZPO` | `Section <§> ZPO` |
|
||||
| `UPC.RoP.<Rule>.<Sub>` | `Regel <Rule>.<Sub> VerfO UPC` | `Rule <Rule>.<Sub> RoP UPC` |
|
||||
| `UPC.RoP.<Rule>` | `Regel <Rule> VerfO UPC` | `Rule <Rule> RoP UPC` |
|
||||
| `DE.PatG.<§>` | `§ <§> PatG` | `Section <§> PatG` |
|
||||
| `EPC.<Art>` | `Art. <Art> EPÜ` | `Art. <Art> EPC` |
|
||||
| (unknown) | original string | original string |
|
||||
|
||||
Unrecognised prefixes pass through unchanged (better than an
|
||||
incorrect prettification). The function is pure and unit-tested.
|
||||
|
||||
---
|
||||
|
||||
## §7 File naming
|
||||
|
||||
Generated file name:
|
||||
|
||||
```
|
||||
{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx
|
||||
```
|
||||
|
||||
Concrete example for the Slice 1 happy path:
|
||||
|
||||
```
|
||||
Klageerwiderung-2 O 123_25-2026-05-19.docx
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `rule.name` honours user locale (`Klageerwiderung` for DE,
|
||||
`Statement of Defence` for EN).
|
||||
- `project.case_number` slash/backslash → underscore (Word file name
|
||||
hygiene), other characters preserved.
|
||||
- Date is ISO at server-local (`Europe/Berlin`) date.
|
||||
- If `project.case_number` is empty → fall back to a short hash of
|
||||
`project_id` (8 hex chars) so the file still has a stable identifier
|
||||
the lawyer can rename without losing track.
|
||||
|
||||
---
|
||||
|
||||
## §8 Authorization
|
||||
|
||||
- **Visibility gate:** `paliad.can_see_project(project_id)` — anyone
|
||||
who can see the project can generate. Matches every other write
|
||||
surface on the project. The endpoint inlines the predicate;
|
||||
unauthorised callers get 404 (not 403, to avoid project
|
||||
enumeration).
|
||||
- **No profession floor.** A paralegal can generate a draft of a
|
||||
Klageerwiderung; the draft is a Word doc that needs the associate's
|
||||
approval downstream (in Word, on the document itself). Adding an
|
||||
approval gate on generation would slow the workflow without
|
||||
preventing anything that paliad's existing approval system doesn't
|
||||
already cover at the substantive-act layer.
|
||||
- **Owner gate (Paliadin) does NOT apply.** This is the
|
||||
submission-template engine, not Paliadin. All paliad users get the
|
||||
feature once a template exists for the proceeding their project is
|
||||
in.
|
||||
|
||||
---
|
||||
|
||||
## §9 Audit trail
|
||||
|
||||
Two records per generation:
|
||||
|
||||
### 9.1 `paliad.documents` row (audit-only, no binary)
|
||||
|
||||
```sql
|
||||
INSERT INTO paliad.documents (id, title, doc_type, file_path, file_size,
|
||||
mime_type, ai_extracted, uploaded_by,
|
||||
project_id)
|
||||
VALUES (gen_random_uuid(),
|
||||
'{rule.name} (generiert {YYYY-MM-DD})',
|
||||
'generated_submission', -- new doc_type value
|
||||
NULL, -- no on-disk path
|
||||
NULL, -- no file size (binary not persisted)
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
jsonb_build_object(
|
||||
'submission_code', $1,
|
||||
'template_path', $2, -- the gitea path we resolved
|
||||
'template_sha', $3, -- pinned SHA from the cache fetch
|
||||
'firm', $4),
|
||||
$user_id,
|
||||
$project_id);
|
||||
```
|
||||
|
||||
- `doc_type='generated_submission'` is a new value; no CHECK constraint
|
||||
on doc_type today so this is additive.
|
||||
- `file_path NULL` is the marker that says "regenerate from inputs on
|
||||
demand". The /api/projects/{id}/documents listing UI (Phase 2) will
|
||||
surface a `[Erneut generieren]` action for these rows.
|
||||
- `ai_extracted` jsonb is repurposed for generation provenance
|
||||
(template SHA, firm at time of generation). Naming is unfortunate
|
||||
but the column shape fits; renaming the column is out of scope for
|
||||
this task.
|
||||
|
||||
### 9.2 `paliad.system_audit_log` row
|
||||
|
||||
```sql
|
||||
INSERT INTO paliad.system_audit_log (event_type, actor_id, actor_email,
|
||||
scope, scope_root, metadata)
|
||||
VALUES ('submission.generated',
|
||||
$user_id,
|
||||
$user_email,
|
||||
'project',
|
||||
$project_id::text,
|
||||
jsonb_build_object(
|
||||
'submission_code', $1,
|
||||
'template_path', $2,
|
||||
'template_sha', $3,
|
||||
'document_id', $document_id,
|
||||
'firm', $4));
|
||||
```
|
||||
|
||||
Mirrors the existing `system_audit_log` event_type convention
|
||||
(`*.created`, `*.updated`, etc., from t-paliad-214).
|
||||
|
||||
### 9.3 Verlauf entry (project event)
|
||||
|
||||
`paliad.project_events` gets a row with `event_type='submission_generated'`
|
||||
and `timeline_kind='custom_milestone'` so the generation surfaces in
|
||||
SmartTimeline's audit-log toggle and on the project's Verlauf list.
|
||||
This is the user-visible footprint; the `system_audit_log` entry is
|
||||
the admin-visible audit footprint.
|
||||
|
||||
---
|
||||
|
||||
## §10 Frontend surface
|
||||
|
||||
### 10.1 Slice 1 — SubmissionsPanel on project detail
|
||||
|
||||
A new panel below the existing Verlauf / Deadlines panels on
|
||||
`/projects/{id}`:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Schriftsätze │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Klageerhebung [— Vorlage fehlt] │
|
||||
│ Klageerwiderung [Generieren ↓] │
|
||||
│ Replik [— Vorlage fehlt] │
|
||||
│ Duplik [— Vorlage fehlt] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Filter: only `event_type='filing'` rules from the project's
|
||||
`proceeding_type` are listed. Hearings and decisions don't have
|
||||
submissions.
|
||||
- Per-row state: `has_template` returned by
|
||||
`GET /api/projects/{id}/submissions`. Disabled buttons show the
|
||||
"Vorlage fehlt" hint (German default, English in EN locale).
|
||||
- Click `[Generieren ↓]` → POST → browser triggers download.
|
||||
- `aria-busy="true"` on the panel while a generation is in flight
|
||||
(cheap, but lawyers feel slow networks).
|
||||
|
||||
### 10.2 Out of scope for Slice 1
|
||||
|
||||
- A standalone `/submissions` index page.
|
||||
- A Frist-detail "Generate" button.
|
||||
- A picker for template variants (1:N) — locked to fallback chain
|
||||
(Q4), which is 1:1 from the user's perspective.
|
||||
- An "edit project, then regenerate" loop on the same UI.
|
||||
|
||||
---
|
||||
|
||||
## §11 AI-drafted body (deferred — sketch only)
|
||||
|
||||
NOT in scope for t-paliad-215. Documented here so the next inventor
|
||||
picking up the "AI Klageerwiderung body" task has a clear starting
|
||||
shape.
|
||||
|
||||
The natural fit: a new aichat persona (e.g. `paliadin-draft`) on
|
||||
mRiver, parallel to the existing `paliadin` persona.
|
||||
|
||||
```
|
||||
{{ai.draft_body}} # placeholder in the template
|
||||
|
||||
→ generator detects {{ai.*}} placeholders in the template
|
||||
→ POSTs to aichat with persona=paliadin-draft + context:
|
||||
- project state (variables already built)
|
||||
- relevant project notes (paliad.notes)
|
||||
- the deadline_rule corpus (rule + family)
|
||||
- HL Patents Style guide chunks (RAG, eventually)
|
||||
→ aichat returns Markdown body
|
||||
→ generator injects into the .docx as one or more <w:p> paragraphs
|
||||
(Word-friendly Markdown → docx mapping needed; substantive
|
||||
formatting question for that follow-up)
|
||||
```
|
||||
|
||||
Open shape questions for that follow-up (NOT for this design):
|
||||
|
||||
- One persona per submission type, or one persona that branches on
|
||||
`submission_code` in its system prompt?
|
||||
- Owner gate (m only) like current paliadin, or open to all
|
||||
authenticated users?
|
||||
- Approval gate before the AI body lands in the .docx?
|
||||
- Cost accounting per generation?
|
||||
- Where does the prose context come from (notes / uploaded patent
|
||||
spec / prior pleadings)?
|
||||
|
||||
Re-uses, when that task fires:
|
||||
|
||||
- This task's template engine, variable contract, fallback chain,
|
||||
audit trail — all unchanged.
|
||||
- Just a new placeholder family (`{{ai.*}}`) + a new aichat persona +
|
||||
a new admin gate.
|
||||
|
||||
---
|
||||
|
||||
## §12 Slice plan beyond Slice 1
|
||||
|
||||
| Slice | Scope |
|
||||
|---|---|
|
||||
| 1 | One template (`de.inf.lg.erwidg`), engine + fallback chain + audit + SubmissionsPanel on project detail. THIS DESIGN. |
|
||||
| 2 | 3–5 more templates (Klageerhebung, SoC `upc.inf.cfi.soc`, SoD `upc.inf.cfi.sod`, Berufungsbegründung `de.inf.olg.begruendung`). Template authoring effort, no new architecture. |
|
||||
| 3 | Variable-contract sidebar in a new `/admin/submission-templates` page (mirrors `/admin/email-templates` shape). Shows what placeholders exist, with samples. Does NOT add an uploader UI — Gitea remains the editor. |
|
||||
| 4 | Per-firm override directory exercised (first non-HLC firm onboarded). |
|
||||
| 5 | Frist-detail "Generate" button + paliad.documents.deadline_id FK (mig 103+) for per-Frist draft history. |
|
||||
| 6 | (Separate task) AI-drafted body via Paliadin persona — see §11. |
|
||||
| 7 | (Future) Paliad UI uploader as alternative to Gitea, if the round-trip is friction. |
|
||||
|
||||
Slices 2–5 are roadmap markers, not commitments — m decides cadence.
|
||||
|
||||
---
|
||||
|
||||
## §13 Trade-offs flagged
|
||||
|
||||
1. **No binary persistence is a deliberate retention choice.** If a
|
||||
lawyer regenerates after the project state changes (party renamed,
|
||||
case_number corrected), the "regenerated" doc differs from the
|
||||
"original generated" doc. This is a feature, not a bug — the source
|
||||
of truth is paliad's project state, and the .docx is a derivative.
|
||||
But the lawyer needs to be aware: there is no "what did I generate
|
||||
last Thursday" recovery without re-saving locally. The
|
||||
`paliad.documents` audit row records WHAT was generated (template
|
||||
SHA + project state hash, optionally), but not the bytes.
|
||||
|
||||
2. **Gitea round-trip for template edits is friction.** Template
|
||||
authors edit `.docx` in Word, save, drag to Gitea web UI (or push
|
||||
from a local clone). The 5-min SHA cache means edits surface
|
||||
within 5 minutes (or instantly via `POST /api/files/refresh` —
|
||||
already wired for the HL Patents Style template). If lawyers
|
||||
complain, Phase 7 adds an in-paliad uploader. Until then, Gitea is
|
||||
the editor.
|
||||
|
||||
3. **Variable contract changes are coordinated edits.** Adding a new
|
||||
`{{project.*}}` placeholder needs both a code change (var bag) AND
|
||||
template edits (templates won't auto-discover new placeholders).
|
||||
The variable-contract sidebar (Slice 3) is the mitigation —
|
||||
template authors see what's available without reading the Go code.
|
||||
|
||||
4. **`lukasjarosch/go-docx` library risk.** ~2k stars, MIT, maintained
|
||||
— but it's a third-party dep we haven't used before. Fallback is
|
||||
the in-house ~200-LoC walker. The placeholder syntax doesn't change
|
||||
either way; Slice 1 can swap engines without touching templates or
|
||||
callers.
|
||||
|
||||
5. **`paliad.documents.ai_extracted` is repurposed for generation
|
||||
provenance.** Slightly ugly naming because the column was added for
|
||||
Phase H (AI Frist-Extraktion), which never shipped. Renaming the
|
||||
column to something like `metadata` is out of scope for this task
|
||||
but should be folded into the migration that lands when Phase 5
|
||||
adds `deadline_id`.
|
||||
|
||||
6. **`paliad.parties.role='claimant'`** — multiple claimants on a
|
||||
project (multi-party suit) → Slice 1 picks the first row. v1
|
||||
shortcut. Templates needing multi-claimant blocks become Phase 2
|
||||
work (with a `{{#each parties.claimants}}` shape on top of
|
||||
`lukasjarosch/go-docx`'s loop support).
|
||||
|
||||
7. **No Word-side `MERGEFIELD` support.** Lawyers who insert Word
|
||||
merge fields (via Insert → Quick Parts → Field) instead of typing
|
||||
`{{…}}` will get untouched MERGEFIELD codes in the rendered output.
|
||||
Decision: standardise on `{{…}}` syntax (cheap to type, visible
|
||||
in the template, predictable). Document this in the `templates/
|
||||
README.md`.
|
||||
|
||||
8. **No template versioning UI.** Gitea provides git history; that's
|
||||
the canonical version trail. Bumping to "use template X as of
|
||||
commit Y" for an old project is a manual git-checkout-and-pin
|
||||
exercise. Phase 2+ if anyone asks; not today.
|
||||
|
||||
---
|
||||
|
||||
## §14 Open follow-ups (NOT blocking)
|
||||
|
||||
These items are NOT m-decisions; they're follow-ups for the coder
|
||||
shift or future inventor passes:
|
||||
|
||||
- **Template authoring effort.** Slice 1 needs HLC to author/review
|
||||
the actual Klageerwiderung template. That's a legal-review task that
|
||||
can run in parallel with the engine code (template uploaded last
|
||||
before the slice ships). Coordinate with m on who reviews.
|
||||
- **English version of `legalSourcePretty`.** Pretty-printer table in
|
||||
§6.4 needs an EN column for every prefix — populated from existing
|
||||
glossary entries where possible.
|
||||
- **i18n key sweep.** `submissions.*` namespace; ~20 keys for Slice 1
|
||||
(panel title, button labels, "Vorlage fehlt" hints, error messages
|
||||
for 503/404/422).
|
||||
- **README for template authors.** A `templates/README.md` in
|
||||
m/mWorkRepo listing the available placeholders + naming convention
|
||||
+ a screenshot of a working template. Coder ships this alongside
|
||||
Slice 1.
|
||||
- **CLAUDE.md update.** Add a "Submission templates" section
|
||||
documenting the Gitea proxy, placeholder syntax, and the
|
||||
`submission.generated` audit event_type.
|
||||
- **Cleanup task for `ai_extracted` naming.** Issue + Phase 5 mig.
|
||||
|
||||
---
|
||||
|
||||
## §15 What this design does NOT do
|
||||
|
||||
To set the scope boundary cleanly:
|
||||
|
||||
- ❌ Generate PDFs.
|
||||
- ❌ Generate emails or any non-.docx format.
|
||||
- ❌ Edit `.docx` files inside paliad (no in-browser Word editor).
|
||||
- ❌ Upload .docx to NetDocuments or any external DMS.
|
||||
- ❌ Translate templates DE↔EN automatically.
|
||||
- ❌ Validate the generated draft against any legal rule.
|
||||
- ❌ Sign, certify, or notarise the output.
|
||||
- ❌ Send the draft to court / e-filing.
|
||||
- ❌ AI-draft any prose. (See §11.)
|
||||
- ❌ Provide a paliad-UI template editor. (Gitea is the editor.)
|
||||
- ❌ Persist generated .docx bytes server-side. (Audit row only.)
|
||||
- ❌ Add a new database table. (`paliad.documents` is enough for v1.)
|
||||
- ❌ Require a database migration. (Slice 1 is migration-free.)
|
||||
|
||||
Each of these is a defensible future-scope item; none belong in
|
||||
Slice 1.
|
||||
|
||||
---
|
||||
|
||||
## §16 Recommended implementer
|
||||
|
||||
Pattern-fluent Sonnet coder. The substrate is well-trodden:
|
||||
|
||||
- Gitea proxy + cache: `internal/handlers/files.go` is the template
|
||||
to lift.
|
||||
- Variable contract pattern: `internal/services/email_template_variables.go`
|
||||
is the template to mirror (different surface, identical shape).
|
||||
- Visibility gate: `internal/services/visibility.go` +
|
||||
`paliad.can_see_project()` — standard everywhere.
|
||||
- Audit insert: `paliad.system_audit_log` (mig 102) + `paliad.documents`
|
||||
(existing table, first writer).
|
||||
- Frontend SubmissionsPanel: stock TSX + client/.ts pattern, same shape
|
||||
as the existing CardLayout / EventsList panels.
|
||||
|
||||
The only novel piece is the docx merge library integration — that's a
|
||||
~200 LoC isolated module the coder can prototype on a sample .docx
|
||||
before wiring into the project flow.
|
||||
|
||||
NOT cronus per project memory directive.
|
||||
|
||||
---
|
||||
|
||||
## §17 Acceptance criteria for Slice 1
|
||||
|
||||
The coder considers Slice 1 done when:
|
||||
|
||||
1. Pushing a `.docx` to `m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`
|
||||
and visiting any project with `proceeding_type=de.inf.lg` surfaces
|
||||
a `[Generieren]` Klageerwiderung button.
|
||||
2. Clicking it downloads a `.docx` named per §7 with all §6.2
|
||||
placeholders resolved (or `[KEIN WERT: …]` markers for genuinely
|
||||
missing project fields).
|
||||
3. Opening the downloaded .docx in Word renders cleanly (no run
|
||||
fragmentation artefacts, no broken styles).
|
||||
4. A row appears in `paliad.documents` with `doc_type='generated_submission'`,
|
||||
`file_path=NULL`, and `ai_extracted` jsonb carrying the template
|
||||
path + SHA.
|
||||
5. A row appears in `paliad.system_audit_log` with `event_type='submission.generated'`.
|
||||
6. A row appears in `paliad.project_events` with
|
||||
`event_type='submission_generated'` and shows up in the project's
|
||||
Verlauf / SmartTimeline.
|
||||
7. Calling the endpoint without project visibility returns 404.
|
||||
8. Calling the endpoint with no template anywhere in the fallback
|
||||
chain returns 503 with a clear error.
|
||||
9. Unit tests cover: placeholder rendering happy path, missing-var
|
||||
marker, fallback chain (all 4 levels), file naming, slash
|
||||
sanitization, legalSourcePretty for every prefix in §6.4.
|
||||
10. `go build ./... && go vet ./... && go test ./... && bun run build`
|
||||
all clean.
|
||||
11. Manual test on the live database (test admin
|
||||
`tester@hlc.de` per memory) against a project with a real
|
||||
`de.inf.lg` proceeding succeeds end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## §18 Approval gate
|
||||
|
||||
Per inventor SKILL.md and project CLAUDE.md: this design needs m's
|
||||
go/no-go before any coder is hired. After m approves:
|
||||
|
||||
- The head decides whether to hire the same worker as `/mai-coder`
|
||||
with this design as the brief, or a fresh coder.
|
||||
- A coder shift takes this doc as the spec, ships Slice 1, opens a
|
||||
PR (no self-merge — maria's gate).
|
||||
- Phase 11 (AI-drafted body) is a SEPARATE task — not auto-spawned.
|
||||
|
||||
Inventor parks here.
|
||||
435
docs/proposals/fristen-gap-fill-2026-05-18.md
Normal file
435
docs/proposals/fristen-gap-fill-2026-05-18.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# Fristenrechner Gap-Fill Proposals — t-paliad-203
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Author:** curie (researcher)
|
||||
**Status:** DRAFT — for m's review, not yet ingested via `/admin/rules`
|
||||
**Branch:** `mai/curie/fristenrechner-gap`
|
||||
**Supersedes:** t-paliad-201 (cancelled)
|
||||
**Source audit:** the four gaps surfaced by mig 093 commit message (t-paliad-200, `internal/db/migrations/093_retire_litigation_category.up.sql:40-54`) when 40 Pipeline-A litigation rules were archived under `_archived_litigation` and 7 litigation proceeding_types were dropped
|
||||
|
||||
---
|
||||
|
||||
## 0. Read-this-first — what was archived, what's left
|
||||
|
||||
mig 093 (commit `40e49e8`) retired the entire `category='litigation'` rule corpus by:
|
||||
|
||||
1. Snapshotting the 40 rules into `paliad.deadline_rules_pre_093` and the 7 proceeding_types into `paliad.proceeding_types_pre_093`.
|
||||
2. Re-homing all 40 rules under a holding proceeding_type `_archived_litigation` (id 32, `category='archived'`, `is_active=false`, `lifecycle_state='archived'`).
|
||||
3. Dropping `INF`, `REV`, `CCR`, `APM`, `APP`, `AMD`, `ZPO_CIVIL` from `paliad.proceeding_types`.
|
||||
|
||||
The commit's own body listed four open coverage questions for legal review (lines 40-54 of `093_retire_litigation_category.up.sql`):
|
||||
|
||||
| # | Pipeline-A rule(s) | Claim in commit body | This doc's verdict |
|
||||
|---|---|---|---|
|
||||
| 1 | `inf.prelim` (R.19, 1 month) | "not present on UPC_INF — possible coverage gap" | **Real gap.** Drafts 1.1 + 1.2 below. |
|
||||
| 2 | `inf.appeal` / `rev.appeal` / `ccr.appeal` (RoP.220.1, 2 months) into UPC_APP | "fristenrechner UPC_APP starts standalone with no spawn" | **Real gap.** Drafts 2.1 + 2.2 below. Pipeline-A's three rules collapse to two in the unified UPC_INF (CCR-as-flag) world — see § 2 FLAG. |
|
||||
| 3 | `ccr.amend` / `rev.amend` (spawn into AMD) | "superseded by `inf.app_to_amend` / `rev.app_to_amend` — safe to drop" | **Claim confirmed for patent amendment.** No new rules. § 3 documents the verification and surfaces R.263 (case-amendment) as a separate not-modelled item. |
|
||||
| 4 | `zpo.klage` / `zpo.vertanz` / `zpo.klageerw` / `zpo.berufung` | "no UPC analogue; redundant with DE_INF / DE_INF_OLG / DE_INF_BGH / DE_NULL / DE_NULL_BGH" | **Claim confirmed for klage / vertanz / berufung.** `klageerw` exists on DE_INF but with a duration discrepancy worth m's attention. § 4 details. |
|
||||
|
||||
**Net: 4 substantive rule drafts** (1 PO on UPC_INF + 1 PO on UPC_REV + 2 merits-appeal spawns) — well under the "~4-10" estimate in the brief, and at the low end because two of the four gaps don't need new rules.
|
||||
|
||||
### 0.1 Naming convention notes
|
||||
|
||||
- **Appeal proceeding code referenced by ROLE, not by current code.** Per task brief and pairing with t-paliad-204 (proceeding-code abbreviation rework, m's review pending), the current `UPC_APP` (id=11) is referred to in proposals 2.1/2.2 as **"UPC infringement-appeal proceeding (RoP 220.1(a) main-judgment appeal)"** rather than by code. m picks the final `spawn_proceeding_type_id` when ingesting via `/admin/rules`.
|
||||
- **Existing rule-code pattern.** Live `UPC_INF` rules use bare prefix `inf.*` (not `upc.inf.*`), e.g. `inf.sod`, `inf.def_to_ccr`. Live `UPC_REV` rules use `rev.*`. I follow that pattern: proposed PO rules are `inf.prelim` (matching Pipeline-A's archived name) and `rev.prelim`; proposed spawn rules are `inf.appeal_spawn` / `rev.appeal_spawn` (the `_spawn` suffix disambiguates them from the existing UPC_APP-root `app.notice`, which is the *target*, not the *source*).
|
||||
- **Anchor semantics** (per `docs/audit-fristen-logic-2026-05-13.md` § 4 and `docs/proposals/orphan-concepts-2026-05-15.md` § 0.2): `parent_id NOT NULL` chains the new rule off an existing rule in the same proceeding. `trigger_event_id NOT NULL` roots the rule on a paliad/youpc trigger event. The unified Phase 2 schema (Slice 4, mig 081+082) supports both — proposals use `parent_id` whenever the natural anchor is an existing intra-proceeding rule (e.g. `inf.soc` for inf.prelim), which matches the pattern set by `inf.sod`, `inf.def_to_ccr`, etc.
|
||||
- **`condition_expr` form.** Existing UPC_INF / UPC_REV conditional rules use `{"flag":"with_ccr"}` or `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`. The proposals add three new flag names — `with_po`, `with_appeal`, and reuse `with_amend` only where existing. Flag names are surfaced as **FLAG** items for m to confirm before ingest.
|
||||
|
||||
### 0.2 What's deliberately out of scope
|
||||
|
||||
- **Order-appeals (R.220.2/R.220.3) spawn wiring** — the brief specifies RoP 220.1(a) (main-judgment, 2-month appeal → `UPC_APP`). The 15-day order/discretion track lives in `UPC_APP_ORDERS` and has its own root rules (`app_ord.with_leave`, `app_ord.discretion`). Spawn rules from UPC_INF/UPC_REV/UPC_PI for that track would be a separate proposal — flagged as future-work in § 6.
|
||||
- **Cost-decision-appeal spawn (R.221.1)** — `UPC_COST_APPEAL` exists with `cost.leave_app` as a root rule. Same shape as the order-appeals: future-work, not this proposal.
|
||||
- **R.263 application to amend the case** — surfaced in § 3 but not drafted as a rule because it's court-discretion (no calendar deadline computable from a fixed anchor).
|
||||
- **Vertagungsantrag (ZPO §227)** — the brief's description of Gap 4 named "Vertagungsantrag" but the Pipeline-A rule code `zpo.vertanz` is actually *Verteidigungsanzeige* (contraction of "Verteidigungs-Anzeige"), not Vertagungsantrag. There is no Vertagungsantrag rule anywhere in the corpus today; if m wants one, that's a fresh proposal. Documented in § 4 FLAG.
|
||||
|
||||
---
|
||||
|
||||
## 0.3 m's decisions on the open FLAGs (2026-05-18)
|
||||
|
||||
Captured live with paliadin/head. Anything not explicitly answered defaults to curie's recommendation.
|
||||
|
||||
### Gap 1 — Preliminary Objection
|
||||
|
||||
- **F1.4 (CCR-defendant PO):** **NO** — do not seed a third PO rule for the patentee on a CCR. Final shape stays at 2 PO rules: `inf.prelim` + `rev.prelim`.
|
||||
- F1.1 (flag name): default to curie's `with_po`.
|
||||
- F1.2 (priority): default to curie's `optional`.
|
||||
- F1.3 (citation pattern): default to curie's `UPC.RoP.19.1` substantive-cite for both rules (cross-ref to R.46 lives in the description, not the legal_source field).
|
||||
|
||||
### Gap 2 — Appeal spawns
|
||||
|
||||
- **F2.1 (drop `ccr.appeal`):** **CONFIRMED** — one decision under R.118 = one 2-month appeal window. Rule 2.3 explicitly NOT seeded. Final shape stays at 2 spawn rules.
|
||||
- **F2.3 (appeal flag-gated or always-fire):** **ALWAYS-FIRE.** Rationale (m): "the appeal deadline should always be triggered by a decision … the flags for ccr / amend are different because that is something which only comes up during the proceedings and depends on a party. Appeal is always a possibility." So both `inf.appeal_spawn` and `rev.appeal_spawn` ship **without `condition_expr`** — the 2-month window unconditionally appears once `inf.decision` / `rev.decision` is anchored. Visibility filtering ("hide appeal deadlines on projects where the user doesn't care") is a frontend concern, not a rule-level flag — surfaced as follow-up (see § 6.X below).
|
||||
- F2.2 (anchor): default to curie's `parent_id = inf.decision` / `rev.decision` (consistent with how `inf.cost_app` already chains).
|
||||
|
||||
### Gap 3 — `ccr.amend` / `rev.amend`
|
||||
|
||||
- **F3.1 (model R.263?):** **NO** — court-discretion, no calendar deadline computable. If R.263 ever needs surfacing, it goes on the project page as a checklist item, not the fristenrechner.
|
||||
|
||||
### Gap 4 — `zpo.*` family
|
||||
|
||||
- **§4.3 — `de_inf.erwidg` discrepancy (6 weeks vs. court-set 2-week minimum):** **FLIP to court-set.** Klageerwiderung is statutorily court-set with a 2-week minimum under ZPO §276(1) S.2; the existing 6-week fixed-duration rule is wrong. Action at ingest: `is_court_set=true`, keep `duration_value=6, duration_unit='weeks'` as the **default display value** when no court order is yet attached, with the description noting "Gericht setzt eine Frist von mindestens zwei Wochen ab Verteidigungsanzeige (§276 Abs. 1 S. 2 ZPO)." This matches the pattern existing court-set rules use elsewhere.
|
||||
- F4.1 (legal_source backfills on `de_inf.klage` etc.): default to curie's "yes — apply the polish patches in § 4.1, § 4.2, § 4.4".
|
||||
|
||||
### Final delta to ingest via `/admin/rules`
|
||||
|
||||
```
|
||||
NEW RULES (4):
|
||||
inf.prelim UPC_INF parent=inf.soc 1mo RoP.19.1 flag=with_po optional
|
||||
rev.prelim UPC_REV parent=rev.app 1mo RoP.19.1 flag=with_po optional
|
||||
inf.appeal_spawn UPC_INF parent=inf.decision 2mo RoP.220.1.a (no flag) optional spawn→merits-appeal
|
||||
rev.appeal_spawn UPC_REV parent=rev.decision 2mo RoP.220.1.a (no flag) optional spawn→merits-appeal
|
||||
|
||||
PATCHES on existing rules:
|
||||
de_inf.klage set legal_source = 'DE.ZPO.253'
|
||||
de_inf.anzeige (no change — already correct)
|
||||
de_inf.erwidg flip is_court_set = true; description note about §276 Abs.1 S.2
|
||||
de_inf.berufung (verify legal_source — curie's §4.4 polish patch)
|
||||
```
|
||||
|
||||
### Follow-up surfaced — not for this proposal
|
||||
|
||||
- **Frontend visibility toggle for appeal deadlines** — m flagged that appeals "always fire" at the rule level but the UI could hide them on projects where the user doesn't want to see them. NOT a rule-corpus question; file as a separate frontend task if/when m signals.
|
||||
- **`ccr.appeal` in `_archived_litigation`** — the Pipeline-A `ccr.appeal` row stays archived (m's call F2.1). No further action.
|
||||
- **Vertagungsantrag (ZPO §227)** — never modelled; not in scope. Open follow-up if m wants it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Gap 1 — Preliminary Objection (RoP 19)
|
||||
|
||||
**Status:** Real gap. Pipeline-A had `inf.prelim` (defendant, 1 month, R.19, "Rarely triggers separate decision; usually decided with main case") — archived without a fristenrechner replacement.
|
||||
|
||||
Verification — current UPC_INF / UPC_REV corpus has zero rules with `rule_code` matching `R.19`, `RoP.019`, or any "Preliminary Objection" variant; verified via `SELECT * FROM paliad.deadline_rules WHERE rule_code ILIKE '%19%' OR name ILIKE '%vorab%' OR name ILIKE '%prelim%' AND lifecycle_state <> 'archived'` returns empty.
|
||||
|
||||
Legal context — RoP 19 itself (Application of the Rules of Procedure, Part 1, Chapter 1, Section 4):
|
||||
|
||||
- **R.19.1**: The defendant may, within 1 month of service of the Statement of claim, lodge a Preliminary objection concerning (a) jurisdiction and competence of the Court including any objection to the decision of the Registry to assign a case to a particular division, (b) the language of the Statement of claim (R.14), or (c) the competence of the panel to which the action has been assigned.
|
||||
- **R.19.7 / R.19.8**: The Court decides on a preliminary objection by way of order, typically before the interim conference, but may join it to the main proceedings.
|
||||
- **R.46**: The Rules in Part 1, Chapter 1 (including R.19) apply *mutatis mutandis* to revocation actions — i.e. the defendant in a revocation action (the patent proprietor) may also lodge a preliminary objection within 1 month of service of the Statement for revocation.
|
||||
|
||||
The Pipeline-A note "Rarely triggers separate decision; usually decided with main case" is accurate practice — but the **1-month deadline to raise the objection** is hard and statutory. That deadline is what the fristenrechner needs to model.
|
||||
|
||||
### Rule 1.1 — Preliminary Objection on UPC_INF
|
||||
|
||||
- **Rule code:** `inf.prelim`
|
||||
- **Proceeding type:** UPC_INF (id=8)
|
||||
- **Name (DE):** Vorab-Einrede (R. 19 VerfO)
|
||||
- **Name (EN):** Preliminary Objection (RoP 19)
|
||||
- **Party:** defendant
|
||||
- **Anchor:** `parent_id = inf.soc` (the existing root rule "Klageerhebung") — same anchor pattern as `inf.sod` (Klageerwiderung, also parent=inf.soc). `inf.soc` is the trigger-date anchor; computing 1 month after `inf.soc` reads as "1 month from service of the Statement of Claim", consistent with R.19.1's wording.
|
||||
- **Duration:** 1, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional *(party decides whether to raise the objection; the 1-month period is statutory once invoked)*
|
||||
- **is_court_set:** false *(statutory period from service; not court-set)*
|
||||
- **condition_expr:** `{"flag":"with_po"}` *(only renders when the defendant indicates a PO will be filed — same shape as existing `with_ccr` / `with_amend` flags)*
|
||||
- **Legal source:** `UPC.RoP.19.1`
|
||||
- **`rule_code`:** `RoP.019.1`
|
||||
- **event_type:** `filing`
|
||||
- **Notes:** R.19.1 covers three independent grounds (a) jurisdiction/competence, (b) language under R.14, (c) panel competence. All share the same 1-month deadline. The UI rendering decision (one row vs. three rows by ground) is downstream UX, not a rule-corpus question.
|
||||
- **FLAG (F1.1):** Flag name — `with_po` is suggested by analogy to `with_ccr` / `with_amend` / `with_cci`. Alternative names: `with_preliminary_objection`, `prelim`. m's call.
|
||||
- **FLAG (F1.2):** Priority — proposed `optional` (defendant chooses); m may prefer `recommended` to surface it as a sanity-check chip on every defendant timeline. The Pipeline-A predecessor had `is_optional=true / is_mandatory=false` per the old binary schema, which maps cleanly to `priority='optional'` in the post-Slice-3 enum.
|
||||
|
||||
### Rule 1.2 — Preliminary Objection on UPC_REV
|
||||
|
||||
- **Rule code:** `rev.prelim`
|
||||
- **Proceeding type:** UPC_REV (id=9)
|
||||
- **Name (DE):** Vorab-Einrede (R. 19 i.V.m. R. 46 VerfO)
|
||||
- **Name (EN):** Preliminary Objection (RoP 19 in conjunction with RoP 46)
|
||||
- **Party:** defendant *(in a revocation action the patentee is the defendant)*
|
||||
- **Anchor:** `parent_id = rev.app` (the existing root rule "Nichtigkeitsklage" — analogous to `rev.defence` which also parents off `rev.app`)
|
||||
- **Duration:** 1, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** `{"flag":"with_po"}` *(same flag as 1.1 — a PO is a PO; the user sets `with_po=true` on a UPC_REV project when the patentee plans to lodge one)*
|
||||
- **Legal source:** `UPC.RoP.46` *(R.46 makes R.19 applicable to revocation actions; cite R.46 as the operative provision because RoP 19's literal text only addresses infringement)*
|
||||
- **`rule_code`:** `RoP.046` *(or `RoP.019.1` with a note — m's call; see FLAG F1.3)*
|
||||
- **event_type:** `filing`
|
||||
- **Notes:** Functionally identical to Rule 1.1 but rooted on UPC_REV. The grounds are narrower in practice (language and panel competence are the main triggers — jurisdiction is rarely contested in pure revocation actions because the UPC's jurisdiction over revocation of unitary patents is exclusive). But the 1-month statutory window is identical.
|
||||
- **FLAG (F1.3):** Legal-source citation — should this read `UPC.RoP.46` (operative provision for revocation) or `UPC.RoP.19.1` (substantive content)? Existing rules use the substantive citation (e.g. `inf.def_to_ccr` cites `UPC.RoP.29.a`, not the cross-reference that brings R.29 into the UPC_INF flow). I lean `UPC.RoP.19.1` with `rule_code='RoP.019.1'` to match that pattern; the cross-reference to R.46 belongs in the description, not the citation field.
|
||||
- **FLAG (F1.4):** Does paliad want **counterclaim-defendant** PO rules too? Specifically, when UPC_INF has `with_ccr=true`, the *claimant* (patentee) becomes the de-facto-defendant for the CCR portion. Does the claimant get a 1-month PO window from service of the CCR? My read of R.19 + R.46 + R.25: yes — the CCR triggers a fresh R.19 window for the claimant, anchored on service of the SoD-with-CCR. But this would be a third rule (`inf.prelim_ccr`, party=claimant, parent=inf.sod, 1 month, condition_expr={"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_po_ccr"}]}). I'm **not** drafting it pending m's confirmation; either it's truly there in the case law or it's an over-reading on my part. Lex-research won't help here because there's no relevant published UPC PO case on a CCR yet (R.46 + R.25 cross-reads are theoretical).
|
||||
|
||||
**Summary for Gap 1:** 2 new rules drafted (one on UPC_INF, one on UPC_REV). 4 FLAGs. Potential third rule (CCR-PO) deferred pending m's read.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gap 2 — Cross-proceeding APP spawns (RoP 220.1(a))
|
||||
|
||||
**Status:** Real gap. Pipeline-A had three placeholder rules (`inf.appeal`, `rev.appeal`, `ccr.appeal`, all 2 months, RoP.220.1, is_spawn=true) — but their `spawn_proceeding_type_id` was NULL so they weren't functional spawns either. Fristenrechner UPC_APP currently starts standalone with `app.notice` as its root rule (party=both, 2 months, RoP.220.1).
|
||||
|
||||
Verification — current corpus has zero `is_spawn=true AND is_active=true AND lifecycle_state<>'archived'` rules; the `spawn_proceeding_type_id` column on `paliad.deadline_rules` is unused in the live data (Slice 7 wiring was the design intent but no real spawns have been seeded yet).
|
||||
|
||||
Legal context — RoP 220 (Decisions and orders which may be appealed):
|
||||
|
||||
- **R.220.1(a)**: Final decisions under R.118 may be appealed. The appeal period is **2 months of service** of the decision (R.224.1(a)).
|
||||
- **R.224.1(a)**: The Statement of appeal must be lodged within 2 months of service of the decision.
|
||||
- **R.224.2(a)**: The Statement of grounds of appeal must be lodged within 4 months of service of the decision (independent from R.224.1(a), not chained off it).
|
||||
|
||||
The spawn target — the proceeding rooted by `app.notice` (Berufungseinlegung, RoP.220.1, 2 months) and `app.grounds` (Berufungsbegründung, 4 months from decision) — is what the task brief calls the "UPC infringement-appeal (RoP 220.1(a) main-judgment appeal)" proceeding. Today that's `UPC_APP` (id=11); per t-paliad-204, the code may be renamed before m ingests these proposals, so I refer to it by role only.
|
||||
|
||||
### Rule 2.1 — Appeal spawn from UPC_INF
|
||||
|
||||
- **Rule code:** `inf.appeal_spawn`
|
||||
- **Proceeding type:** UPC_INF (id=8)
|
||||
- **Name (DE):** Berufung gegen Endentscheidung
|
||||
- **Name (EN):** Appeal against final decision
|
||||
- **Party:** both *(either party may appeal an R.118 final decision adverse to them)*
|
||||
- **Anchor:** `parent_id = inf.decision` (existing court-set rule "Entscheidung"). The chain: `inf.soc → … → inf.decision (court-set, no statutory date) → inf.appeal_spawn (2 months after service of decision)`. Because `inf.decision` is `IsCourtSet=true` (per `isCourtDeterminedRule` in `internal/services/fristenrechner.go`), the appeal-spawn deadline only becomes a concrete date once the user anchors `inf.decision` via the smart-timeline click-to-anchor mechanism (Slice 2, `POST /api/projects/{id}/timeline/anchor` per memory `ab966313-cae6-49b0-8223-9adb62a64370`).
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional *(party decides whether to appeal; the 2-month period is statutory once invoked)*
|
||||
- **is_court_set:** false *(deadline is statutory once the decision is served)*
|
||||
- **condition_expr:** `{"flag":"with_appeal"}` *(only renders when the user has indicated an appeal is contemplated — keeps non-appealing projects' timelines clean)*
|
||||
- **Legal source:** `UPC.RoP.220.1`
|
||||
- **`rule_code`:** `RoP.220.1.a`
|
||||
- **event_type:** `filing`
|
||||
- **is_spawn:** true
|
||||
- **spawn_proceeding_type_id:** → UPC infringement-appeal proceeding (currently `UPC_APP`, id=11; m picks final code at ingest per t-paliad-204).
|
||||
- **spawn_label (DE):** "Berufungsverfahren öffnen"
|
||||
- **spawn_label (EN):** "Open appeal proceedings"
|
||||
- **Notes:** Spawning into the appeal proceeding creates a child project (or routes into the standalone UPC_APP fristenrechner depending on how spawn rendering works on the project page). The 4-month Statement of grounds period (R.224.2(a), `app.grounds`) is already a root rule on UPC_APP — once the appeal child opens, that timeline takes over. **No need** to also model `app.grounds` as a spawn rule from UPC_INF; the existing UPC_APP root rules cover it.
|
||||
- **FLAG (F2.1):** Does the spawn fire on the CCR portion of the decision too? In a `with_ccr=true` UPC_INF, the R.118 final decision adjudicates both the infringement *and* the counterclaim for revocation. Either side may appeal either part. My read: **one spawn covers both** — there's only one R.118 decision, one 2-month window. The Pipeline-A `ccr.appeal` was a relic of the days when CCR was a separate proceeding type. **Recommend dropping the third "ccr.appeal" entirely**, because in the unified UPC_INF (CCR-as-flag) model it would duplicate Rule 2.1. m to confirm.
|
||||
- **FLAG (F2.2):** Anchor — should the spawn rule chain off `inf.decision` (court-set, requires anchor-click) or be event-rooted on a `final_decision_service` trigger event (paliad has trigger_event id=88 "Endentscheidung (Zustellung)")? Both work. Chaining on `inf.decision` keeps the rule visually attached to its parent proceeding in the UI; event-rooted is more flexible if the user wants to compute an appeal deadline standalone without a project. Recommend `parent_id = inf.decision` to match how `inf.cost_app` chains off `inf.decision` already.
|
||||
- **FLAG (F2.3):** Flag name — `with_appeal` mirrors the existing `with_ccr` / `with_amend` flag naming. Alternative: spawn rules might always fire (no flag), letting the timeline show the appeal window as a "predicted/court-set" placeholder. The latter is closer to what the SmartTimeline projection (`projection_service.go`) already does for cross-proceeding rules per memory `686f0b8c-02ed-4807-8785-b088e3a3e515` § 6 gap 7. If m wants the appeal window to *always* appear after the decision (unconditionally), drop `condition_expr` here and on Rule 2.2.
|
||||
|
||||
### Rule 2.2 — Appeal spawn from UPC_REV
|
||||
|
||||
- **Rule code:** `rev.appeal_spawn`
|
||||
- **Proceeding type:** UPC_REV (id=9)
|
||||
- **Name (DE):** Berufung gegen Endentscheidung (Nichtigkeit)
|
||||
- **Name (EN):** Appeal against final decision (revocation)
|
||||
- **Party:** both
|
||||
- **Anchor:** `parent_id = rev.decision` (existing court-set rule "Entscheidung")
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** `{"flag":"with_appeal"}`
|
||||
- **Legal source:** `UPC.RoP.220.1`
|
||||
- **`rule_code`:** `RoP.220.1.a`
|
||||
- **event_type:** `filing`
|
||||
- **is_spawn:** true
|
||||
- **spawn_proceeding_type_id:** → same UPC infringement-appeal proceeding as Rule 2.1. The UPC CoA hears both INF and REV appeals; in a `with_cci=true` UPC_REV (Verletzungswiderklage / counterclaim-for-infringement), the R.118 decision may also adjudicate the infringement piece, but again it's one decision, one appeal window.
|
||||
- **spawn_label (DE):** "Berufungsverfahren öffnen"
|
||||
- **spawn_label (EN):** "Open appeal proceedings"
|
||||
- **Notes:** Functionally a mirror of Rule 2.1 on the revocation proceeding. Same FLAGs F2.1-F2.3 apply.
|
||||
|
||||
### Rule 2.3 — (proposed) NOT drafted: separate `ccr.appeal` from UPC_INF with_ccr
|
||||
|
||||
**See FLAG F2.1.** In the unified model, the CCR portion of an UPC_INF decision is appealed via the same R.118 final-decision spawn (Rule 2.1) — a single 2-month window covers infringement, revocation, and patent-amendment claims because they all sit in one R.118 decision. Drafting `ccr.appeal` as a third rule would duplicate Rule 2.1 conditionally (`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_appeal"}]}`) and produce a redundant timeline row. **Recommendation: do not seed.** If m disagrees, the rule shape would be:
|
||||
|
||||
```
|
||||
inf.appeal_spawn_ccr (UPC_INF)
|
||||
condition_expr: {"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_appeal"}]}
|
||||
spawn_label: "Berufung Nichtigkeit öffnen" (specifically the CCR portion)
|
||||
```
|
||||
|
||||
Only useful if the appeal UI needs to distinguish "appealing the infringement finding" from "appealing the revocation finding". Today's fristenrechner UI doesn't make that distinction; the appeal proceeding handles both.
|
||||
|
||||
**Summary for Gap 2:** 2 new spawn rules drafted. 3 FLAGs. The third Pipeline-A relic (`ccr.appeal`) is structurally redundant and recommended **not** to seed.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gap 3 — `ccr.amend` / `rev.amend` (verification of "safe to drop" claim)
|
||||
|
||||
**Status:** No new rules needed. The migration's claim ("superseded by `inf.app_to_amend` / `rev.app_to_amend` — safe to drop") is **confirmed for the patent-amendment scope**. There is a separate concept (R.263 application to amend the case) that has never been modelled and probably shouldn't be — see § 3.2.
|
||||
|
||||
### 3.1 Verification — patent-amendment coverage
|
||||
|
||||
Pipeline-A's `ccr.amend` and `rev.amend` were both:
|
||||
|
||||
- duration_value=0, duration_unit='months', event_type='filing', is_spawn=true, party='claimant'
|
||||
- legal_source=NULL, rule_code=NULL
|
||||
- source proceeding=AMD (now archived)
|
||||
- "Application to Amend Patent" / no German name
|
||||
|
||||
These were placeholder spawns into a hypothetical "AMD" (Application to amend the patent) proceeding type that never existed as a real fristenrechner tree. They modelled the concept "filing a patent amendment", not its deadline.
|
||||
|
||||
The unified UPC_INF / UPC_REV corpus already covers patent amendment with real deadlines and flag-gated chains:
|
||||
|
||||
| Existing rule | Proceeding | Trigger / parent | Duration | Legal source | Flag-gating |
|
||||
|---|---|---|---|---|---|
|
||||
| `inf.app_to_amend` | UPC_INF | parent=inf.sod | 2 months | UPC.RoP.30.1 | `with_ccr+with_amend` |
|
||||
| `inf.def_to_amend` | UPC_INF | parent=inf.app_to_amend | 2 months | UPC.RoP.32.1 | `with_ccr+with_amend` |
|
||||
| `inf.reply_def_amd` | UPC_INF | parent=inf.def_to_amend | 1 month | UPC.RoP.32.3 | `with_ccr+with_amend` |
|
||||
| `inf.rejoin_amd` | UPC_INF | parent=inf.reply_def_amd | 1 month | UPC.RoP.32.3 | `with_ccr+with_amend` |
|
||||
| `rev.app_to_amend` | UPC_REV | parent=rev.defence | 0 months (filed-with-parent) | UPC.RoP.49.2.a | `with_amend` |
|
||||
| `rev.def_to_amend` | UPC_REV | parent=rev.app_to_amend | 2 months | UPC.RoP.43.3 | `with_amend` |
|
||||
| `rev.reply_def_amd` | UPC_REV | parent=rev.def_to_amend | 1 month | UPC.RoP.32.3 | `with_amend` |
|
||||
| `rev.rejoin_amd` | UPC_REV | parent=rev.reply_def_amd | 1 month | UPC.RoP.32.3 | `with_amend` |
|
||||
|
||||
The flag-gated chain on UPC_INF (`with_ccr+with_amend`) is the post-2026-05-05 ship from t-paliad-131 PR-2 (memory `ba1517a3-2294-4c58-aeb6-87e82067834d`); the UPC_REV chain (`with_amend` and `with_cci`) is from the same PR. Both fully replace what `ccr.amend` / `rev.amend` ever could have represented.
|
||||
|
||||
**Verdict on Gap 3:** "Safe to drop" is correct. **No new rules.**
|
||||
|
||||
### 3.2 R.263 — Application to amend the case (not modelled, probably shouldn't be)
|
||||
|
||||
R.263 ("Leave to change claim or amend case") is conceptually different from R.30 (Application to amend the patent). R.263 governs amendment of the **pleadings / case** — adding a new infringement allegation, narrowing claims, etc. The current corpus has no R.263 rule.
|
||||
|
||||
I'm **not proposing one** because R.263 is purely court-discretionary (R.263.1: "An application may be made by a party at any time to … amend its case … Leave shall be granted only if … the requesting party could not with reasonable diligence have made the application earlier and the amendment will not unreasonably hinder the other party in the conduct of its action"). There is no statutory deadline computable from a fixed anchor — the party files when it needs to, and the court grants or refuses leave by order. Modelling it as a deadline_rule would either:
|
||||
|
||||
- (a) Produce a phantom row with no computable date (the existing `is_court_set=true` pattern would technically work but offers no UX value because the deadline is "whenever you need to amend").
|
||||
- (b) Produce a misleading row anchored on the SoC date with some heuristic period.
|
||||
|
||||
**Recommendation: don't seed.** If m wants R.263 surfaced anywhere, it belongs as a checklist item on the project page, not as a fristenrechner rule.
|
||||
|
||||
**FLAG (F3.1):** Confirm "don't model R.263" is acceptable. If R.263 *should* be modelled, what anchor + duration heuristic should it use?
|
||||
|
||||
**Summary for Gap 3:** 0 new rules. 1 FLAG. The claim "safe to drop" is verified for patent amendment. R.263 is a separate concept and intentionally left unmodelled.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gap 4 — `zpo.*` family vs. existing DE_INF / DE_INF_OLG / DE_INF_BGH
|
||||
|
||||
**Status:** No new rules needed for `klage`, `vertanz`, `berufung`. **Existing rule `de_inf.erwidg` (Klageerwiderung) has a duration discrepancy worth m's attention.** Task brief's mention of "Klageerweiterung" / "Vertagungsantrag" is a misread of Pipeline-A rule names — those concepts are not in scope here. § 4.1-4.4 verify each Pipeline-A rule; § 4.5 surfaces what *would* be a real gap if m wants ZPO §227 modelled.
|
||||
|
||||
### 4.1 `zpo.klage` (Klageerhebung, ZPO §253) — ✓ redundant
|
||||
|
||||
Pipeline-A: claimant, 0 months, filing, `§ 253 ZPO`, legal_source=NULL.
|
||||
|
||||
Existing rule `de_inf.klage` on DE_INF: claimant, 0 months, filing. Functionally identical as a root rule (a 0-duration "trigger" anchor). Legal source on the existing rule is NULL — could be backfilled to `DE.ZPO.253` as a minor polish, but no new rule needed.
|
||||
|
||||
**Verdict: no gap.** *Optional polish:* set `de_inf.klage.legal_source = 'DE.ZPO.253'` (one-line UPDATE; not a new rule). FLAG F4.1.
|
||||
|
||||
### 4.2 `zpo.vertanz` (Verteidigungsanzeige, ZPO §276(1) Satz 1) — ✓ redundant
|
||||
|
||||
**Task-brief naming note:** the brief described this gap as "Vertagungsantrag" but Pipeline-A's `zpo.vertanz` is actually *Verteidigungsanzeige* (contraction "VertAnz" not "VertA. (Antrag)"). The rule name in the snapshot reads "Verteidigungsanzeige" verbatim. Vertagungsantrag (§ 227 ZPO) is a different concept entirely — see § 4.5.
|
||||
|
||||
Pipeline-A: defendant, 2 weeks, filing, `§ 276 Abs. 1 S. 1 ZPO`, deadline_notes "Notfrist ab Zustellung der Klageschrift".
|
||||
|
||||
Existing rule `de_inf.anzeige` on DE_INF: defendant, 2 weeks, `DE.ZPO.276.1`, "Anzeige der Verteidigungsbereitschaft". Same period, same legal basis, same party.
|
||||
|
||||
**Verdict: no gap.**
|
||||
|
||||
### 4.3 `zpo.klageerw` (Klageerwiderung, ZPO §276(1) Satz 2) — ⚠ duration discrepancy
|
||||
|
||||
Pipeline-A: defendant, **2 weeks**, filing, `§ 276 Abs. 1 S. 2 ZPO`, legal_source=NULL, deadline_notes "Vom Gericht gesetzt, mindestens 2 Wochen".
|
||||
|
||||
Existing rule `de_inf.erwidg` on DE_INF: defendant, **6 weeks**, `DE.ZPO.276.1`, "Klageerwiderung", is_court_set=false.
|
||||
|
||||
**This is a substantive discrepancy.** Both rules cite the same statutory anchor (ZPO §276(1) Satz 2), but:
|
||||
|
||||
- Pipeline-A modelled the **statutory floor** ("mindestens 2 Wochen") with `is_court_set` implicit (the deadline_notes said "Vom Gericht gesetzt").
|
||||
- DE_INF models a **typical court-practice heuristic** (6 weeks is a common Munich/Düsseldorf LG setting, though 4-8 weeks is the realistic range).
|
||||
|
||||
The DE_INF rule is **strictly more useful** for a practitioner planning a defence schedule (the 2-week floor is rarely the actual deadline; the court order sets the real date). But it's **technically wrong** to mark `is_court_set=false` because the date *is* set by court order — the 6 weeks is a guess at what the court will set, not a statutory period.
|
||||
|
||||
**No new rule needed**, but two corrections are worth flagging on the existing rule:
|
||||
|
||||
- **FLAG F4.2 (correctness):** Set `de_inf.erwidg.is_court_set = true`. The deadline date is set by the court's Klageerwiderungsfrist order under §276(1) Satz 2, not by the statute directly. This matches how Schriftsatznachreichung (§296a) was flagged in `docs/proposals/orphan-concepts-2026-05-15.md` § 2.1 FLAG F8.
|
||||
- **FLAG F4.3 (heuristic transparency):** 6 weeks vs. the statutory 2-week floor — the deadline_notes (DE) on `de_inf.erwidg` should probably say "Vom Gericht gesetzt, mindestens 2 Wochen (§ 276 Abs. 1 S. 2 ZPO); typische Praxis: 4-8 Wochen" rather than just rendering as a hard 6-week deadline. UX consideration, not a rule-shape question.
|
||||
|
||||
Neither change is a new rule; both are PATCH operations on the existing row via `/admin/rules`.
|
||||
|
||||
### 4.4 `zpo.berufung` (Berufung, ZPO §517) — ✓ redundant (twice over)
|
||||
|
||||
Pipeline-A: both, 1 month, filing, `§ 517 ZPO`, `DE.ZPO.517`, deadline_notes "Notfrist ab Zustellung des vollständigen Urteils".
|
||||
|
||||
Existing rules:
|
||||
|
||||
- `de_inf.berufung` on DE_INF: both, 1 month, `DE.ZPO.517`. Same shape.
|
||||
- `de_inf_olg.berufung` on DE_INF_OLG: both, 1 month, `DE.ZPO.517`. Same shape (covers the OLG-instance entry point).
|
||||
|
||||
Either rule covers it. **Verdict: no gap.**
|
||||
|
||||
### 4.5 Real gap (if m wants): Vertagungsantrag (ZPO §227)
|
||||
|
||||
The task brief mentioned "Vertagungsantrag" by name. Pipeline-A had no Vertagungsantrag rule (the `zpo.vertanz` rule code is a contraction of *Verteidigungsanzeige*, not Vertagungsantrag — see § 4.2). The current corpus has no Vertagungsantrag rule either.
|
||||
|
||||
ZPO §227 governs applications to adjourn a hearing ("Aufhebung und Verlegung von Terminen, Vertagung der Verhandlung"). §227.1 requires "erhebliche Gründe", §227.2 gives examples (verhinderter Anwalt etc.), §227.3 restricts adjournment of evidence hearings (Beweisaufnahme). **There is no statutory deadline for filing a Vertagungsantrag** — it's "as soon as the ground arises and, in practice, as early as possible before the hearing date". The application is court-discretionary (§227.1: "kann").
|
||||
|
||||
I would **not** recommend modelling Vertagungsantrag as a deadline_rule for the same reason as R.263 in § 3.2: there's no statutory deadline anchor; it's a checklist concept, not a calendar deadline. But m may have a different view — flag F4.4.
|
||||
|
||||
**FLAG (F4.4):** Should Vertagungsantrag be modelled? If yes, what anchor + duration? Most natural seed would be `condition_expr={"flag":"with_vertagung"}` on the relevant hearing rule (de_inf.termin, de_null.termin, etc.), is_court_set=true, no duration. But that's an oddly-shaped rule that produces no useful date.
|
||||
|
||||
**Summary for Gap 4:** 0 new rules. 4 FLAGs (F4.1-F4.4). The migration's "redundant — safe to drop" claim is confirmed for `klage` / `vertanz` / `berufung`. `klageerw` exposes a discrepancy on the existing `de_inf.erwidg` rule (`is_court_set=false` is wrong; 6-weeks heuristic should be transparent in notes) — both are PATCH operations on the existing row, not new rules. Vertagungsantrag is a separate concept that probably shouldn't be modelled as a deadline_rule.
|
||||
|
||||
---
|
||||
|
||||
## 5. Track A — Polish UPDATEs on existing rows (no new rules, no legal review)
|
||||
|
||||
Distinct from new rules, three existing rows could be PATCH'd via `/admin/rules` to improve correctness or transparency. **None of these are required for the gap-fill to be considered "done"** — they're flagged so they don't get lost if m wants to address them in the same ingest session.
|
||||
|
||||
| # | Row | Field | From | To | Reason |
|
||||
|---|---|---|---|---|---|
|
||||
| P1 | `de_inf.klage` (DE_INF) | `legal_source` | NULL | `DE.ZPO.253` | Polish; matches existing convention (Rule 1.1's `UPC.RoP.19.1` etc.). |
|
||||
| P2 | `de_inf.erwidg` (DE_INF) | `is_court_set` | false | true | Correctness; deadline is court-order-set per ZPO §276(1) Satz 2. |
|
||||
| P3 | `de_inf.erwidg` (DE_INF) | `deadline_notes` (DE) | (current text) | "Vom Gericht gesetzt, mindestens 2 Wochen (§ 276 Abs. 1 S. 2 ZPO); typische Praxis: 4-8 Wochen" | Transparency; the 6-week duration is a heuristic, not statutory. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Track B — Genuinely new rule drafts (this proposal's substantive output)
|
||||
|
||||
| # | Gap | Rule code | Proceeding (by role) | Source |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 | 1 (PO) | `inf.prelim` | UPC_INF | RoP 19.1 |
|
||||
| 1.2 | 1 (PO) | `rev.prelim` | UPC_REV | RoP 19.1 i.V.m. R.46 |
|
||||
| 2.1 | 2 (APP spawn) | `inf.appeal_spawn` | UPC_INF, spawn → UPC infringement-appeal proceeding | RoP 220.1(a) / R.224.1(a) |
|
||||
| 2.2 | 2 (APP spawn) | `rev.appeal_spawn` | UPC_REV, spawn → UPC infringement-appeal proceeding | RoP 220.1(a) / R.224.1(a) |
|
||||
|
||||
**Total new rules: 4.** Plus 3 optional polish PATCHes in § 5. None of the proposed rules introduce new flag-name conventions (other than `with_po` and `with_appeal`, which mirror existing `with_ccr` / `with_amend` / `with_cci`).
|
||||
|
||||
### Future-work (not this proposal)
|
||||
|
||||
- Order-appeals spawn (R.220.2 / R.220.3) from UPC_INF / UPC_REV / UPC_PI → UPC_APP_ORDERS (15-day track). Today UPC_APP_ORDERS has only standalone root rules.
|
||||
- Cost-decision-appeal spawn (R.221.1) from UPC_INF / UPC_REV → UPC_COST_APPEAL.
|
||||
- CCR-defendant PO (FLAG F1.4): claimant's 1-month PO window when receiving SoD-with-CCR — only if confirmed against real case law or m's read.
|
||||
- R.263 (case amendment) and ZPO §227 (Vertagungsantrag): both court-discretionary, no statutory deadline — recommend leaving unmodelled (FLAGs F3.1, F4.4).
|
||||
- DE_NULL / DE_NULL_BGH appeal spawns: PatG §110 chains DE_NULL → DE_NULL_BGH (Berufung BGH). Currently DE_NULL_BGH is a standalone tree rooted on `de_null_bgh.urteil_bpatg`. Same pattern as the UPC spawn gap. Out of brief scope but worth a parallel proposal.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions / FLAGs index
|
||||
|
||||
For convenience, all `**FLAG**`-marked items in one place. m's decision is needed on each before `/admin/rules` ingest of the corresponding rule (or rule edit).
|
||||
|
||||
| ID | Section | Question |
|
||||
|---|---|---|
|
||||
| F1.1 | § 1.1 | Flag name for Preliminary Objection — `with_po` vs `with_preliminary_objection` vs `prelim`. |
|
||||
| F1.2 | § 1.1 | Priority for PO — `optional` (recommended) vs `recommended` (always-surface as sanity-check chip). |
|
||||
| F1.3 | § 1.2 | Legal-source citation for UPC_REV PO — `UPC.RoP.19.1` (substantive) vs `UPC.RoP.46` (operative). Recommend substantive. |
|
||||
| F1.4 | § 1.2 | Add a third PO rule for CCR-defendant (party=claimant, fires when `with_ccr=true`)? |
|
||||
| F2.1 | § 2.1 | Recommend **not seeding** `ccr.appeal` as a third rule — CCR appeal is covered by `inf.appeal_spawn` (one R.118 decision, one window). Confirm. |
|
||||
| F2.2 | § 2.1 | Anchor for spawn — `parent_id = inf.decision` (chain) vs `trigger_event_id = 88 final_decision_service` (event-rooted). Recommend chain. |
|
||||
| F2.3 | § 2.1 | Flag-gated (`with_appeal`) vs always-rendered. Recommend flag-gated to keep non-appealing timelines clean; SmartTimeline's "predicted" rendering of cross-proceeding rules is the alternative. |
|
||||
| F3.1 | § 3.2 | R.263 (case amendment) — confirm not modelled as a deadline_rule. |
|
||||
| F4.1 | § 4.1 | Polish P1: backfill `de_inf.klage.legal_source = 'DE.ZPO.253'`? |
|
||||
| F4.2 | § 4.3 | Polish P2: set `de_inf.erwidg.is_court_set = true`? |
|
||||
| F4.3 | § 4.3 | Polish P3: improve `de_inf.erwidg.deadline_notes` to expose the 6-week heuristic vs the 2-week statutory floor? |
|
||||
| F4.4 | § 4.5 | Vertagungsantrag (ZPO §227) — confirm not modelled. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Sources cited
|
||||
|
||||
| Citation key | Reference |
|
||||
|---|---|
|
||||
| `UPC.RoP.19.1` | UPC Rules of Procedure, Rule 19(1) — Preliminary objection |
|
||||
| `UPC.RoP.19.7` | UPC RoP Rule 19(7) — Court decides preliminary objection by order |
|
||||
| `UPC.RoP.25` | UPC RoP Rule 25 — Lodging of Counterclaim for Revocation (cross-ref for FLAG F1.4) |
|
||||
| `UPC.RoP.30.1` | UPC RoP Rule 30(1) — Application to amend the patent (cross-ref for § 3.1) |
|
||||
| `UPC.RoP.46` | UPC RoP Rule 46 — Part 1 Chapter 1 (incl. R.19) applies *mutatis mutandis* to revocation actions |
|
||||
| `UPC.RoP.118` | UPC RoP Rule 118 — Final decisions on the merits |
|
||||
| `UPC.RoP.151` | UPC RoP Rule 151 — Cost decision (cross-ref for existing `inf.cost_app`) |
|
||||
| `UPC.RoP.220.1.a` | UPC RoP Rule 220(1)(a) — Appeal against R.118 final decision |
|
||||
| `UPC.RoP.220.2` | UPC RoP Rule 220(2) — Order appeals with leave (cross-ref, future work) |
|
||||
| `UPC.RoP.220.3` | UPC RoP Rule 220(3) — Discretionary review (cross-ref, future work) |
|
||||
| `UPC.RoP.221.1` | UPC RoP Rule 221(1) — Cost-decision appeal (cross-ref, future work) |
|
||||
| `UPC.RoP.224.1.a` | UPC RoP Rule 224(1)(a) — Statement of appeal lodged within 2 months |
|
||||
| `UPC.RoP.224.2.a` | UPC RoP Rule 224(2)(a) — Statement of grounds within 4 months |
|
||||
| `UPC.RoP.263` | UPC RoP Rule 263 — Leave to change claim or amend case |
|
||||
| `DE.ZPO.227` | ZPO §227 — Vertagung und Terminsänderung |
|
||||
| `DE.ZPO.253` | ZPO §253 — Klageschrift |
|
||||
| `DE.ZPO.276.1` | ZPO §276(1) — Verteidigungsanzeige (S.1) und Klageerwiderungsfrist (S.2) |
|
||||
| `DE.ZPO.517` | ZPO §517 — Berufungsfrist (1 Monat ab Zustellung) |
|
||||
|
||||
---
|
||||
|
||||
## 9. What's next (if m approves)
|
||||
|
||||
1. **Decide the 12 FLAGs in § 7** (mostly flag names, priorities, and the three PATCH operations on existing rows). None require legal-side research — they're product/UX calls.
|
||||
2. **Confirm the appeal target's final proceeding-code** post-t-paliad-204 rename. Until then, ingest using whatever code lives at id=11 (currently `UPC_APP`) and rename via mig if t-paliad-204 lands with a different code.
|
||||
3. **Ingest the 4 new rules** via `/admin/rules` POST (Slice 11a backend, Slice 11b frontend). Each goes into `lifecycle_state='draft'` first. Promote to `published` after spot-checking via the calculator preview endpoint with a test project (e.g. UPC_INF with `with_po=true` should show the new `inf.prelim` row 1 month after the trigger date).
|
||||
4. **Optionally apply the 3 PATCHes in § 5** in the same session.
|
||||
5. **Verify spawn rendering** end-to-end — the spawn_proceeding_type_id column is unused in live data today, so this is the first real consumer. The SmartTimeline projection (per `internal/services/projection_service.go`, memory `686f0b8c-…`) early-returns on spawn rules when "we don't have that rule in our map" — that code path needs to actually render a spawn row now, not no-op. May require a Slice 7 follow-up tweak in `projection_service.go` to honour `spawn_proceeding_type_id` and surface the appeal proceeding's root deadline as a spawned child row.
|
||||
|
||||
**Estimated corpus delta after ingest:** Track B = 4 new rules → `paliad.deadline_rules` row count grows from 249 to **253**. Track A polish = 3 row-level PATCHes (no row count change). One new `is_spawn=true` row goes live for the first time, exercising the previously-unused `spawn_proceeding_type_id` wiring.
|
||||
429
docs/proposals/legal-citation-backfill-2026-05-18.md
Normal file
429
docs/proposals/legal-citation-backfill-2026-05-18.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# Legal-citation Backfill Proposals — t-paliad-208 (Workstream A)
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Author:** huygens (researcher)
|
||||
**Status:** DRAFT — for m's review, not yet migrated
|
||||
**Branch:** `mai/huygens/workstream-a-backfill`
|
||||
**Adjacent:** parallel-track with t-paliad-209 (workstream B — `code` rename + UI cleanup; different fields, no overlap)
|
||||
**Successor:** mig 097 will UPDATE the rows m approves; backup snapshot `deadline_rules_pre_097`
|
||||
|
||||
---
|
||||
|
||||
## 0. Read-this-first
|
||||
|
||||
### 0.1 What this doc is
|
||||
|
||||
Today's audit (paliadin/head, 2026-05-18) found that **130 of 213 active+published rows in `paliad.deadline_rules`** have `rule_code IS NULL`, and 122 have `legal_source IS NULL`. The internal slug field `code` (e.g. `inf.sod`, `de_null.berufung`) had been mistaken for a legal citation; it is just the per-proceeding submission identifier. The actual RoP / ZPO / EPÜ / PatG / UPCA citation belongs in `rule_code` (display form) + `legal_source` (structured locator).
|
||||
|
||||
This document proposes a citation per rule. m approves; head re-tasks for migration 097.
|
||||
|
||||
### 0.2 Field convention (profiled from the 83 already-populated rows)
|
||||
|
||||
| Field | Purpose | Examples from live data |
|
||||
|---|---|---|
|
||||
| `rule_code` | **Human display form**, what we'd write in a brief | `§ 276 ZPO`, `§ 110 PatG`, `Art. 99 EPÜ`, `R. 71(3) EPÜ`, `R. 116 EPÜ`, `RPBA Art. 12`, `RoP.029.a`, `RoP.220.1.a`, `RoP.151`, `RoP.49.1` |
|
||||
| `legal_source` | **Structured locator** (forum-prefixed, no zero padding) for cross-system joins / lex extraction | `DE.ZPO.276.1`, `DE.PatG.111.1`, `EU.EPÜ.108`, `EU.EPC-R.71.3`, `EU.RPBA.12.1.c`, `UPC.RoP.29.a`, `UPC.RoP.220.1` |
|
||||
|
||||
**Sub-conventions observed in live data**
|
||||
|
||||
- `legal_source` prefixes: `DE.<statute>.<n>.<para>`, `EU.EPÜ.<n>.<para>`, `EU.EPC-R.<n>.<para>`, `EU.RPBA.<n>.<para>.<letter>`, `UPC.RoP.<n>.<sub>`.
|
||||
- `rule_code` padding for UPC RoP is **inconsistent today**: rules below 100 are mostly 3-digit padded (`RoP.029.a`, `RoP.030.1`, `RoP.049.2.a`, `RoP.056.1`) but `rev.defence` carries an un-padded `RoP.49.1`. Rules ≥100 are never padded (`RoP.137.2`, `RoP.220.1`).
|
||||
- **Proposed normalization:** 3-digit pad for rules <100, no pad for ≥100. mig 097 should also normalize `RoP.49.1 → RoP.049.1` (1 outlier row, `rev.defence`) as a side-fix. m to confirm.
|
||||
- `legal_source` for UPC RoP **never** pads (`UPC.RoP.29.a`, not `UPC.RoP.029.a`). I follow that.
|
||||
|
||||
### 0.3 Triage philosophy — events vs. deadlines
|
||||
|
||||
Of the 130 NULL-rule_code rows, 53 carry a `proceeding_type_id` and 77 are orphans (`proceeding_type_id IS NULL`, also `code IS NULL`). Within the proceeding-typed bucket, most are **event markers** (zero `duration_value`, `event_type ∈ {hearing, decision, filing}`) that anchor other deadlines rather than computing one of their own.
|
||||
|
||||
I classify each row as one of:
|
||||
|
||||
| Category | Treatment | Examples |
|
||||
|---|---|---|
|
||||
| **Deadline** (positive duration, fires off an anchor) | Cite the operative procedural norm. Confidence usually HIGH. | `inf.sod` Klageerwiderung 3 months → RoP.23 |
|
||||
| **Constitutive event** (zero duration, but a statute defines it) | Cite the constitutive norm (matches existing convention: `de_inf.klage` already has `DE.ZPO.253`). Confidence HIGH where the norm is canonical. | Klageerhebung → § 253 ZPO; Anmeldung EP → Art. 75 EPÜ; Klage UPC → RoP.13.1 |
|
||||
| **Service / trigger event** (zero duration, third-party delivery) | Cite the service norm (§ 317 ZPO etc.) with MEDIUM confidence — these are anchor events for downstream timers, not deadlines on a party. m may prefer NULL here. **FLAG.** | `de_inf_olg.urteil_lg` Zustellung LG-Urteil |
|
||||
| **Court-scheduled event** (hearing, judgment-issuance) | Either NULL (recommended) or cite the general norm authorising the court to schedule. **FLAG.** | Mündliche Verhandlung BGH; OLG-Urteil |
|
||||
| **Court-set duration** (positive duration but `is_court_set=true`, or local practice) | Cite the framing norm (e.g. § 273 ZPO for ZPO patent practice), MEDIUM, FLAG. | `de_inf.replik` 4 weeks (LG patent practice) |
|
||||
|
||||
**Where I am proposing NULL**, the row stays as-is on the DB side (mig 097 simply doesn't touch it). The FLAG list at the bottom of this doc enumerates every NULL proposal so m can override with an explicit citation if desired.
|
||||
|
||||
### 0.4 Counts
|
||||
|
||||
- 130 rows in scope (rule_code IS NULL; is_active=true; lifecycle_state='published')
|
||||
- 53 proceeding-typed + 77 orphan (no proceeding_type_id, no code)
|
||||
- 8 rows already carry a `legal_source` — those are **easy wins**: only `rule_code` needs proposing
|
||||
- ~ 40 HIGH-confidence proposals
|
||||
- ~ 35 MEDIUM-confidence proposals
|
||||
- ~ 55 FLAG entries (court-scheduled events, combined-pleading rows, ambiguous orphans)
|
||||
|
||||
The orphan bucket carries a noticeable number of **duplicates** (six "Mängelbeseitigung / Zahlung" rows, two "Beginn des Hauptsacheverfahrens", two "Antrag auf Patentänderung", etc.). Those are likely vestiges of older Fristenrechner pipelines; backfilling them with the same citation is fine, but m may want a separate dedup pass (out of scope here; flag in § 4).
|
||||
|
||||
---
|
||||
|
||||
## 1. Easy wins — rows with `legal_source` already set, `rule_code` missing (8)
|
||||
|
||||
For these, the structured locator is already in the DB; only the display form is missing.
|
||||
|
||||
| id | code / name | duration | existing `legal_source` | proposed `rule_code` | conf |
|
||||
|---|---|---|---|---|---|
|
||||
| `1f532c82…` | `de_inf.klage` / Klageerhebung | event | `DE.ZPO.253` | `§ 253 ZPO` | HIGH |
|
||||
| `20254f4e…` | (orphan) Einspruch gegen Versäumnisurteil | 2 weeks | `DE.ZPO.339.1` | `§ 339 ZPO` | HIGH |
|
||||
| `3c36f149…` | (orphan) Schriftsatznachreichung (§ 296a ZPO) | 3 weeks | `DE.ZPO.296a` | `§ 296a ZPO` | HIGH |
|
||||
| `f1099cf6…` | (orphan) Weiterbehandlungsantrag (Art. 121 EPÜ) | 2 months | `EU.EPC-R.135.1` | `R. 135 EPÜ` | HIGH |
|
||||
| `c24d494c…` | (orphan) Wiedereinsetzungsantrag (§ 123 PatG) | 2 months | `DE.PatG.123.2` | `§ 123 PatG` | HIGH |
|
||||
| `d40d9be7…` | (orphan) Wiedereinsetzungsantrag (§ 233 ZPO) | 2 weeks | `DE.ZPO.234.1` | `§ 234 ZPO` | HIGH |
|
||||
| `23c6f445…` | (orphan) Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 months | `EU.EPC-R.136.1` | `R. 136 EPÜ` | HIGH |
|
||||
| `b588fa64…` | (orphan) Wiedereinsetzungsantrag (DPMA) | 2 months | `DE.PatG.123.2` | `§ 123 PatG` | HIGH |
|
||||
|
||||
**Naming note on the two Wiedereinsetzung-`§ 123 PatG` rows.** Both `c24d494c…` ("§ 123 PatG" name) and `b588fa64…` ("DPMA" name) map to the same statute — § 123 PatG (Wiedereinsetzung) applies to all DPMA-Verfahren, so the duplication is a pure naming choice. mig 097 fills both; potential dedup is a separate question (§ 4 FLAG-A).
|
||||
|
||||
---
|
||||
|
||||
## 2. Proceeding-typed rows (53)
|
||||
|
||||
Grouped by `proceeding_types.code`. Within each group: alphabetical by `code`.
|
||||
|
||||
### 2.1 `upc.inf.cfi` — Verletzungsverfahren CFI (4 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `inf.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | RoP.118 — but this is the court's own decision, not a party deadline | **FLAG-B** |
|
||||
| `inf.interim` | Zwischenverfahren | event | hearing | *(NULL)* | *(NULL)* | RoP.101 ff. governs interim procedure; not a single norm | **FLAG-B** |
|
||||
| `inf.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | RoP.111-117 (oral procedure); court-scheduled | **FLAG-B** |
|
||||
| `inf.soc` | Klageerhebung (Statement of claim) | event | filing | `RoP.013.1` | `UPC.RoP.13.1` | RoP.13 — Statement of claim contents | HIGH |
|
||||
|
||||
### 2.2 `upc.rev.cfi` — Nichtigkeitsverfahren CFI (6 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `rev.app` | Nichtigkeitsklage | event | filing | `RoP.042` | `UPC.RoP.42` | RoP.42 — Statement for revocation | HIGH |
|
||||
| `rev.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | court-issued, not a party deadline | **FLAG-B** |
|
||||
| `rev.interim` | Zwischenverfahren | event | hearing | *(NULL)* | *(NULL)* | not a single norm | **FLAG-B** |
|
||||
| `rev.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | court-scheduled | **FLAG-B** |
|
||||
| `rev.reply` | Replik | 2 months | filing | `RoP.052` | `UPC.RoP.52` | RoP.52 — Reply to defence in revocation | MED (**FLAG-C**: duration vs. norm) |
|
||||
| `rev.rejoin` | Duplik | 2 months | filing | `RoP.052` | `UPC.RoP.52` | RoP.52 — Rejoinder | MED (**FLAG-C**: duration vs. norm) |
|
||||
|
||||
**FLAG-C:** RoP.52(1) sets the reply to 2 months but RoP.52(2) sets the rejoinder to 1 month from service of the reply. m's `rev.rejoin` says 2 months — verify whether the rule duration is correct or whether `RoP.52.2` (1 month) is the right citation. Cross-check with the existing `rev.rejoin_cci` row which uses RoP.056.4 (cci context); the main-pleadings rejoinder lives in RoP.52.
|
||||
|
||||
### 2.3 `upc.pi.cfi` — Einstweilige Maßnahmen (4 rules)
|
||||
|
||||
All four rules are currently NULL on both fields.
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `pi.app` | Antrag | event | filing | `RoP.206` | `UPC.RoP.206` | RoP.206 — Application for provisional measures | HIGH |
|
||||
| `pi.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | RoP.209 — at judge's discretion | **FLAG-B** |
|
||||
| `pi.order` | Beschluss | event | decision | *(NULL)* | *(NULL)* | RoP.211 — court-issued | **FLAG-B** |
|
||||
| `pi.response` | Erwiderung | event | filing | *(NULL)* | *(NULL)* | RoP.209.1 — judge sets time; no statutory period | **FLAG-B** (alt: `RoP.209.1` / `UPC.RoP.209.1` to flag as court-set) |
|
||||
|
||||
### 2.4 `upc.apl.merits` — Berufungsverfahren Merits (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `app.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | RoP.350 — appellate decision | **FLAG-B** |
|
||||
| `app.oral` | Mündliche Verhandlung | event | hearing | `RoP.243` | `UPC.RoP.243` | RoP.243 — oral procedure in appeal | MED |
|
||||
| `app.response` | Berufungserwiderung | 2 months | filing | `RoP.235.1` | `UPC.RoP.235.1` | RoP.235.1 — Statement of response | MED (**FLAG-C**: RoP.235.1 says 3 months for main-judgment appeals; 2 months may be a residual from a different appeal track. Verify duration vs. norm.) |
|
||||
|
||||
### 2.5 `upc.apl.order` — Berufungsverfahren Anordnungen (1 rule)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `app_ord.order` | Anordnung / angegriffene Entscheidung | event | decision | *(NULL)* | *(NULL)* | trigger event for orders-appeal; RoP.220.1.c references it | **FLAG-B** (alt: `RoP.220.1.c` to surface) |
|
||||
|
||||
### 2.6 `upc.apl.cost` — Berufungsverfahren Kosten (1 rule)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `cost.decision` | Kostenfestsetzungsbeschluss | event | decision | *(NULL)* | *(NULL)* | RoP.150 ff. — cost decision in the assessment proceedings | **FLAG-B** |
|
||||
|
||||
### 2.7 `upc.dmgs.cfi` — Schadensbemessungsverfahren (1 rule)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `damages.app` | Antrag auf Schadensbemessung | event | filing | `RoP.131` | `UPC.RoP.131` | RoP.131 — Application for damages determination | HIGH |
|
||||
|
||||
### 2.8 `upc.disc.cfi` — Bucheinsichtsverfahren (1 rule)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `disc.app` | Antrag auf Bucheinsicht | event | filing | `RoP.141` | `UPC.RoP.141` | RoP.141 — Application for order to lay open books | HIGH |
|
||||
|
||||
### 2.9 `de.inf.lg` — Verletzungsverfahren LG (5 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `de_inf.klage` | Klageerhebung | event | filing | `§ 253 ZPO` | `DE.ZPO.253` *(already set)* | § 253 ZPO — Klageschrift | HIGH (rule_code only) |
|
||||
| `de_inf.replik` | Replik | 4 weeks | filing | `§ 273 ZPO` | `DE.ZPO.273` | § 273 ZPO — vorbereitende Anordnungen / court-set period (Düsseldorfer Praxis) | MED (**FLAG-D**: 4 weeks is local LG practice, no statutory period; flag `is_court_set=true` already true in DB) |
|
||||
| `de_inf.duplik` | Duplik | 4 weeks | filing | `§ 273 ZPO` | `DE.ZPO.273` | same | MED (**FLAG-D**) |
|
||||
| `de_inf.termin` | Haupttermin | event | hearing | *(NULL)* | *(NULL)* | § 272 / § 137 ZPO — court-scheduled | **FLAG-B** |
|
||||
| `de_inf.urteil` | Urteil | event | decision | *(NULL)* | *(NULL)* | § 300 ZPO — court-issued | **FLAG-B** |
|
||||
|
||||
### 2.10 `de.inf.olg` — Berufungsverfahren OLG Verletzung (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `de_inf_olg.urteil_lg` | Zustellung LG-Urteil | event | filing (trigger) | `§ 317 ZPO` | `DE.ZPO.317` | § 317 ZPO — Zustellung von Urteilen | MED (**FLAG-E**: service-trigger event — may be NULL per philosophy) |
|
||||
| `de_inf_olg.termin` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | court-scheduled | **FLAG-B** |
|
||||
| `de_inf_olg.urteil_olg` | OLG-Urteil | event | decision | *(NULL)* | *(NULL)* | court-issued | **FLAG-B** |
|
||||
|
||||
### 2.11 `de.inf.bgh` — Revision/NZB BGH Verletzung (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `de_inf_bgh.urteil_olg` | Zustellung OLG-Urteil | event | filing (trigger) | `§ 317 ZPO` | `DE.ZPO.317` | § 317 ZPO — Zustellung | MED (**FLAG-E**) |
|
||||
| `de_inf_bgh.termin` | Mündliche Verhandlung BGH | event | hearing | *(NULL)* | *(NULL)* | § 555 i.V.m. § 137 ZPO — court-scheduled | **FLAG-B** |
|
||||
| `de_inf_bgh.urteil_bgh` | BGH-Urteil | event | decision | *(NULL)* | *(NULL)* | § 562, § 563 ZPO — court-issued | **FLAG-B** |
|
||||
|
||||
### 2.12 `de.null.bpatg` — Nichtigkeitsverfahren BPatG (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `de_null.klage` | Nichtigkeitsklage | event | filing | `§ 81 PatG` | `DE.PatG.81.1` | § 81 PatG — Nichtigkeitsklage einreichen | HIGH |
|
||||
| `de_null.termin` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | § 89 PatG | **FLAG-B** |
|
||||
| `de_null.urteil` | Urteil | event | decision | *(NULL)* | *(NULL)* | § 84 PatG | **FLAG-B** |
|
||||
|
||||
### 2.13 `de.null.bgh` — Berufung BGH Nichtigkeit (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `de_null_bgh.urteil_bpatg` | Zustellung BPatG-Urteil | event | filing (trigger) | `§ 99 PatG` | `DE.PatG.99.1` | § 99 PatG verweist auf ZPO; Zustellung der BPatG-Urteile | MED (**FLAG-E**) |
|
||||
| `de_null_bgh.termin` | Mündliche Verhandlung BGH | event | hearing | *(NULL)* | *(NULL)* | § 113 PatG i.V.m. ZPO | **FLAG-B** |
|
||||
| `de_null_bgh.urteil_bgh` | BGH-Urteil | event | decision | *(NULL)* | *(NULL)* | § 119 PatG | **FLAG-B** |
|
||||
|
||||
### 2.14 `dpma.opp.dpma` — Einspruchsverfahren DPMA (2 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `dpma_opp.publish` | Veröffentlichung der Erteilung | event | filing (trigger) | `§ 58 PatG` | `DE.PatG.58.1` | § 58(1) PatG — Veröffentlichung der Erteilung im Patentblatt | HIGH |
|
||||
| `dpma_opp.entscheidung` | DPMA-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 47 PatG ff. | **FLAG-B** |
|
||||
|
||||
### 2.15 `dpma.appeal.bpatg` — Beschwerdeverfahren BPatG vs. DPMA (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `dpma_bpatg.entscheidung` | Zustellung DPMA-Entscheidung | event | filing (trigger) | `§ 47 PatG` | `DE.PatG.47.1` | § 47 PatG — Zustellung der Entscheidung im DPMA-Verfahren | MED (**FLAG-E**: trigger-event citation. Alternative `§ 127 PatG` for service procedure.) |
|
||||
| `dpma_bpatg.entsch_bpatg` | BPatG-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 79 PatG | **FLAG-B** |
|
||||
| `dpma_bpatg.termin` | Mündliche Verhandlung BPatG | event | hearing | *(NULL)* | *(NULL)* | § 78 PatG | **FLAG-B** |
|
||||
|
||||
### 2.16 `dpma.appeal.bgh` — Rechtsbeschwerdeverfahren BGH (2 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `dpma_bgh.entsch_bpatg` | Zustellung BPatG-Entscheidung | event | filing (trigger) | `§ 79 PatG` | `DE.PatG.79.1` | § 79 PatG — Zustellung der BPatG-Entscheidung | MED (**FLAG-E**) |
|
||||
| `dpma_bgh.entsch_bgh` | BGH-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 107 PatG | **FLAG-B** |
|
||||
|
||||
### 2.17 `epa.grant.exa` — EP-Erteilungsverfahren (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `ep_grant.filing` | Anmeldung | event | filing | `Art. 75 EPÜ` | `EU.EPÜ.75` | Art. 75 EPÜ — Filing of European patent application | HIGH |
|
||||
| `ep_grant.search` | Recherchenbericht | 6 months | decision | `Art. 92 EPÜ` | `EU.EPÜ.92` | Art. 92 EPÜ — Drawing up of the European search report | MED (the 6-month figure is a Richtwert per `deadline_notes` — not a statutory deadline. Could also cite `R. 65 EPÜ` if we want the issuance procedure.) |
|
||||
| `ep_grant.grant` | Erteilung (B1) | event | decision | `Art. 97 EPÜ` | `EU.EPÜ.97.1` | Art. 97(1) EPÜ — Decision to grant | HIGH |
|
||||
|
||||
### 2.18 `epa.opp.opd` — Einspruchsverfahren EPA (2 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `epa_opp.grant` | Veröffentlichung der Erteilung | event | filing (trigger) | `Art. 97 EPÜ` | `EU.EPÜ.97.3` | Art. 97(3) EPÜ — mention of grant; trigger for the 9-month Einspruchsfrist (Art. 99(1) EPÜ) | HIGH |
|
||||
| `epa_opp.entsch` | Entscheidung | event | decision | `Art. 101 EPÜ` | `EU.EPÜ.101` | Art. 101 EPÜ — Decision on opposition | HIGH |
|
||||
|
||||
### 2.19 `epa.opp.boa` — Beschwerdeverfahren BoA (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `epa_app.entsch` | Zustellung der Beschwerdeentscheidung | event | filing (trigger) | `R. 111 EPÜ` | `EU.EPC-R.111` | R. 111 EPÜ — Form and notification of decisions | MED (**FLAG-E**: service-trigger citation. Could also cite `Art. 119 EPÜ` for notification.) |
|
||||
| `epa_app.oral` | Mündliche Verhandlung | event | hearing | `Art. 116 EPÜ` | `EU.EPÜ.116` | Art. 116 EPÜ — Oral proceedings | HIGH |
|
||||
| `epa_app.entsch2` | Entscheidung | event | decision | `Art. 111 EPÜ` | `EU.EPÜ.111` | Art. 111 EPÜ — Decision in respect of appeals | HIGH |
|
||||
|
||||
---
|
||||
|
||||
## 3. Orphan rows — `proceeding_type_id IS NULL` and `code IS NULL` (77)
|
||||
|
||||
Identified by `id` (UUID first 8 chars) + name. These are the older Fristenrechner catalogue rows that pre-date the proceeding-typed slice and were never re-anchored to a proceeding. Many are 1:1 duplicates of rules that now live in proceeding-typed form.
|
||||
|
||||
### 3.1 UPC RoP — main-pleadings track (15)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `e34097d6…` | Klageerwiderung | 3 mo | `RoP.023` | `UPC.RoP.23.1` | RoP.23.1 — Statement of defence | HIGH | dup of `inf.sod` |
|
||||
| `7d8a4804…` | Nichtigkeitswiderklage | 3 mo | `RoP.025.1` | `UPC.RoP.25.1` | RoP.25.1 — Counterclaim for revocation | HIGH | — |
|
||||
| `c7523e6b…` | Verletzungswiderklage | 2 mo | `RoP.049.2.b` | `UPC.RoP.49.2.b` | RoP.49.2.b — Counterclaim for infringement in revocation | HIGH | dup of `rev.cc_inf` |
|
||||
| `c57f62f8…` | Vorgängige Einrede | 1 mo | `RoP.019.1` | `UPC.RoP.19.1` | RoP.19.1 — Preliminary objection | HIGH | dup of `inf.prelim` / `rev.prelim` |
|
||||
| `cec1a865…` | Erwiderung Nichtigkeitswiderklage **+** Replik Klageerwiderung | 2 mo | `RoP.029.a` | `UPC.RoP.29.a` | RoP.29.a / .b — combined Defence-to-CCR + Reply to SoD | HIGH (**FLAG-F**: combined-pleading orphan — m to confirm one citation is sufficient or whether row should be split) |
|
||||
| `84b390e0…` | Replik auf die Klageerwiderung | 2 mo | `RoP.029.b` | `UPC.RoP.29.b` | RoP.29.b — Reply to defence | HIGH | dup of `inf.reply` |
|
||||
| `176cc1ca…` | Duplik zur Replik auf die Klageerwiderung | 1 mo | `RoP.029.c` | `UPC.RoP.29.c` | RoP.29.c — Rejoinder | HIGH | dup of `inf.rejoin` |
|
||||
| `02ae9c1f…` | Duplik zur Replik, Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.029.c` | `UPC.RoP.29.c` | combined: RoP.29.c + RoP.32.3 | MED (**FLAG-F**) |
|
||||
| `ec2a1274…` | Replik auf Erwiderung Widerklage, Duplik Replik Klageerwiderung, Erwiderung Patentänderungsantrag | 2 mo | `RoP.029.d` | `UPC.RoP.29.d` | combined: RoP.29.d + RoP.29.c + RoP.32.1 | MED (**FLAG-F**: three-norm combined row) |
|
||||
| `a32dcec1…` | Erwiderung auf die Nichtigkeitsklage | 2 mo | `RoP.049.1` | `UPC.RoP.49.1` | RoP.49.1 — Defence to revocation | HIGH | dup of `rev.defence` |
|
||||
| `37bd034b…` | Replik Erwiderung Nichtigkeitsklage + Erwiderung Patentänderungsantrag + Erwiderung Verletzungswiderklage | 2 mo | `RoP.051` | `UPC.RoP.51` | combined: RoP.51 + RoP.49.2.a-reply + RoP.56.1 | MED (**FLAG-F**) |
|
||||
| `1b5c6dee…` | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 mo | `RoP.052` | `UPC.RoP.52` | RoP.52 — Rejoinder in revocation | MED |
|
||||
| `bea86f9b…` | Erwiderung auf die Verletzungswiderklage | 2 mo | `RoP.056.1` | `UPC.RoP.56.1` | RoP.56.1 | HIGH | dup of `rev.def_cci` |
|
||||
| `4834c957…` | Replik auf die Erwiderung zur Verletzungswiderklage | 1 mo | `RoP.056.3` | `UPC.RoP.56.3` | RoP.56.3 | HIGH | dup of `rev.reply_def_cci` |
|
||||
| `7b548c48…` | Duplik (Verletzungswiderklage + Patentänderungsantrag) | 1 mo | `RoP.056.4` | `UPC.RoP.56.4` | combined: RoP.56.4 + RoP.32.3 | MED (**FLAG-F**) |
|
||||
|
||||
### 3.2 UPC RoP — Patentänderungs-Track (5)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `fb7050c6…` | Antrag auf Patentänderung | 2 mo | `RoP.030.1` | `UPC.RoP.30.1` | RoP.30.1 (infringement context) | MED (**FLAG-G**: 2 rows with identical name + 2-month dur; one likely refers to `RoP.30.1` infringement, other to `RoP.49.2.a` revocation) |
|
||||
| `21e67ac1…` | Antrag auf Patentänderung | 2 mo | `RoP.049.2.a` | `UPC.RoP.49.2.a` | RoP.49.2.a (revocation context) | MED (**FLAG-G**) |
|
||||
| `7e65a434…` | Erwiderung auf den Antrag auf Patentänderung | 2 mo | `RoP.032.1` | `UPC.RoP.32.1` | RoP.32.1 — Defence to application to amend | HIGH | dup of `inf.def_to_amend` |
|
||||
| `dfd52792…` | Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.032.3` | `UPC.RoP.32.3` | RoP.32.3 — Reply | HIGH | dup of `inf.reply_def_amd` |
|
||||
| `8cdf54eb…` | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.032.3` | `UPC.RoP.32.3` | RoP.32.3 — Rejoinder | HIGH | dup of `inf.rejoin_amd` |
|
||||
|
||||
### 3.3 UPC RoP — appeal track (16)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `1dfba5b1…` | Berufungsschrift gegen Entscheidung nach R. 220.1(a)/(b) | 2 mo | `RoP.224.1.a` | `UPC.RoP.224.1.a` | RoP.224.1.a — Notice of appeal, main-judgment track | HIGH | dup of `app.notice` |
|
||||
| `5c0508f4…` | Berufungsschrift gegen Entscheidung nach R. 220.1(a)/(b) | 2 mo | `RoP.224.1.a` | `UPC.RoP.224.1.a` | same | HIGH | duplicate-of-duplicate (**FLAG-A**) |
|
||||
| `d560b3b6…` | Berufungsschrift gegen Anordnung R. 220.1(c) / R. 220.2 / 221.3 | 15 d | `RoP.224.1.b` | `UPC.RoP.224.1.b` | RoP.224.1.b — Notice of appeal, orders/leave track | HIGH | dup of `app_ord.with_leave`-family |
|
||||
| `791fd0f7…` | Berufungsbegründung Entscheidung R. 220.1(a)/(b) | 4 mo | `RoP.225.1` | `UPC.RoP.225.1` | RoP.225.1 — Statement of grounds, main track | HIGH | dup of `app.grounds` |
|
||||
| `573df3d1…` | Berufungsbegründung Entscheidung R. 220.1(a)/(b) | 4 mo | `RoP.225.1` | `UPC.RoP.225.1` | same | HIGH | duplicate-of-duplicate (**FLAG-A**) |
|
||||
| `c3a369f9…` | Berufungsbegründung Anordnung R. 220.1(c) / R. 220.2 / 221.3 | 15 d | `RoP.225.2` | `UPC.RoP.225.2` | RoP.225.2 — Statement of grounds, orders/leave | MED (**FLAG-H**: RoP.225.2 form; verify 15d figure aligns with current RoP version) |
|
||||
| `91e367dd…` | Berufung (Anordnungen & mit Zulassung) | 15 d | `RoP.224.1.b` | `UPC.RoP.224.1.b` | same | MED | dup of `app_ord.with_leave` |
|
||||
| `ccb916df…` | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d | `RoP.221.1` | `UPC.RoP.221.1` | RoP.221.1 — Leave to appeal cost decisions | HIGH | dup of `cost.leave_app` |
|
||||
| `342e749d…` | Antrag auf Ermessensüberprüfung | 15 d | `RoP.220.3` | `UPC.RoP.220.3` | RoP.220.3 — Discretionary review | HIGH | dup of `app_ord.discretion` |
|
||||
| `d4f739cd…` | Anfechtung einer Entscheidung über Verwerfung der Berufung als unzulässig | 1 mo | `RoP.234.1` | `UPC.RoP.234.1` | RoP.234 — Inadmissibility of appeal review | MED (**FLAG-H**: confirm sub-paragraph; RoP.234 governs the topic but the 1-month review window may sit elsewhere) |
|
||||
| `10374392…` | Berufungserwiderung (zur Berufung nach R. 224.2(a)) | 3 mo | `RoP.235.1` | `UPC.RoP.235.1` | RoP.235.1 — Statement of response, main track | HIGH |
|
||||
| `4c585c6d…` | Berufungserwiderung (zur Berufung nach R. 224.2(b)) | 15 d | `RoP.235.4` | `UPC.RoP.235.4` | RoP.235.4 — Statement of response, orders/leave track | MED (**FLAG-H**: confirm RoP.235.4 vs. RoP.235.2 in current RoP version) |
|
||||
| `6e39b653…` | Anschlussberufungsschrift (zur Berufung R. 224.2(a)) | 3 mo | `RoP.237.1` | `UPC.RoP.237.1` | RoP.237.1 — Cross-appeal | HIGH |
|
||||
| `a00e51bb…` | Anschlussberufungsschrift (zur Berufung R. 224.2(b)) | 15 d | `RoP.237.2` | `UPC.RoP.237.2` | RoP.237 — Cross-appeal in orders track | MED (**FLAG-H**) |
|
||||
| `6b989e85…` | Erwiderung auf Anschlussberufungsschrift (R. 224.2(a)) | 2 mo | `RoP.238.1` | `UPC.RoP.238.1` | RoP.238.1 — Reply to cross-appeal | HIGH | dup of `app.cross_a_reply` |
|
||||
| `e78f4652…` | Erwiderung auf Anschlussberufungsschrift (R. 224.2(b)) | 15 d | `RoP.238.2` | `UPC.RoP.238.2` | RoP.238.2 — Reply to cross-appeal, orders track | HIGH | dup of `app_ord.cross_reply` |
|
||||
|
||||
### 3.4 UPC RoP — Schadensbemessung / Rechnungslegung (7)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `d414f603…` | Erwiderung Antrag auf Schadensersatzbemessung | 2 mo | `RoP.137.2` | `UPC.RoP.137.2` | RoP.137.2 | HIGH | dup of `damages.defence` |
|
||||
| `9f39e263…` | Replik Erwiderung Schadensersatzbemessung | 1 mo | `RoP.139` | `UPC.RoP.139` | RoP.139 | HIGH | dup of `damages.reply` |
|
||||
| `067ffdf0…` | Duplik Replik Schadensersatzbemessung | 1 mo | `RoP.139` | `UPC.RoP.139` | RoP.139 | HIGH | dup of `damages.rejoin` |
|
||||
| `429b8ec0…` | Erwiderung Antrag auf Rechnungslegung | 2 mo | `RoP.142.2` | `UPC.RoP.142.2` | RoP.142.2 — Defence in account procedure | HIGH | dup of `disc.defence` |
|
||||
| `8d36fc76…` | Replik Erwiderung Rechnungslegung | 14 d | `RoP.142.3` | `UPC.RoP.142.3` | RoP.142.3 | HIGH | dup of `disc.reply` |
|
||||
| `ed82fec9…` | Duplik Replik Erwiderung Rechnungslegung | 14 d | `RoP.142.3` | `UPC.RoP.142.3` | RoP.142.3 | HIGH | dup of `disc.rejoin` |
|
||||
| `eed69e8b…` | Antrag auf Kostenentscheidung | 1 mo | `RoP.151` | `UPC.RoP.151` | RoP.151 — Application for cost decision | HIGH | dup of `inf.cost_app` |
|
||||
|
||||
### 3.5 UPC RoP — provisional / PI (6)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `ba335c99…` | Beginn des Hauptsacheverfahrens | 31 d | `RoP.213.1` | `UPC.RoP.213.1` | RoP.213.1 — 31 days or 20 working days after PI granted | HIGH |
|
||||
| `d886f46f…` | Beginn des Hauptsacheverfahrens | 31 d | `RoP.213.1` | `UPC.RoP.213.1` | same — duplicate row (**FLAG-A**) | HIGH |
|
||||
| `1f1f72ef…` | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d | `RoP.197.3` | `UPC.RoP.197.3` | RoP.197.3 — Review of evidence preservation order | HIGH |
|
||||
| `3e2f5697…` | Erneuerung der Schutzschrift | 6 mo | `RoP.207.9` | `UPC.RoP.207.9` | RoP.207.9 — Protective letter, 6-month validity | HIGH |
|
||||
|
||||
### 3.6 UPC RoP — feststellungs / Widerruf-Track (4)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `521bf607…` | Erwiderung auf negative Feststellungsklage | 2 mo | *(NULL)* | *(NULL)* | UPC declaration of non-infringement procedure follows RoP.49 ff. by analogy (RoP.69 references) | **FLAG-I**: negative declaration track has no single statutory norm; cite either `RoP.069` / `UPC.RoP.69` (general procedure) or leave NULL pending m's call |
|
||||
| `e887b1fb…` | Replik Erwiderung negative Feststellungsklage | 1 mo | *(NULL)* | *(NULL)* | same | **FLAG-I** |
|
||||
| `0cf1d755…` | Duplik Replik Erwiderung negative Feststellungsklage | 1 mo | *(NULL)* | *(NULL)* | same | **FLAG-I** |
|
||||
|
||||
### 3.7 UPC RoP — formalities / Registry (14)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `d058f412…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | RoP.16.4 — Notice to remedy defects | HIGH |
|
||||
| `c690c323…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | same — duplicate (**FLAG-A**) | HIGH |
|
||||
| `5f2884a4…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
|
||||
| `13600049…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
|
||||
| `ceb780ba…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
|
||||
| `d51c50eb…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
|
||||
| `3bc40027…` | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d | `RoP.016.5` | `UPC.RoP.16.5` | RoP.16.5 — Written observations after Registry notice | MED |
|
||||
| `69e356b7…` | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d | `RoP.262.2` | `UPC.RoP.262.2` | RoP.262.2 — Confidentiality vis-à-vis public (note in DB confirms) | HIGH |
|
||||
| `57e6eeca…` | Berichtigung von Entscheidungen und Anordnungen | 1 mo | `RoP.353` | `UPC.RoP.353` | RoP.353 — Rectification of decisions/orders | HIGH |
|
||||
| `8ec233b9…` | Antrag auf Überprüfung verfahrensleitender Anordnung | 15 d | `RoP.333.1` | `UPC.RoP.333.1` | RoP.333.1 — Review of procedural order | HIGH |
|
||||
| `d124c95b…` | Antrag auf Aufhebung oder Änderung Entscheidung des Amtes | 1 mo | *(NULL)* | *(NULL)* | unclear which Amts-Entscheidung this targets — Registry order? Unitary-effect refusal? | **FLAG-J** (recommend NULL; ask m what proceeding-context this row maps to) |
|
||||
| `0531b6ba…` | Antrag auf Aufhebung Entscheidung EPA über einheitliche Wirkung | 3 wk | `RoP.097.1` | `UPC.RoP.97.1` | RoP.97.1 — Action against EPO decision on unitary effect | MED (**FLAG-H**: verify 3-week period vs. norm; current RoP gives 1 month for such applications under R.88 EPÜ-UPC; possibly outdated) |
|
||||
| `6b6b967c…` | Antrag auf Verweisung an die Zentralkammer | 10 d | `RoP.037.4` | `UPC.RoP.37.4` | RoP.37 governs division apportionment; .4 is the 10-day observation period | MED (**FLAG-H**: confirm sub-paragraph) |
|
||||
| `002c2ba7…` | Antrag auf Folgemaßnahmen rechtskräftiger Validitätsentscheidung | 2 mo | *(NULL)* | *(NULL)* | likely refers to post-revocation register-correction request; norm uncertain | **FLAG-J** |
|
||||
|
||||
### 3.8 UPC RoP — translation / interpretation (3)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `bb7bafcb…` | Antrag auf Simultanübersetzung | 1 mo (before) | `RoP.109.1` | `UPC.RoP.109.1` | RoP.109.1 — Request for simultaneous interpretation | HIGH |
|
||||
| `8c682cff…` | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 wk (before) | `RoP.109.5` | `UPC.RoP.109.5` | RoP.109.5 — Notice of own-cost interpreter | MED (**FLAG-H**: confirm sub-paragraph; RoP.109 governs interpretation but the specific 2-week notice rule may sit at .4 or .5) |
|
||||
| `9ed513c1…` | Einreichung von Übersetzungen von Schriftstücken | 1 mo | `RoP.007.2` | `UPC.RoP.7.2` | RoP.7.2 — Language of documents | MED (**FLAG-H**: alternative `RoP.7.4` for translations of party-submitted documents) |
|
||||
| `902cc5d5…` | Klärung von Übersetzungsfragen | 2 wk | *(NULL)* | *(NULL)* | unclear which "Übersetzungsfrage" rule | **FLAG-J** |
|
||||
|
||||
### 3.9 UPC RoP — review / rehearing (2)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `372e86e3…` | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 mo | `RoP.247.2` | `UPC.RoP.247.2` | RoP.247.2 — Application for rehearing within 2 months | HIGH |
|
||||
| `58de9573…` | Antrag auf Wiederaufnahme (Straftat) | 2 mo | `RoP.247.2` | `UPC.RoP.247.2` | RoP.247.1(b) substantively (criminal act ground); RoP.247.2 for the 2-month period | HIGH |
|
||||
|
||||
### 3.10 Already-cited orphans (covered in § 1 Easy wins, 7 rows)
|
||||
|
||||
`20254f4e…`, `3c36f149…`, `f1099cf6…`, `c24d494c…`, `d40d9be7…`, `23c6f445…`, `b588fa64…` — see § 1.
|
||||
|
||||
---
|
||||
|
||||
## 4. FLAG summary — items needing m's call
|
||||
|
||||
| FLAG | Topic | Count | Decision needed |
|
||||
|---|---|---|---|
|
||||
| **A** | Genuine duplicate orphan rows (same name + dur + citation) | ~10 | Confirm the dedup pass should happen in mig 097 (or a follow-up). Recommended: leave duplicates in place for mig 097 (fills all of them with the same citation); dedup separately so the rule-resolution semantics don't drift. |
|
||||
| **B** | Court-scheduled / court-issued event rows (Mündliche Verhandlung, Urteil, Entscheidung) | ~22 | Confirm NULL is the right default. Alternative: cite the framing norm with a "context" note. |
|
||||
| **C** | UPC RoP duration vs. norm mismatch (`rev.reply` / `rev.rejoin` / `app.response`) | 3 | Verify the rule durations are correct as stored — proposed citations are canonical but rule duration may be from an older RoP version. |
|
||||
| **D** | German LG patent practice: 4-week replik/duplik (court-set) | 2 | Confirm `§ 273 ZPO` is the cite m wants (no statutory period, framing norm only). |
|
||||
| **E** | Service / trigger-event citations (`§ 317 ZPO`, `R. 111 EPÜ` etc.) | 6 | These are anchor-events for downstream timers, not deadlines. Confirm whether to cite (current proposal) or leave NULL. |
|
||||
| **F** | Combined-pleading orphan rows (one row = several norms) | 5 | Confirm one citation is acceptable, or whether the rows should be split before mig 097 (out of scope here). |
|
||||
| **G** | Twin "Antrag auf Patentänderung" orphans (2-mo, identical name) | 2 | Confirm one is infringement-context (`RoP.30.1`), the other revocation-context (`RoP.49.2.a`). |
|
||||
| **H** | RoP sub-paragraph uncertainty (current text vs. older version) | ~8 | Spot-check against current published RoP; my citations are canonical but small `.x` numbers may need a tweak. |
|
||||
| **I** | Negative-declaration track (no single UPC norm) | 3 | Confirm citing `RoP.69` (procedure-by-analogy) vs. leaving NULL. |
|
||||
| **J** | Orphan with unclear scope | 3 | `d124c95b…` (Aufhebung Entscheidung des Amtes), `002c2ba7…` (Folgemaßnahmen Validitätsentscheidung), `902cc5d5…` (Klärung Übersetzungsfragen). m to identify which UPC norm. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Side-fix (recommend bundled in mig 097)
|
||||
|
||||
**RoP-display normalization**: `rev.defence` currently carries `rule_code = "RoP.49.1"`. All other RoP rules under 100 use 3-digit padding (`RoP.029.a`, `RoP.049.2.a` etc.). mig 097 should normalize `RoP.49.1 → RoP.049.1` in that one row, while filling the 130 NULL rows with consistently padded values.
|
||||
|
||||
```sql
|
||||
-- side-fix candidate
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.049.1'
|
||||
WHERE rule_code = 'RoP.49.1'
|
||||
AND code = 'rev.defence'; -- only one row; idempotent
|
||||
```
|
||||
|
||||
This is opt-in; m to confirm before mig 097 ships.
|
||||
|
||||
---
|
||||
|
||||
## 6. Migration 097 hints (for the coder who writes it)
|
||||
|
||||
**Shape m has asked for:**
|
||||
|
||||
- `UPDATE paliad.deadline_rules SET rule_code = …, legal_source = … WHERE id = … AND rule_code IS NULL AND legal_source IS [NULL|expected];`
|
||||
- Idempotent: `WHERE rule_code IS NULL` (or `IS DISTINCT FROM`) guard so re-applying is a no-op.
|
||||
- Backup snapshot: `CREATE TABLE paliad.deadline_rules_pre_097 AS SELECT * FROM paliad.deadline_rules` before any UPDATEs.
|
||||
- Wrap in `audit_reason = 't-paliad-208 legal-citation backfill'` (matches `paliad.audit_log` pattern used elsewhere).
|
||||
- Touch only the m-approved rows from § 1, § 2, § 3 — FLAG rows (those with `*(NULL)*` in the proposed columns) stay untouched until m resolves them.
|
||||
- Side-fix § 5 (`RoP.49.1 → RoP.049.1`) only if m confirms.
|
||||
|
||||
**Counts the migration should match (assuming m approves all HIGH proposals as-is):**
|
||||
|
||||
- Easy wins (§ 1): 8 `rule_code` UPDATEs (legal_source already set)
|
||||
- Proceeding-typed HIGH/MED proposals (§ 2): ~25 rows
|
||||
- Orphan HIGH/MED proposals (§ 3): ~50 rows
|
||||
- Total expected `rule_code` writes: ~83 rows
|
||||
- Total expected `legal_source` writes: ~75 rows (8 of the easy wins already have one)
|
||||
- FLAG rows left NULL: ~47 rows pending m's decisions
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions for m
|
||||
|
||||
1. **NULL for event-markers (FLAG-B):** confirm NULL is correct for the 22 court-scheduled / court-issued event rows. If m wants citations there too, I'll do a second pass.
|
||||
2. **Trigger-event citations (FLAG-E):** apply `§ 317 ZPO` to LG/OLG service rows, or leave NULL?
|
||||
3. **Duplicates (FLAG-A):** mig 097 fills duplicates with the same citation; do you want a separate dedup pass scheduled (filing `t-paliad-21x`) or is the duplicate count acceptable for now?
|
||||
4. **Combined-pleading orphans (FLAG-F):** keep one citation per row, or split each row into N rows before mig 097?
|
||||
5. **Negative-declaration track (FLAG-I):** cite `RoP.69` by analogy, or leave NULL?
|
||||
6. **Side-fix (§ 5):** normalize the one `RoP.49.1` outlier as part of mig 097?
|
||||
|
||||
Once m answers, head can re-task this same worker (or a fresh coder) to write mig 097 against the approved proposals.
|
||||
577
docs/proposals/orphan-concepts-2026-05-15.md
Normal file
577
docs/proposals/orphan-concepts-2026-05-15.md
Normal file
@@ -0,0 +1,577 @@
|
||||
# Orphan Concept Seed Proposals — Fristen Phase 3 Slice 12 (t-paliad-196)
|
||||
|
||||
**Date:** 2026-05-15
|
||||
**Author:** curie (researcher)
|
||||
**Status:** DRAFT — for m's review, not yet ingested via `/admin/rules`
|
||||
**Branch:** `mai/curie/fristen-phase-3-slice-12`
|
||||
**Source audit:** `docs/audit-fristen-logic-2026-05-13.md` § 3.4 + § 7.9 (pauli)
|
||||
|
||||
---
|
||||
|
||||
## 0. Read-this-first — orphan count discrepancy
|
||||
|
||||
m's task description (and pauli's audit dated 2026-05-13) cited **nine** orphan concepts with `rule_count=0`. Today's live `paliad` DB shows **five**:
|
||||
|
||||
| # | Slug | Party | Category |
|
||||
|---|------|-------|----------|
|
||||
| 1 | `wiedereinsetzung` | both | submission |
|
||||
| 2 | `schriftsatznachreichung` | both | submission |
|
||||
| 3 | `versaeumnisurteil-einspruch` | defendant | submission |
|
||||
| 4 | `weiterbehandlung` | claimant | submission |
|
||||
| 5 | `counterclaim-for-revocation` | defendant | submission |
|
||||
|
||||
Four of the audit's nine were almost certainly seeded between 2026-05-13 and 2026-05-15 by Slice 10 (migration 090, fuzzy backfill) and the Slice-11 admin rule-editor work. `notice-of-defence-intention` is one of them: today's `DE_INF` corpus contains `de_inf.anzeige` (Anzeige der Verteidigungsbereitschaft, ZPO §276.1) linked to its own concept, which removes it from the orphan list.
|
||||
|
||||
**FLAG (count discrepancy):** I drafted proposals for the **5** remaining orphans, not 9. m should confirm whether the other 4 audit-named concepts were intentionally seeded or whether something else is going on before treating this as "done".
|
||||
|
||||
### 0.1 A second, more important framing problem
|
||||
|
||||
The orphan query `deadline_concepts.id NOT IN (SELECT concept_id FROM deadline_rules)` counts only **direct** `concept_id` linkages on `paliad.deadline_rules`. But the schema has two alternate rooting columns: `proceeding_type_id` (Pipeline A) and `trigger_event_id` (Pipeline C). The Pipeline-C migration (Slice 4, m/paliad#…) imported 77 event-rooted rules from `paliad.event_deadlines` but left their `concept_id` **NULL** on the unified `deadline_rules` table — even when the source trigger event had a matching `concept_id` slug already set on `paliad.trigger_events`.
|
||||
|
||||
Concretely, the following rules **already exist** in `paliad.deadline_rules` but lack `concept_id`:
|
||||
|
||||
| Rule name | `trigger_event_id` | Trigger event code | Owning concept (via `trigger_events.concept_id` slug) |
|
||||
|---|---|---|---|
|
||||
| Wiedereinsetzungsantrag (§ 123 PatG) | 200 | `wegfall_hindernisses_de_patg` | `wiedereinsetzung` |
|
||||
| Wiedereinsetzungsantrag (§ 233 ZPO) | 201 | `wegfall_hindernisses_de_zpo` | `wiedereinsetzung` |
|
||||
| Wiedereinsetzungsantrag (Art. 122 EPÜ) | 202 | `wegfall_hindernisses_eu_epc` | `wiedereinsetzung` |
|
||||
| Wiedereinsetzungsantrag (DPMA) | 203 | `wegfall_hindernisses_dpma` | `wiedereinsetzung` |
|
||||
| Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 204 | `zustellung_versaeumnisurteil` | `versaeumnisurteil-einspruch` |
|
||||
| Schriftsatznachreichung (§ 296a ZPO) | 205 | `ende_muendl_verhandlung` | `schriftsatznachreichung` |
|
||||
| Weiterbehandlungsantrag (Art. 121 EPÜ) | 206 | `mitteilung_rechtsverlust_eu` | `weiterbehandlung` |
|
||||
| *(none yet)* | 207 | `wegfall_hindernisses_upc` | `wiedereinsetzung` |
|
||||
|
||||
**Net effect:** four of the five "orphan" concepts already have at least one workable rule — it is just disconnected from the concept by a NULL `concept_id`. The genuine coverage gap is much smaller than "5 concepts × ~5 rules each = 25 rules to draft". Practical Phase-3-Slice-12 work splits into:
|
||||
|
||||
- **Track A (linkage, no legal review needed):** `UPDATE paliad.deadline_rules SET concept_id = … WHERE trigger_event_id IN (200,201,202,203,204,205,206)`. 7 rows, zero new legal substance. See § 6 of this doc.
|
||||
- **Track B (new rule drafts, this doc's main body):** UPC R.320 Wiedereinsetzung (`trigger_event_id=207` truly has no rule yet), proceeding-rooted variants for the four jurisdictions where having a rule under the UPC_INF / DE_INF / EPA_OPP / DPMA_OPP umbrella makes the cascade complete, plus the schema-correct way to resolve `counterclaim-for-revocation` (which is intentionally encoded as flag-gated UPC_INF rules and probably should not get fresh rules at all).
|
||||
|
||||
**FLAG (audit framing):** I recommend the orphan KPI be redefined as "concepts where NO rule references the concept, **directly via `deadline_rules.concept_id` OR transitively via `deadline_rules.trigger_event_id → trigger_events.concept_id`**". Until that happens, the orphan list will keep over-reporting work that has already been done in another column. The Phase 2 design (`docs/design-fristen-phase2-2026-05-15.md` § 3 Step C) anticipates dropping the `paliad.trigger_events` table entirely in Slice 9 and copying `concept_id` onto `deadline_rules` at that point — once that migration runs, the discrepancy resolves itself.
|
||||
|
||||
### 0.2 Convention notes
|
||||
|
||||
- Rule **code** column (`paliad.deadline_rules.code`) uses `<proceeding_short>.<action>` for proceeding-rooted rules (e.g. `inf.sod`, `de_inf.berufung`). For event-rooted rules `code` is NULL today; I follow that pattern.
|
||||
- **Anchor semantics** (audit § 4): `parent_id NULL + duration_value=0` = root anchor / court-set absolute. `parent_id NULL + duration_value>0 + trigger_event_id` = event-rooted, anchored to the trigger event's date. `parent_id NOT NULL` = chained off another rule.
|
||||
- **Priority values** (post-Slice-3): `mandatory` | `recommended` | `optional` | `informational`. Wiedereinsetzung-class rules are conceptually `optional` for the user (they may decide not to file), but the legal-source side is mandatory once invoked. I tag them `optional` with the legal source making the obligation conditional — m to confirm.
|
||||
- **`is_court_set`** is true when the deadline date is set by court order rather than computed from a statutory period. For Schriftsatznachreichung this is the relevant case; for Wiedereinsetzung/Weiterbehandlung it's false (statutory period).
|
||||
- **`legal_source`** uses the existing convention seen on live rules (`UPC.RoP.29.a`, `DE.ZPO.234.1`, `EU.EPC-R.135.1`, `EU.EPÜ.99.1`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Concept: `wiedereinsetzung` (Wiedereinsetzung in den vorigen Stand)
|
||||
|
||||
**Concept ID:** `00b737bf-58a6-4f41-9650-ac3f2e7079e8`
|
||||
**Party:** both · **Category:** submission
|
||||
**Linked event_categories (cascade leaves):**
|
||||
- `cms-eingang.gericht.rechtsverlust-epa` (Mitteilung über Rechtsverlust, EPA)
|
||||
- `frist-verpasst.de-patg` (DE Patentverfahren, PatG §123)
|
||||
- `frist-verpasst.de-zpo` (DE Zivilverfahren, ZPO §233)
|
||||
- `frist-verpasst.dpma` (DPMA, PatG §123)
|
||||
- `frist-verpasst.epa` (EPA, Art. 122 EPÜ)
|
||||
- `frist-verpasst.upc` (UPC, R.320 RoP)
|
||||
|
||||
**Existing trigger-event-rooted rules:** trigger events 200/201/202/203 already have rules in `paliad.deadline_rules` (DE PatG, DE ZPO, EPC, DPMA respectively). Only te 207 (UPC R.320) has no rule yet. See § 6 for the linkage UPDATE that brings the existing four into the concept's rule list.
|
||||
|
||||
**Drafts below:**
|
||||
|
||||
### Rule 1.1 — UPC R.320 Wiedereinsetzungsantrag
|
||||
|
||||
- **Rule code:** `upc.wiedereinsetzung` *(proceeding-rooted) ORalt. NULL code + `trigger_event_id=207` (event-rooted, matches pattern of te 200-206 rules)*
|
||||
- **Proceeding type:** UPC_INF (id=8) — primary. Also relevant for UPC_REV (9), UPC_PI (10), UPC_APP (11), UPC_DAMAGES (17), UPC_DISCOVERY (18), UPC_COST_APPEAL (19), UPC_APP_ORDERS (20). **FLAG:** Wiedereinsetzung applies across the full UPC corpus; m to decide whether to (a) seed one event-rooted rule referencing te 207 — pattern matches the existing four jurisdictions — or (b) seed seven proceeding-rooted clones. Recommend (a): cleaner, mirrors the pattern already set for DE/EPC/DPMA, and Slice 9's table-drop migration in Phase 2 will canonicalise it.
|
||||
- **Name (DE):** Wiedereinsetzungsantrag (R. 320 RoP UPC)
|
||||
- **Name (EN):** Application for re-establishment of rights (UPC R.320 RoP)
|
||||
- **Party:** both (claimant or defendant, whoever missed)
|
||||
- **Anchor:** `trigger_event_id = 207` (`wegfall_hindernisses_upc`)
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional *(filing is at the party's discretion — see § 0.2)*
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `UPC.RoP.320.1`
|
||||
- **Notes:** UPC R.320.1 sets a 2-month window from removal of the cause of non-compliance, capped by an absolute 1-year limit from expiry of the missed period (see Rule 1.2 below). The omitted act must be completed within the same 2-month window (R.320.2). Court fee per R.150(1)(p). UI may want to show the 1-year backstop as a sibling "Achtung" line; that is a renderer decision, not a separate rule.
|
||||
|
||||
### Rule 1.2 — UPC R.320 — 1-Jahres-Ausschlussfrist (informational)
|
||||
|
||||
- **Rule code:** `upc.wiedereinsetzung.cutoff` (or trigger-rooted with a sibling `sequence_order` after Rule 1.1)
|
||||
- **Proceeding type:** same as Rule 1.1
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** the **missed** deadline's date — not `wegfall_hindernisses_upc`. **FLAG:** Today's `trigger_events` model can't express "anchor = the missed deadline" because the trigger fires on removal of cause, not on the missed deadline. Either (a) add a new trigger event `frist_versaeumt_upc` and root this rule there, or (b) make this an `informational` UI-only rule rendered by the renderer next to Rule 1.1 with no real anchor. Recommend (b) for now; (a) is a Phase-3 schema follow-up.
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `UPC.RoP.320.1` (second half: "but at the latest within one year of the expiry of the unobserved time limit")
|
||||
- **Notes:** Cosmetically important — practitioners forget the cut-off. Keep as informational rendering until the schema supports two-anchor rules.
|
||||
|
||||
### Rule 1.3 — EPC Art. 122 / R.136 Wiedereinsetzungsantrag (EPA)
|
||||
|
||||
- **Rule code:** *(event-rooted; NULL `code`, matches existing pattern for te 200-203)*
|
||||
- **Proceeding type:** NULL (or EPA_OPP=14 / EPA_APP=15 / EP_GRANT=16 if proceeding-rooted)
|
||||
- **Name (DE):** Wiedereinsetzungsantrag (Art. 122 EPÜ)
|
||||
- **Name (EN):** Petition for re-establishment of rights (EPC Art.122)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 202` (`wegfall_hindernisses_eu_epc`)
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.136.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** `23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6` — already in `deadline_rules`, just missing `concept_id`. See § 6 linkage UPDATE; do not double-seed.
|
||||
|
||||
### Rule 1.4 — EPC R.136 — 1-Jahres-Ausschlussfrist
|
||||
|
||||
- **Rule code:** as Rule 1.2 pattern
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung EPA (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment, EPC (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** missed-deadline date (same FLAG as Rule 1.2 — schema follow-up)
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.136.1` (second sentence)
|
||||
- **Notes:** R.136(1) third sentence carves out a special **2-month** cut-off for restoration of priority (Art. 87(1) in conjunction with R.136(1)). m may want a separate rule 1.4b for that priority variant; flagging rather than auto-resolving.
|
||||
|
||||
### Rule 1.5 — DE PatG §123 Wiedereinsetzungsantrag (DPMA + national)
|
||||
|
||||
- **Rule code:** event-rooted, te=200 (PatG) and te=203 (DPMA)
|
||||
- **Name (DE):** Wiedereinsetzungsantrag (§ 123 PatG)
|
||||
- **Name (EN):** Petition for re-establishment of rights (PatG §123)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 200` (`wegfall_hindernisses_de_patg`) — for general DE PatG context — AND `trigger_event_id = 203` (`wegfall_hindernisses_dpma`) — for DPMA-specific context.
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.PatG.123.2`
|
||||
- **Notes:** **DUPLICATE of existing rules** `c24d494c-…` (te 200) and `b588fa64-…` (te 203). Linkage only — see § 6.
|
||||
|
||||
### Rule 1.6 — DE PatG §123 — 1-Jahres-Ausschlussfrist
|
||||
|
||||
- **Rule code:** as 1.2/1.4 pattern (informational)
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung PatG (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment, PatG (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** missed-deadline date (schema FLAG as 1.2)
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.PatG.123.2` (Satz 4)
|
||||
- **Notes:** PatG §123(2) Satz 4: "Innerhalb eines Jahres nach Ablauf der versäumten Frist ist keine Wiedereinsetzung mehr möglich." Same as PatG also for DPMA proceedings.
|
||||
|
||||
### Rule 1.7 — DE ZPO §233 Wiedereinsetzungsantrag (Notfrist, 2 Wochen)
|
||||
|
||||
- **Rule code:** event-rooted, te=201
|
||||
- **Name (DE):** Wiedereinsetzungsantrag — Notfrist (§ 234 Abs. 1 S. 1 ZPO)
|
||||
- **Name (EN):** Petition for re-establishment of rights — Notfrist (ZPO §234(1) sentence 1)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 201` (`wegfall_hindernisses_de_zpo`)
|
||||
- **Duration:** 2, weeks
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL — but see Rule 1.8 for the 1-month variant.
|
||||
- **Legal source:** `DE.ZPO.234.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** `d40d9be7-…` — linkage only. ZPO §234(1) sentence 1: 2 weeks for Notfristen (Berufungsfrist, Revisionsfrist, Beschwerdefrist, etc.).
|
||||
|
||||
### Rule 1.8 — DE ZPO §234(1)2 Wiedereinsetzungsantrag (Begründungsfrist, 1 Monat)
|
||||
|
||||
- **Rule code:** event-rooted, te=201, sibling to 1.7
|
||||
- **Name (DE):** Wiedereinsetzungsantrag — Begründungsfrist (§ 234 Abs. 1 S. 2 ZPO)
|
||||
- **Name (EN):** Petition for re-establishment — appeal/revision grounds period (ZPO §234(1) sentence 2)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 201` (`wegfall_hindernisses_de_zpo`)
|
||||
- **Duration:** 1, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** **FLAG** — needs a flag like `{"flag":"begruendungsfrist"}` or similar to distinguish from Rule 1.7 because today's data model can't differentiate "the missed deadline was a Berufungsbegründungsfrist" without an explicit flag from the caller. m to decide whether to add a flag or leave the rule as "informational alternative" rendered alongside 1.7.
|
||||
- **Legal source:** `DE.ZPO.234.1`
|
||||
- **Notes:** ZPO §234(1) Satz 2: "Die Frist beträgt einen Monat, wenn die Partei verhindert war, die Frist zur Begründung der Berufung, der Revision, der Nichtzulassungsbeschwerde oder der Rechtsbeschwerde oder die Frist des § 234 Abs. 3 einzuhalten."
|
||||
|
||||
### Rule 1.9 — DE ZPO §234(3) — 1-Jahres-Ausschlussfrist
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung ZPO (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment, ZPO (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** missed-deadline date (schema FLAG as 1.2)
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.234.3`
|
||||
- **Notes:** "Nach Ablauf eines Jahres, von dem Ende der versäumten Frist an gerechnet, kann die Wiedereinsetzung nicht mehr beantragt … werden."
|
||||
|
||||
**Summary for `wiedereinsetzung`:** four of the five linked event categories (DE PatG, DE ZPO, EPC, DPMA) already have **existing rules** that just need `concept_id` set — see § 6. The genuinely new substance is **Rule 1.1** (UPC R.320, te 207), plus a set of informational 1-year cut-off rules (1.2/1.4/1.6/1.9), plus the optional ZPO §234(1) sentence-2 variant (1.8). Six new rules in total, one duplicate-flagged, four pure linkages. **FLAG:** UPC fee for Wiedereinsetzung (R.150(1)(p)) is not modelled as a rule — should it appear as a sibling informational rule with the fee amount? Today's model doesn't carry money, so probably no, but worth m's call.
|
||||
|
||||
---
|
||||
|
||||
## 2. Concept: `schriftsatznachreichung` (Schriftsatznachreichung, § 296a ZPO)
|
||||
|
||||
**Concept ID:** `b7a3cb3e-ef7e-47a1-8067-be0fe35a4235`
|
||||
**Party:** both · **Category:** submission
|
||||
**Linked event_categories:**
|
||||
- `cms-eingang.gericht.ladung` (Ladung zur mündlichen Verhandlung)
|
||||
- `muendl-verhandlung.gehalten` (Soeben gehalten / heute)
|
||||
- `muendl-verhandlung.geladen` (Geladen — wann findet sie statt?)
|
||||
|
||||
**Existing rules:** te 205 (`ende_muendl_verhandlung`) already has rule `3c36f149-…` (3 weeks). Linkage only — see § 6.
|
||||
|
||||
### Rule 2.1 — DE ZPO §296a Schriftsatznachreichungsfrist
|
||||
|
||||
- **Rule code:** event-rooted, te=205
|
||||
- **Proceeding type:** NULL (event-rooted) — primarily DE_INF/DE_NULL/OLG/BGH context but cross-cutting via the trigger event.
|
||||
- **Name (DE):** Schriftsatznachreichung (§ 296a ZPO)
|
||||
- **Name (EN):** Subsequent written submission (ZPO §296a)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 205` (`ende_muendl_verhandlung`)
|
||||
- **Duration:** 3, weeks
|
||||
- **Timing:** after
|
||||
- **Priority:** optional *(only available if court grants Schriftsatznachreichungsfrist; otherwise §296a bars new attack/defence means)*
|
||||
- **is_court_set:** **true** — the deadline date is set by the court order granting the Schriftsatznachreichungsfrist, not by the statute itself. ZPO §296a permits the court to set it; typical practice is 2-3 weeks but the court fixes the exact date.
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.296a`
|
||||
- **Notes:** **DUPLICATE of existing rule** — linkage only. **FLAG:** the existing rule sets `is_court_set=false` and a fixed 3-week duration. Strictly, the court sets the date, so `is_court_set=true` is more accurate; the 3-week duration is a typical-case estimate. m to decide whether to update the existing rule or leave the heuristic as-is and document the deviation.
|
||||
|
||||
### Rule 2.2 — Schriftsatznachreichung — Beschränkung auf in der Verhandlung erörterte Punkte (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Beschränkung der Schriftsatznachreichung (nur Bezug auf Verhandlungspunkte)
|
||||
- **Name (EN):** Schriftsatznachreichung scope limit (only matters raised at the hearing)
|
||||
- **Party:** both
|
||||
- **Anchor:** same as 2.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.296a`
|
||||
- **Notes:** Reminds the user that a Schriftsatznachreichung is limited to matters raised at the oral hearing — new attack/defence means are barred under §296a. Useful for the cascade card; not a calendar deadline.
|
||||
|
||||
### Rule 2.3 — Schriftsatznachreichung — UPC equivalent? (open question)
|
||||
|
||||
**FLAG:** UPC RoP has no direct §296a analogue. Post-hearing submissions in UPC proceedings are limited and require court leave (general practice; see R.117). I am intentionally **not** drafting a UPC rule under this concept and recommend m confirm the concept stays DE-only. If the cascade exposes the concept under a UPC entry, that is a cascade taxonomy bug, not a rule gap.
|
||||
|
||||
**Summary:** 2 substantive rules (1 duplicate-flagged, 1 informational). Concept is essentially solved by linkage + 1 informational sibling.
|
||||
|
||||
---
|
||||
|
||||
## 3. Concept: `versaeumnisurteil-einspruch` (Einspruch gegen Versäumnisurteil, § 339 ZPO)
|
||||
|
||||
**Concept ID:** `9f809d1d-ea06-4aa5-80d0-6feaa33b464e`
|
||||
**Party:** defendant · **Category:** submission
|
||||
**Linked event_categories:**
|
||||
- `beschluss-entscheidung.versaeumnisurteil` (Versäumnisurteil DE)
|
||||
- `cms-eingang.gericht.endentscheidung.versaeumnisurteil` (Versäumnisurteil DE)
|
||||
|
||||
**Existing rules:** te 204 (`zustellung_versaeumnisurteil`) already has rule `20254f4e-…` (2 weeks). Linkage only — see § 6.
|
||||
|
||||
### Rule 3.1 — DE ZPO §339(1) Einspruchsfrist (Inland-Zustellung, 2 Wochen)
|
||||
|
||||
- **Rule code:** event-rooted, te=204
|
||||
- **Name (DE):** Einspruch gegen Versäumnisurteil (§ 339 Abs. 1 ZPO)
|
||||
- **Name (EN):** Objection to default judgment, domestic service (ZPO §339(1))
|
||||
- **Party:** defendant
|
||||
- **Anchor:** `trigger_event_id = 204` (`zustellung_versaeumnisurteil`)
|
||||
- **Duration:** 2, weeks
|
||||
- **Timing:** after
|
||||
- **Priority:** mandatory *(if defence wants to undo default; otherwise judgment becomes final)*
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL — but see Rule 3.2 for the international-service variant.
|
||||
- **Legal source:** `DE.ZPO.339.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** — linkage only. ZPO §339(1) sentence 1: 2-week Notfrist from Zustellung. §339(1) sentence 2 reserves longer periods for cases under §339(2) and §234(2).
|
||||
|
||||
### Rule 3.2 — DE ZPO §339(2) Einspruchsfrist (Auslands-Zustellung, ≥ 1 Monat)
|
||||
|
||||
- **Rule code:** event-rooted, te=204, sibling
|
||||
- **Name (DE):** Einspruch gegen Versäumnisurteil — Auslandszustellung (§ 339 Abs. 2 ZPO)
|
||||
- **Name (EN):** Objection to default judgment — service abroad (ZPO §339(2))
|
||||
- **Party:** defendant
|
||||
- **Anchor:** `trigger_event_id = 204`
|
||||
- **Duration:** 1, months
|
||||
- **Timing:** after
|
||||
- **Priority:** mandatory
|
||||
- **is_court_set:** **true** — §339(2) sentence 2 says the court sets the period in the order; "at least one month" is the statutory floor.
|
||||
- **condition_expr:** **FLAG** — needs a flag like `{"flag":"auslandszustellung"}` to distinguish from Rule 3.1. m to decide flag naming.
|
||||
- **Legal source:** `DE.ZPO.339.2`
|
||||
- **Notes:** ZPO §339(2): "Bei einer Zustellung im Ausland nach § 183 Abs. 1 Nr. 1 wird die Einspruchsfrist auf mindestens einen Monat festgesetzt."
|
||||
|
||||
### Rule 3.3 — DE ZPO §340 Inhalt der Einspruchsschrift (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Inhalt der Einspruchsschrift (§ 340 ZPO)
|
||||
- **Name (EN):** Required contents of the objection (ZPO §340)
|
||||
- **Party:** defendant
|
||||
- **Anchor:** same as Rule 3.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.340`
|
||||
- **Notes:** Reminds the user that the Einspruchsschrift must contain the designation of the judgment, the declaration of objection, and the parties' applications. Not a calendar deadline.
|
||||
|
||||
### Rule 3.4 — Rechtsfolge Einspruch (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Rechtsfolge des zulässigen Einspruchs (§ 342 ZPO)
|
||||
- **Name (EN):** Effect of admissible objection (ZPO §342)
|
||||
- **Party:** defendant
|
||||
- **Anchor:** same as Rule 3.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.342`
|
||||
- **Notes:** Tells the user that an admissible Einspruch puts the case back in the state pre-default. Useful as a cascade-card pill; not a deadline.
|
||||
|
||||
**Summary:** 4 rules, 1 duplicate-flagged, 1 needing a condition flag, 2 informational.
|
||||
|
||||
---
|
||||
|
||||
## 4. Concept: `weiterbehandlung` (Weiterbehandlung, Art. 121 EPÜ)
|
||||
|
||||
**Concept ID:** `5a58f14c-3042-48e9-87fd-c94b62d13662`
|
||||
**Party:** claimant · **Category:** submission
|
||||
**Linked event_categories:**
|
||||
- `cms-eingang.gericht.rechtsverlust-epa` (Mitteilung über Rechtsverlust, EPA)
|
||||
- `frist-verpasst.epa` (EPA, Art. 122 EPÜ)
|
||||
|
||||
**Existing rules:** te 206 (`mitteilung_rechtsverlust_eu`) already has rule `f1099cf6-…` (2 months). Linkage only — see § 6.
|
||||
|
||||
### Rule 4.1 — EPC Art. 121 / R.135 Weiterbehandlungsantrag
|
||||
|
||||
- **Rule code:** event-rooted, te=206
|
||||
- **Name (DE):** Weiterbehandlungsantrag (Art. 121 EPÜ)
|
||||
- **Name (EN):** Request for further processing (Art.121 EPC)
|
||||
- **Party:** claimant *(applicant during prosecution)*
|
||||
- **Anchor:** `trigger_event_id = 206` (`mitteilung_rechtsverlust_eu`)
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional *(applicant's choice; preferred over Wiedereinsetzung when available because cheaper and no fault analysis)*
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.135.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** — linkage only. R.135(1): 2 months from notification of loss of rights. Missed act must be completed; Weiterbehandlungsgebühr payable per R.135(1) third sentence.
|
||||
|
||||
### Rule 4.2 — Weiterbehandlung Ausschlüsse (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Ausschlüsse Weiterbehandlung (R.135(2) EPÜ)
|
||||
- **Name (EN):** Further-processing exclusions (EPC R.135(2))
|
||||
- **Party:** claimant
|
||||
- **Anchor:** same as Rule 4.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.135.2`
|
||||
- **Notes:** R.135(2): Weiterbehandlung not available for the priority period (Art. 87(1)), the period under Art. 112a(4), the periods for filing of opposition and appeal (Art. 99(1), 108), and various R.6/R.36(1)(a)/R.51(2)/R.158/R.27(3) periods. Cascade-card pill so the user knows when to fall back to Wiedereinsetzung instead. **FLAG:** could be modeled per excluded period as a fine-grained `condition_expr`-gated set; that is overkill for now — informational siblings are enough.
|
||||
|
||||
### Rule 4.3 — Weiterbehandlungsgebühr (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Weiterbehandlungsgebühr fällig
|
||||
- **Name (EN):** Further-processing fee due
|
||||
- **Party:** claimant
|
||||
- **Anchor:** same as Rule 4.1
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.135.1` (third sentence)
|
||||
- **Notes:** Fee per Art. 2(1) item 12 of the EPA fee schedule. Mirrors the missed-act window — both must be completed in the same 2-month window for the request to be effective.
|
||||
|
||||
**Summary:** 3 rules, 1 duplicate-flagged, 2 informational.
|
||||
|
||||
---
|
||||
|
||||
## 5. Concept: `counterclaim-for-revocation` (Nichtigkeitswiderklage, UPC R.25)
|
||||
|
||||
**Concept ID:** `52134900-2bcf-4810-9de3-0b0681c79dd7`
|
||||
**Party:** defendant · **Category:** submission
|
||||
**Linked event_category:**
|
||||
- `ich-moechte-einreichen.widerklage.nichtigkeit-upc` (Nichtigkeitswiderklage UPC R.25)
|
||||
|
||||
**Existing rules:** UPC R.25 / RoP 25-32 are **already encoded** in `UPC_INF` (proceeding_type_id=8) as flag-gated rules using `condition_expr.flag = "with_ccr"`:
|
||||
|
||||
| Rule code | Name | Duration | condition_expr | concept_slug today |
|
||||
|---|---|---|---|---|
|
||||
| `inf.def_to_ccr` | Erwiderung auf Nichtigkeitswiderklage | 2 months | `{"flag":"with_ccr"}` | `defence-to-counterclaim-for-revocation` |
|
||||
| `inf.reply` (with_ccr variant) | Replik | 2 months | `{"flag":"with_ccr"}` | `reply-to-defence` |
|
||||
| `inf.reply_def_ccr` | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 months | `{"flag":"with_ccr"}` | (not yet checked) |
|
||||
| `inf.rejoin` (with_ccr) | Duplik | 1 month | `{"flag":"with_ccr"}` | `rejoinder` |
|
||||
| `inf.rejoin_reply_ccr` | Duplik auf Replik | 1 month | `{"flag":"with_ccr"}` | (not yet checked) |
|
||||
| `inf.def_to_amend` | Erwiderung auf Patentänderungsantrag | 2 months | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` | `defence-to-application-to-amend` |
|
||||
| `inf.app_to_amend` | Antrag auf Patentänderung | 2 months | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` | **NULL** (orphan column) |
|
||||
| `inf.reply_def_amd` | Replik auf Erwiderung zum Patentänderungsantrag | 1 month | same | `reply-to-defence-to-application-to-amend` (or similar) |
|
||||
| `inf.rejoin_amd` | Duplik auf Replik zum Patentänderungsantrag | 1 month | same | `rejoinder-on-amend` (or similar) |
|
||||
|
||||
**The CCR itself** — the act of filing the Nichtigkeitswiderklage — is part of `inf.sod` (Statement of Defence) when `with_ccr=true`. The 3-month SoD period from R.23 doubles as the CCR-filing period from R.25.
|
||||
|
||||
### Proposal 5.1 — Do **not** seed new rules under this concept.
|
||||
|
||||
The concept models a logical artifact ("Nichtigkeitswiderklage") that is, in the data model, an attribute of the SoD rather than a separate timed event. Seeding new rules under `counterclaim-for-revocation.concept_id` would either:
|
||||
|
||||
- (a) Duplicate the existing `inf.sod` / `inf.def_to_ccr` / etc. rules — wasteful, fragile (two sources of truth for the same legal period).
|
||||
- (b) Add a synthetic "filing CCR" rule with the same 3-month period as `inf.sod` — redundant once `inf.sod`'s `concept_id` is set correctly.
|
||||
|
||||
### Proposal 5.2 — Link existing UPC_INF rules to this concept (linkage only).
|
||||
|
||||
Specifically:
|
||||
|
||||
| Rule | Current `concept_id` link | Proposed action |
|
||||
|---|---|---|
|
||||
| `inf.sod` (UPC_INF) | `statement-of-defence` (presumably) | Leave as-is — SoD's primary concept is "Statement of Defence". |
|
||||
| `inf.app_to_amend` (UPC_INF, with_ccr+with_amend) | NULL | **Link to `counterclaim-for-revocation`** — this is the genuine "CCR-derived deadline" that has no concept today. |
|
||||
|
||||
**FLAG:** Whether the cascade entry `ich-moechte-einreichen.widerklage.nichtigkeit-upc` should resolve to the SoD itself or to a CCR-card-with-derivative-deadlines is a UX question m needs to decide. My read: when a user clicks "I want to file Nichtigkeitswiderklage", they want to see the SoD deadline (because that's when the CCR is due — same period as SoD) plus the consequential deadlines (Defence to CCR, Replik, Duplik, Patent amendment etc.). A cleaner data-model fix is to add a junction `paliad.concept_rules` (many-to-many) so a rule can belong to multiple concepts (e.g. `inf.sod` ∈ {`statement-of-defence`, `counterclaim-for-revocation`}). That's a Phase 3+ schema add and outside Slice 12's scope.
|
||||
|
||||
### Proposal 5.3 — Alternative: event-rooted CCR rule.
|
||||
|
||||
Trigger event 1 (`statement_of_defence_which_includes_a_counterclaim_for_revocation`) exists but lacks `concept_id` text. Setting `paliad.trigger_events.concept_id = 'counterclaim-for-revocation'` on te 1 and seeding 1-3 event-rooted rules that fire from te 1 (Defence to CCR within 2 months, Reply within 2 months, etc.) would give the cascade card concrete deadlines without duplicating the SoD-tree rules. This is the pattern the audit § 3.4 description hints at.
|
||||
|
||||
**Recommendation:** Proposal 5.2 + 5.3 combined. m to confirm. Until decided, I'm **not** drafting fresh rules for this concept — it's a data-model question disguised as a coverage gap.
|
||||
|
||||
---
|
||||
|
||||
## 6. Track A — Linkage-only UPDATEs (no legal review needed)
|
||||
|
||||
The following `paliad.deadline_rules` rows already exist; they only need `concept_id` pointed at the right concept. These are the lowest-risk part of Slice 12 and can be applied via the admin UI as no-op edits (or as a one-off migration if m prefers).
|
||||
|
||||
```sql
|
||||
-- DRAFT — do not run blindly; the admin UI route (PATCH /api/admin/rules/{id}) is the preferred path.
|
||||
|
||||
-- Wiedereinsetzung (DE PatG)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = 'c24d494c-0da1-4f01-aa74-0f37f99fe1ae';
|
||||
|
||||
-- Wiedereinsetzung (DE ZPO)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = 'd40d9be7-e1b6-451c-bee2-6eaee2307ec5';
|
||||
|
||||
-- Wiedereinsetzung (EPC)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = '23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6';
|
||||
|
||||
-- Wiedereinsetzung (DPMA)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = 'b588fa64-a727-4cfb-a45d-69a835a3b05a';
|
||||
|
||||
-- Versäumnisurteil-Einspruch (ZPO §339)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '9f809d1d-ea06-4aa5-80d0-6feaa33b464e'
|
||||
WHERE id = '20254f4e-d213-4cf6-8f5f-1d9d36eeb6ac';
|
||||
|
||||
-- Schriftsatznachreichung (ZPO §296a)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = 'b7a3cb3e-ef7e-47a1-8067-be0fe35a4235'
|
||||
WHERE id = '3c36f149-3a81-456e-aac1-d4d18bfcb16b';
|
||||
|
||||
-- Weiterbehandlung (EPC Art.121)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '5a58f14c-3042-48e9-87fd-c94b62d13662'
|
||||
WHERE id = 'f1099cf6-4c87-430e-b1c5-488bd44cb143';
|
||||
```
|
||||
|
||||
After these 7 rows update, `counterclaim-for-revocation` is the only remaining concept with `direct rule_count = 0`, and that is by design (see § 5).
|
||||
|
||||
---
|
||||
|
||||
## 7. Track B — Genuinely new rule drafts
|
||||
|
||||
Pure-new (not in DB today), to be added through `/admin/rules`:
|
||||
|
||||
| # | Concept | Rule | Status |
|
||||
|---|---|---|---|
|
||||
| 1.1 | `wiedereinsetzung` | UPC R.320 Wiedereinsetzungsantrag (te 207) | NEW |
|
||||
| 1.2 | `wiedereinsetzung` | UPC R.320 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 1.4 | `wiedereinsetzung` | EPC R.136 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 1.6 | `wiedereinsetzung` | DE PatG §123 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 1.8 | `wiedereinsetzung` | DE ZPO §234(1)2 — 1-Monat Begründungsfrist | NEW, condition_expr FLAG |
|
||||
| 1.9 | `wiedereinsetzung` | DE ZPO §234(3) 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 2.2 | `schriftsatznachreichung` | §296a-Beschränkung (informational) | NEW |
|
||||
| 3.2 | `versaeumnisurteil-einspruch` | ZPO §339(2) Auslandszustellung 1 Monat | NEW, condition_expr FLAG |
|
||||
| 3.3 | `versaeumnisurteil-einspruch` | ZPO §340 Inhalt der Einspruchsschrift (info) | NEW |
|
||||
| 3.4 | `versaeumnisurteil-einspruch` | ZPO §342 Rechtsfolge (info) | NEW |
|
||||
| 4.2 | `weiterbehandlung` | R.135(2) Ausschlüsse (info) | NEW |
|
||||
| 4.3 | `weiterbehandlung` | Weiterbehandlungsgebühr (info) | NEW |
|
||||
| 5.x | `counterclaim-for-revocation` | (none — see § 5 proposal) | — |
|
||||
|
||||
**Total new rule drafts: 12.** That is well under the "50 rule drafts" estimate in the task brief, because the linkage path covers the bulk of what looked like missing coverage. **FLAG:** if m wants me to draft additional UPC R.320 jurisdiction-specific variants (UPC_REV, UPC_PI, UPC_APP, UPC_DAMAGES, UPC_DISCOVERY) as separate proceeding-rooted rules instead of one shared event-rooted rule (Rule 1.1), that adds ~6-7 more drafts.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions / FLAGs index
|
||||
|
||||
For convenience, all `**FLAG**`-marked items in one place. m's decision is needed on each before /admin/rules ingest of the corresponding rule.
|
||||
|
||||
| ID | Section | Question |
|
||||
|---|---|---|
|
||||
| F1 | § 0 | Count discrepancy: 9 vs 5 — confirm the other 4 audit-named orphans were intentionally resolved, not lost. |
|
||||
| F2 | § 0 | Redefine the orphan KPI to also count `trigger_event_id → trigger_events.concept_id`, so the count reflects actual UX coverage. |
|
||||
| F3 | § 1.1 | UPC R.320: one event-rooted rule (te 207) vs seven proceeding-rooted clones (UPC_INF/UPC_REV/UPC_PI/UPC_APP/UPC_DAMAGES/UPC_DISCOVERY/UPC_APP_ORDERS). |
|
||||
| F4 | § 1.2, 1.4, 1.6, 1.9 | 1-year cut-off rules have no clean anchor in the current schema; informational rendering vs new `frist_versaeumt_*` trigger event. |
|
||||
| F5 | § 1.4 | EPC R.136(1) third sentence: priority-restoration 2-month cut-off — separate rule? |
|
||||
| F6 | § 1.8 | ZPO §234(1) sentence 2 (Begründungsfrist) — flag-gated or informational sibling? |
|
||||
| F7 | § 1.x | UPC Wiedereinsetzungs-Gebühr (R.150(1)(p)) — surface as informational rule or out of scope? |
|
||||
| F8 | § 2.1 | Schriftsatznachreichung existing rule has `is_court_set=false`; strictly it's court-set. Update the row or leave the heuristic in place? |
|
||||
| F9 | § 2.3 | Confirm `schriftsatznachreichung` is DE-only — cascade should not expose it under UPC entries. |
|
||||
| F10 | § 3.2 | ZPO §339(2) Auslandszustellung — flag name for `condition_expr` (e.g. `auslandszustellung`). |
|
||||
| F11 | § 5 | `counterclaim-for-revocation` — link existing UPC_INF rules (proposal 5.2) vs event-rooted CCR rule under te 1 (proposal 5.3) vs both. |
|
||||
| F12 | § 5 | Many-to-many concept↔rule junction (`paliad.concept_rules`) as a Phase 3+ schema add. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Sources cited
|
||||
|
||||
| Citation key | Reference |
|
||||
|---|---|
|
||||
| `UPC.RoP.320.1` | UPC Rules of Procedure, Rule 320(1) — Application for re-establishment of rights, time limits |
|
||||
| `UPC.RoP.320.2` | UPC RoP Rule 320(2) — Completion of omitted act |
|
||||
| `UPC.RoP.150.1.p` | UPC RoP Rule 150(1)(p) — Re-establishment fee |
|
||||
| `UPC.RoP.25` | UPC RoP Rule 25 — Lodging of Counterclaim for Revocation |
|
||||
| `UPC.RoP.23.1` | UPC RoP Rule 23(1) — Statement of Defence period (existing rule reference) |
|
||||
| `EU.EPC-R.136.1` | EPC Implementing Regulations Rule 136(1) |
|
||||
| `EU.EPC-R.136.2` | EPC Implementing Regulations Rule 136(2) — Exclusions |
|
||||
| `EU.EPC-R.135.1` | EPC Implementing Regulations Rule 135(1) — Further processing |
|
||||
| `EU.EPC-R.135.2` | EPC Implementing Regulations Rule 135(2) — Exclusions |
|
||||
| `EU.EPÜ.122` | European Patent Convention Article 122 |
|
||||
| `EU.EPÜ.121` | European Patent Convention Article 121 |
|
||||
| `DE.PatG.123.2` | German Patent Act §123(2) — Wiedereinsetzung |
|
||||
| `DE.ZPO.233` | German ZPO §233 — Wiedereinsetzung in den vorigen Stand |
|
||||
| `DE.ZPO.234.1` | German ZPO §234(1) — Antragsfrist (2 Wochen / 1 Monat) |
|
||||
| `DE.ZPO.234.3` | German ZPO §234(3) — 1-year cut-off |
|
||||
| `DE.ZPO.296a` | German ZPO §296a — Schriftsatznachreichung |
|
||||
| `DE.ZPO.339.1` | German ZPO §339(1) — Einspruchsfrist 2 Wochen |
|
||||
| `DE.ZPO.339.2` | German ZPO §339(2) — Einspruchsfrist Auslandszustellung |
|
||||
| `DE.ZPO.340` | German ZPO §340 — Inhalt der Einspruchsschrift |
|
||||
| `DE.ZPO.342` | German ZPO §342 — Rechtsfolge des zulässigen Einspruchs |
|
||||
|
||||
---
|
||||
|
||||
## 10. What's next (if m approves)
|
||||
|
||||
1. **Track A first** (low risk): apply the 7 linkage UPDATEs from § 6 via `/admin/rules` PATCH. Cascade UX immediately recovers for 4 of 5 concepts.
|
||||
2. **Track B legal-review pass:** m or HLC lawyer signs off on the 12 new drafts in § 7 — adjust durations / phrasings as needed.
|
||||
3. **Ingest Track B** via `/admin/rules` POST, one rule at a time. Each new rule goes into `lifecycle_state='draft'` first; m promotes to `published` after spot-checking via the calculator preview endpoint (Slice 11a).
|
||||
4. **Schema follow-ups** (FLAGs F2, F4, F12) deferred to Phase 3 follow-up tickets — not in Slice 12 scope.
|
||||
|
||||
**Estimated rule count after Slice 12 land:** Track A linkage = 7 connections, Track B new rules = 12 drafts → total `paliad.deadline_rules` row count grows from 249 to **261**; orphan-concept count drops from 5 to **1** (only `counterclaim-for-revocation`, which is by design — see § 5).
|
||||
52
docs/t-paliad-207-followup-scope.md
Normal file
52
docs/t-paliad-207-followup-scope.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# t-paliad-207 follow-up scope — close-out assessment
|
||||
|
||||
**Author:** fermi (inventor)
|
||||
**Date:** 2026-05-20
|
||||
**Verdict:** **(A) DONE** — interactive session scope is shipped; remaining tail is filed-or-fileable as discrete issues, not a fresh fermi slice.
|
||||
|
||||
---
|
||||
|
||||
## 0. What shipped under t-paliad-207
|
||||
|
||||
Six substantive deliveries on `mai/fermi/interactive-session`, all merged to main as of 2026-05-20 morning:
|
||||
|
||||
1. **Verfahrensablauf + Fristenrechner polish** — jurisdiction prefix on the picked proceeding, trigger-event label derived from the root rule, flag rows lifted to `/tools/verfahrensablauf`, rule references rendered as `youpc.org/laws#…` links via new `BuildLegalSourceURL`, `Vorab-Einrede → Einspruch` rename (DE i18n).
|
||||
2. **DE proceeding picker — sub-group headers** (`Verletzungsverfahren` / `Nichtigkeitsverfahren`) + parallel labels (`LG (1. Instanz)` / `OLG (Berufung)` / …).
|
||||
3. **mig 099** — drop the `with_po` flag from the two RoP 19 rules (Einspruch is always-available, not flag-gated).
|
||||
4. **mig 100** — `upc.inf.cfi.ccr` visible rule (`Nichtigkeitswiderklage`) so the CCR filing event surfaces when `with_ccr` is set; later corrected to `priority='optional'` via mig 101.
|
||||
5. **mig 101** — strip rule-cite brackets from the two Einspruch names + flip the CCR priority `informational → optional`.
|
||||
6. **mig 102** — track-aware sequence reshuffle on `upc.inf.cfi` so at any tied date the order is infringement (Replik) → revocation (Erwiderung Nichtigkeitswiderklage) → amendment.
|
||||
7. **Notes toggle** — `Hinweise anzeigen` checkbox in the view-toggle bar; compact ⓘ hover hint when off (default), inline `timeline-notes` block when on. `localStorage` shared across both tool pages.
|
||||
|
||||
Filed two follow-up issues during the session:
|
||||
|
||||
- **m/paliad#39** — link DE + EPA + EU rule references to `youpc.org/laws` (depends on youpc.org ingesting the corpus).
|
||||
- **m/paliad#41** — DE proceedings as one combined timeline per type (LG→OLG→BGH, BPatG→BGH) — corpus + spawn + de-duplication + multi-instance UI.
|
||||
|
||||
## 1. Why (A) DONE
|
||||
|
||||
Every concrete thing m surfaced in the session was addressed and merged. The two larger unaddressed asks — combined-timeline behaviour for DE proceedings, and DE/EPA rule-link coverage — are already captured in #39 and #41 with concrete scope notes. Neither belongs as a fermi "next slice" because:
|
||||
|
||||
- **#41** is a corpus + UI design pass of its own (3 new spawn rules, de-duplication of the existing `de.inf.lg.berufung ↔ de.inf.olg.berufung` pair, multi-court picker shape, instance markers in the timeline body). That's its own design ticket, not a fermi follow-up.
|
||||
- **#39** is primarily a youpc.org-side ingest task; the paliad-side change is a 5-line `switch` extension once youpc serves the URLs. Wait for the dependency, then small.
|
||||
|
||||
Everything else I surfaced in the read-only audit is either pre-existing (not introduced by this session) or speculative (no user complaint behind it).
|
||||
|
||||
## 2. Optional tail — would file as discrete issues, not a fermi slice
|
||||
|
||||
Surfacing these for completeness; none are blocking, and most would be small enough to either roll into the existing tickets or land as one-off polish:
|
||||
|
||||
| # | Candidate | Size | Already covered? |
|
||||
|---|---|---|---|
|
||||
| 1 | **`legal_source` backfill on 47 unsourced active rules** — query: 4 of `upc.inf.cfi`, 4 of `upc.pi.cfi` (100% gap), 6 of `upc.rev.cfi`, others. Pre-condition for #39's links to bite. | Medium — corpus research per rule | Partially: huygens did the broader citation backfill in t-paliad-208 / mig 097. This is the remaining tail. |
|
||||
| 2 | **`upc.pi.cfi` corpus completeness audit** — all 4 of its rules lack `legal_source`; likely also missing the analogous track-of-decision spawn rules to `upc.apl.merits`. | Small audit, medium fix | No — would be a fresh task. |
|
||||
| 3 | **Touch-device fallback for the ⓘ hover hint** — `title=` attribute degrades poorly on phones (no hover, no tap-to-show). Either a click-to-popover variant, or accept the gap. | Tiny | No, but no user complaint yet. |
|
||||
| 4 | **R.46 mutatis-mutandis distinction in `upc.rev.cfi.prelim` description** — when mig 101 stripped the `(R. 19 i.V.m. R. 46)` cite, the legal nuance dropped from the user-visible name. Could be surfaced in the description text where it doesn't crowd the timeline cell. | Tiny (one row update) | No. |
|
||||
| 5 | **Save-modal warning on SoD + CCR double-check** — with mig 100's new `upc.inf.cfi.ccr` rule, a user can save both `sod` and `ccr` from the same modal and get two `paliad.deadlines` rows on the same date. Today's pre-uncheck behaviour for optional priority mitigates accidental double-write but doesn't surface the duplication actively. | Small | No. |
|
||||
| 6 | **Deferred slices from earlier design docs that touch this surface**: t-paliad-179 Slice 2-4 (variant chips, lane view, side-by-side compare on `/tools/verfahrensablauf`); t-paliad-169 "+ Eintrag" CTA on the SmartTimeline (project-bound) path. | Each a separate slice. | Yes — parked from their original tasks; would be revisited when m prioritises. |
|
||||
|
||||
None of these warrant a "next fermi slice" right now. They're polish + corpus tail, and best handled as individual issues that m can pick from.
|
||||
|
||||
## 3. Recommendation
|
||||
|
||||
Close t-paliad-207. Fire fermi. The remaining tail (items 1–6 above) is appropriate as a small "polish backlog" m can dip into when relevant, but not a coherent unit of work that needs a parked inventor.
|
||||
@@ -42,6 +42,9 @@ import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit"
|
||||
import { renderAdminEventTypes } from "./src/admin-event-types";
|
||||
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
|
||||
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
|
||||
import { renderAdminRulesList } from "./src/admin-rules-list";
|
||||
import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderNotFound } from "./src/notfound";
|
||||
@@ -274,6 +277,9 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-event-types.ts"),
|
||||
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-list.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-export.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
// t-paliad-161 — inline Paliadin widget. Loaded via the
|
||||
// PaliadinWidget component on every authenticated page, so the
|
||||
@@ -400,6 +406,9 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
|
||||
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList());
|
||||
await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit());
|
||||
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||
|
||||
BIN
frontend/public/patentstyle/HL-Patents-Style.dotm
Normal file
BIN
frontend/public/patentstyle/HL-Patents-Style.dotm
Normal file
Binary file not shown.
126
frontend/public/patentstyle/index.html
Normal file
126
frontend/public/patentstyle/index.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HL Patents Style</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #002236;
|
||||
--fg: #e8e8ed;
|
||||
--muted: #8a9aa6;
|
||||
--accent: #bff355;
|
||||
--rule: #0f3a55;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif;
|
||||
line-height: 1.55;
|
||||
font-size: 17px;
|
||||
}
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem 6rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
margin: 0 0 0.25rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h1 .accent { color: var(--accent); }
|
||||
.lead {
|
||||
color: var(--muted);
|
||||
margin: 0 0 3rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
margin: 2.5rem 0 0.75rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
ul { padding-left: 1.25rem; margin: 0.5rem 0 1rem; }
|
||||
li { margin: 0.35rem 0; }
|
||||
p { margin: 0.6rem 0; }
|
||||
a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; }
|
||||
a:hover { border-bottom-color: var(--accent); }
|
||||
code, kbd {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
background: #0a2d44;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.download {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
border: 0;
|
||||
}
|
||||
.download:hover { border-bottom: 0; filter: brightness(1.05); }
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
footer code { color: var(--muted); background: transparent; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<h1>HL <span class="accent">Patents Style</span></h1>
|
||||
<p class="lead">Das Word-Template fuer Patentschriftsaetze bei Hogan Lovells.</p>
|
||||
|
||||
<h2>Was es kann</h2>
|
||||
<ul>
|
||||
<li>Vorlagen-Stile fuer alle gaengigen Schriftsatz-Bausteine (Headings, Randnummern, Antraege, Exhibits)</li>
|
||||
<li>BuildingBlocks: ueber das Ribbon vorgefertigte Abschnitte einfuegen</li>
|
||||
<li>Sprachumschaltung DE / EN per Ribbon-Toggle</li>
|
||||
<li>Scaffolding: kompletter Schriftsatz-Aufbau mit einem Klick</li>
|
||||
<li>Margin Numbers, Exhibit-Nummerierung, SEQ-Felder</li>
|
||||
<li>Auto-Update ueber das Ribbon (siehe unten)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Aktualisierungen</h2>
|
||||
<p>Im Ribbon-Tab <em>HL Patent</em> → Gruppe <em>Manage</em> → <kbd>Check for Updates</kbd>. Holt das aktuelle Manifest von diesem Server, prueft die Version, laedt die neue <code>.dotm</code> nur bei Bedarf, verifiziert per SHA256, installiert. Nach dem Update Word neu starten.</p>
|
||||
|
||||
<h2>Frische Installation</h2>
|
||||
<p>Wer das Template noch nicht installiert hat, laedt einmal manuell die aktuelle Version und kopiert sie in den Word-Startup-Ordner. Den Rest macht die <code>InstallTemplate</code>-Routine im Template selbst.</p>
|
||||
<p><a class="download" href="HL-Patents-Style.dotm" download>HL Patents Style.dotm herunterladen</a></p>
|
||||
|
||||
<h2>Hilfe & Feedback</h2>
|
||||
<p>Fehler, Wuensche, Stilfragen, Build-Probleme: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HL%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
|
||||
|
||||
<footer>
|
||||
<p>Update-Endpoint: <code>paliad.msbls.de/patentstyle/</code> · Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
|
||||
<p id="ver"></p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Best-effort: show the currently-served version
|
||||
fetch('version.json', { cache: 'no-cache' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(j => {
|
||||
if (j && j.version) {
|
||||
document.getElementById('ver').textContent = 'Aktuell ausgeliefert: ' + j.version;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
</script>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
5
frontend/public/patentstyle/version.json
Normal file
5
frontend/public/patentstyle/version.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v0.260518",
|
||||
"dotm_url": "https://paliad.msbls.de/patentstyle/HL-Patents-Style.dotm",
|
||||
"sha256": "5CEA98A29D2FD6D9970B9A2499054DF52685A1116459E07F9290B0D0ADD521F4"
|
||||
}
|
||||
352
frontend/src/admin-rules-edit.tsx
Normal file
352
frontend/src/admin-rules-edit.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules/{id}/edit — Slice 11b (t-paliad-192). Form for the full
|
||||
// 37-column rule row plus a side panel with the preview widget and the
|
||||
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
|
||||
// rule's current state (draft/published/archived). Every write goes
|
||||
// through a reason modal that enforces the ≥10-char rule from Slice 11a
|
||||
// edge case #4.
|
||||
//
|
||||
// The id of the rule is parsed from the URL path on hydration —
|
||||
// frontend never reads it from a server-injected blob, so the static
|
||||
// HTML shell is reusable for every rule. condition_expr ships with a
|
||||
// raw JSON textarea + a simple AND/OR/NOT tree-builder (toggle).
|
||||
export function renderAdminRulesEdit(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.edit.title">Regel bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header admin-rules-edit-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
|
||||
<div className="admin-rules-edit-meta">
|
||||
<span id="rules-edit-lifecycle" className="admin-rules-pill admin-rules-pill-draft" />
|
||||
<span id="rules-edit-id" className="admin-rules-edit-uuid" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rules-edit-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-rules-edit-grid">
|
||||
<form id="rules-edit-form" className="entity-form admin-rules-edit-form" autocomplete="off">
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.identity">Identität</legend>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-name" data-i18n="admin.rules.edit.field.name">Name (DE)</label>
|
||||
<input type="text" id="f-name" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-name-en" data-i18n="admin.rules.edit.field.name_en">Name (EN)</label>
|
||||
<input type="text" id="f-name-en" className="admin-rules-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-description" data-i18n="admin.rules.edit.field.description">Beschreibung</label>
|
||||
<textarea id="f-description" className="admin-rules-input" rows={2} />
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
|
||||
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rechtsgrundlage (Kurzform)</label>
|
||||
<input type="text" id="f-rule-code" className="admin-rules-input" placeholder="z. B. RoP.151" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage (Langform)</label>
|
||||
<input type="text" id="f-legal-source" className="admin-rules-input" placeholder="z. B. UPC.RoP.151" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.proceeding">Verfahren & Trigger</legend>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-proceeding" data-i18n="admin.rules.edit.field.proceeding">Verfahrenstyp</label>
|
||||
<select id="f-proceeding" className="admin-rules-select">
|
||||
<option value="" data-i18n="admin.rules.edit.field.proceeding.none">—</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-trigger" data-i18n="admin.rules.edit.field.trigger">Trigger-Ereignis</label>
|
||||
<select id="f-trigger" className="admin-rules-select">
|
||||
<option value="" data-i18n="admin.rules.edit.field.trigger.none">—</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
|
||||
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-concept" data-i18n="admin.rules.edit.field.concept">Konzept (UUID)</label>
|
||||
<input type="text" id="f-concept" className="admin-rules-input" placeholder="UUID oder leer" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-sequence" data-i18n="admin.rules.edit.field.sequence_order">Reihenfolge</label>
|
||||
<input type="number" id="f-sequence" className="admin-rules-input" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.timing">Berechnung</legend>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-duration" data-i18n="admin.rules.edit.field.duration_value">Dauer</label>
|
||||
<input type="number" id="f-duration" className="admin-rules-input" min="0" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-duration-unit" data-i18n="admin.rules.edit.field.duration_unit">Einheit</label>
|
||||
<select id="f-duration-unit" className="admin-rules-select">
|
||||
<option value="days">days</option>
|
||||
<option value="weeks">weeks</option>
|
||||
<option value="months">months</option>
|
||||
<option value="working_days">working_days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-timing" data-i18n="admin.rules.edit.field.timing">Timing</label>
|
||||
<select id="f-timing" className="admin-rules-select">
|
||||
<option value="">—</option>
|
||||
<option value="after">after</option>
|
||||
<option value="before">before</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-combine-op" data-i18n="admin.rules.edit.field.combine_op">Combine-Op</label>
|
||||
<select id="f-combine-op" className="admin-rules-select">
|
||||
<option value="">—</option>
|
||||
<option value="max">max</option>
|
||||
<option value="min">min</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-alt-duration" data-i18n="admin.rules.edit.field.alt_duration_value">Alt-Dauer</label>
|
||||
<input type="number" id="f-alt-duration" className="admin-rules-input" min="0" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-alt-duration-unit" data-i18n="admin.rules.edit.field.alt_duration_unit">Alt-Einheit</label>
|
||||
<select id="f-alt-duration-unit" className="admin-rules-select">
|
||||
<option value="">—</option>
|
||||
<option value="days">days</option>
|
||||
<option value="weeks">weeks</option>
|
||||
<option value="months">months</option>
|
||||
<option value="working_days">working_days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-alt-rule-code" data-i18n="admin.rules.edit.field.alt_rule_code">Alt-Rule-Code</label>
|
||||
<input type="text" id="f-alt-rule-code" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-anchor-alt" data-i18n="admin.rules.edit.field.anchor_alt">Alt-Anchor</label>
|
||||
<input type="text" id="f-anchor-alt" className="admin-rules-input" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.party">Partei & Ereignis</legend>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-primary-party" data-i18n="admin.rules.edit.field.primary_party">Primäre Partei</label>
|
||||
<input type="text" id="f-primary-party" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
|
||||
<input type="text" id="f-event-type" className="admin-rules-input" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.display">Anzeige & Notizen</legend>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-notes" data-i18n="admin.rules.edit.field.deadline_notes">Hinweise (DE)</label>
|
||||
<textarea id="f-notes" className="admin-rules-input" rows={2} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-notes-en" data-i18n="admin.rules.edit.field.deadline_notes_en">Hinweise (EN)</label>
|
||||
<textarea id="f-notes-en" className="admin-rules-input" rows={2} />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.lifecycle">Priorität & Flags</legend>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-priority" data-i18n="admin.rules.edit.field.priority">Priorität</label>
|
||||
<select id="f-priority" className="admin-rules-select">
|
||||
<option value="mandatory">mandatory</option>
|
||||
<option value="recommended">recommended</option>
|
||||
<option value="optional">optional</option>
|
||||
<option value="informational">informational</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field admin-rules-checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" id="f-is-court-set" />
|
||||
<span data-i18n="admin.rules.edit.field.is_court_set">Gerichtlich gesetzt</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-field admin-rules-checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" id="f-is-spawn" />
|
||||
<span data-i18n="admin.rules.edit.field.is_spawn">Spawn</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-rules-edit-row" id="f-spawn-row" style="display:none">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-spawn-label" data-i18n="admin.rules.edit.field.spawn_label">Spawn-Label</label>
|
||||
<input type="text" id="f-spawn-label" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-spawn-proceeding" data-i18n="admin.rules.edit.field.spawn_proceeding">Spawn-Verfahren</label>
|
||||
<select id="f-spawn-proceeding" className="admin-rules-select">
|
||||
<option value="" data-i18n="admin.rules.edit.field.spawn_proceeding.none">—</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.condition">Bedingung (condition_expr)</legend>
|
||||
<p className="admin-rules-hint" data-i18n="admin.rules.edit.field.condition_hint">
|
||||
JSON-Grammatik: <code>{"flag":"name"}</code> · <code>{"op":"and|or","args":[...]}</code> · <code>{"op":"not","args":[...]}</code>
|
||||
</p>
|
||||
<div className="form-field">
|
||||
<textarea id="f-condition-expr" className="admin-rules-input admin-rules-code-input" rows={5} placeholder='z. B. {"flag":"with_ccr"}' />
|
||||
<p className="admin-rules-hint" id="f-condition-msg" />
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<aside className="admin-rules-edit-side">
|
||||
{/* Preview widget */}
|
||||
<div className="admin-rules-edit-card">
|
||||
<h3 data-i18n="admin.rules.edit.preview.heading">Preview</h3>
|
||||
<p className="admin-rules-hint" data-i18n="admin.rules.edit.preview.hint">
|
||||
Nur für Drafts. Berechnet die Fristenkette mit dieser Draft-Regel anstelle der publizierten Variante.
|
||||
</p>
|
||||
<div className="form-field">
|
||||
<label htmlFor="preview-trigger-date" data-i18n="admin.rules.edit.preview.trigger_date">Trigger-Datum</label>
|
||||
<input type="date" lang="de" id="preview-trigger-date" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="preview-flags" data-i18n="admin.rules.edit.preview.flags">Flags (komma-separiert)</label>
|
||||
<input type="text" id="preview-flags" className="admin-rules-input" placeholder="z. B. with_ccr,is_appeal" />
|
||||
</div>
|
||||
<button type="button" id="preview-run" className="btn-secondary" data-i18n="admin.rules.edit.preview.run">
|
||||
Preview berechnen
|
||||
</button>
|
||||
<div id="preview-result" className="admin-rules-preview-result" style="display:none" />
|
||||
</div>
|
||||
|
||||
{/* Audit-log timeline */}
|
||||
<div className="admin-rules-edit-card">
|
||||
<h3 data-i18n="admin.rules.edit.audit.heading">Audit-Log</h3>
|
||||
<ol id="rules-edit-audit" className="admin-rules-audit-list">
|
||||
<li className="admin-rules-loading" data-i18n="admin.rules.edit.audit.loading">Lade...</li>
|
||||
</ol>
|
||||
<button type="button" id="audit-loadmore" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.audit.loadmore">
|
||||
Weitere laden
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="admin-rules-actionbar">
|
||||
<button type="button" id="action-save-draft" className="btn-primary" style="display:none" data-i18n="admin.rules.edit.action.save_draft">
|
||||
Draft speichern
|
||||
</button>
|
||||
<button type="button" id="action-publish" className="btn-primary" style="display:none" data-i18n="admin.rules.edit.action.publish">
|
||||
Publish
|
||||
</button>
|
||||
<button type="button" id="action-clone" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.clone">
|
||||
Als Draft klonen
|
||||
</button>
|
||||
<button type="button" id="action-archive" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.archive">
|
||||
Archivieren
|
||||
</button>
|
||||
<button type="button" id="action-restore" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.restore">
|
||||
Wiederherstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Reason modal — shared for every lifecycle action. Action-specific
|
||||
body text is set by the client at open time. */}
|
||||
<div className="modal-overlay" id="rules-action-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="rules-action-modal-title">Aktion bestätigen</h2>
|
||||
<button className="modal-close" id="rules-action-modal-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p id="rules-action-modal-body" className="invite-modal-body" />
|
||||
<form id="rules-action-modal-form" className="entity-form" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="rules-action-modal-reason" data-i18n="admin.rules.modal.reason">Grund</label>
|
||||
<textarea
|
||||
id="rules-action-modal-reason"
|
||||
className="admin-rules-input"
|
||||
rows={3}
|
||||
required
|
||||
minlength={10}
|
||||
/>
|
||||
<p className="admin-rules-hint" data-i18n="admin.rules.modal.reason.hint">
|
||||
Mindestens 10 Zeichen.
|
||||
</p>
|
||||
</div>
|
||||
<p className="form-msg" id="rules-action-modal-msg" style="display:none" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="rules-action-modal-cancel" data-i18n="common.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary" id="rules-action-modal-submit" data-i18n="admin.rules.modal.confirm">
|
||||
Bestätigen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-rules-edit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
80
frontend/src/admin-rules-export.tsx
Normal file
80
frontend/src/admin-rules-export.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules/export — Slice 11b (t-paliad-192). Surfaces the
|
||||
// GET /admin/api/rules/export-migrations endpoint as a SQL preview the
|
||||
// editor can copy or download. Optional ?since=<audit-id> query lets
|
||||
// the editor scope the export to a particular audit window — empty =
|
||||
// every un-exported audit row.
|
||||
export function renderAdminRulesExport(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.export.title">Regel-Migrations exportieren — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/rules" data-i18n="admin.rules.export.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 data-i18n="admin.rules.export.heading">Regel-Migrations exportieren</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.export.subtitle">
|
||||
Generiert ein <code>*.up.sql</code>-Blob mit allen unsynchronisierten Audit-Veränderungen.
|
||||
Manuell in <code>internal/db/migrations/</code> einchecken.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-export-controls">
|
||||
<div className="form-field">
|
||||
<label htmlFor="export-since" data-i18n="admin.rules.export.field.since">Startend ab Audit-ID (optional)</label>
|
||||
<input type="text" id="export-since" className="admin-rules-input" placeholder="UUID, leer = alle un-exportierten" />
|
||||
</div>
|
||||
<button type="button" id="export-run" className="btn-primary" data-i18n="admin.rules.export.run">
|
||||
Export generieren
|
||||
</button>
|
||||
<button type="button" id="export-download" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.download">
|
||||
Als Datei herunterladen
|
||||
</button>
|
||||
<button type="button" id="export-copy" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.copy">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="export-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-rules-export-summary" id="export-summary" style="display:none">
|
||||
<span id="export-summary-count" />
|
||||
<span id="export-summary-latest" />
|
||||
</div>
|
||||
|
||||
<pre id="export-output" className="admin-rules-export-pre" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-rules-export.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
187
frontend/src/admin-rules-list.tsx
Normal file
187
frontend/src/admin-rules-list.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules — Slice 11b (t-paliad-192). Filterable rule table + an
|
||||
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
|
||||
// admin can hand-bind each legacy deadline to one of the candidate
|
||||
// rule_ids. Both surfaces share the same page shell to keep navigation
|
||||
// shallow — the count badge on the Orphans tab is loaded eagerly on
|
||||
// first paint so the editor sees the legal-review backlog every visit.
|
||||
export function renderAdminRulesList(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.list.title">Regeln verwalten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.rules.list.heading">Regeln verwalten</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
|
||||
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-rules-header-actions">
|
||||
<a href="/admin/rules/export" className="btn-secondary" data-i18n="admin.rules.list.export">
|
||||
Migrations exportieren
|
||||
</a>
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
|
||||
+ Neue Regel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-tabs">
|
||||
<button type="button" className="admin-rules-tab active" id="rules-tab-rules" data-tab="rules" data-i18n="admin.rules.tab.rules">
|
||||
Regeln
|
||||
</button>
|
||||
<button type="button" className="admin-rules-tab" id="rules-tab-orphans" data-tab="orphans">
|
||||
<span data-i18n="admin.rules.tab.orphans">Orphans</span>
|
||||
<span className="admin-rules-tab-badge" id="rules-orphans-badge" style="display:none">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="rules-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
{/* Rules tab */}
|
||||
<div id="rules-pane-rules" className="admin-rules-pane">
|
||||
<div className="admin-rules-filters">
|
||||
<div className="admin-rules-filter">
|
||||
<label htmlFor="rules-filter-proceeding" data-i18n="admin.rules.filter.proceeding">Verfahrenstyp</label>
|
||||
<select id="rules-filter-proceeding" className="admin-rules-select">
|
||||
<option value="" data-i18n="admin.rules.filter.proceeding.any">Alle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-filter">
|
||||
<label htmlFor="rules-filter-trigger" data-i18n="admin.rules.filter.trigger">Trigger-Ereignis</label>
|
||||
<select id="rules-filter-trigger" className="admin-rules-select">
|
||||
<option value="" data-i18n="admin.rules.filter.trigger.any">Alle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-filter admin-rules-filter-chips">
|
||||
<span className="admin-rules-filter-label" data-i18n="admin.rules.filter.lifecycle">Lifecycle</span>
|
||||
<div className="admin-rules-chips" id="rules-filter-lifecycle">
|
||||
<button type="button" className="admin-rules-chip active" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="draft" data-i18n="admin.rules.lifecycle.draft">Draft</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="archived" data-i18n="admin.rules.lifecycle.archived">Archived</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-filter admin-rules-filter-search">
|
||||
<label htmlFor="rules-filter-search" data-i18n="admin.rules.filter.search">Suche</label>
|
||||
<input
|
||||
type="text"
|
||||
id="rules-filter-search"
|
||||
className="admin-rules-input"
|
||||
placeholder="Name, Submission Code, Rechtsgrundlage..."
|
||||
data-i18n-placeholder="admin.rules.filter.search.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap admin-rules-table-wrap">
|
||||
<table className="entity-table admin-rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
|
||||
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
|
||||
<th data-i18n="admin.rules.col.name">Name</th>
|
||||
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
|
||||
<th data-i18n="admin.rules.col.priority">Priorität</th>
|
||||
<th data-i18n="admin.rules.col.lifecycle">Lifecycle</th>
|
||||
<th data-i18n="admin.rules.col.modified">Zuletzt geändert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rules-tbody">
|
||||
<tr><td colspan={7} className="admin-rules-loading" data-i18n="admin.rules.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="rules-empty" style="display:none">
|
||||
<p data-i18n="admin.rules.empty">Keine Regeln für die gewählten Filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orphans tab */}
|
||||
<div id="rules-pane-orphans" className="admin-rules-pane" style="display:none">
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.orphans.subtitle">
|
||||
Legacy-Deadlines aus dem fuzzy-match Backfill (Slice 10), die nicht eindeutig einer Regel zugeordnet werden konnten. Bitte die richtige Kandidaten-Regel auswählen.
|
||||
</p>
|
||||
<div id="rules-orphans-list" className="admin-rules-orphans">
|
||||
<p className="admin-rules-loading" data-i18n="admin.rules.orphans.loading">Lade...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Reason modal — reused for "+ Neue Regel" (creates a draft) and for
|
||||
the orphan resolve flow. Both writes go through audit-reason
|
||||
session config server-side, so the modal enforces the 10-char
|
||||
minimum client-side per Slice 11a edge case #4. */}
|
||||
<div className="modal-overlay" id="rules-reason-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="rules-reason-title" data-i18n="admin.rules.modal.new.title">Neue Regel anlegen</h2>
|
||||
<button className="modal-close" id="rules-reason-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p id="rules-reason-body" className="invite-modal-body" data-i18n="admin.rules.modal.new.body">
|
||||
Eine neue Regel wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.
|
||||
</p>
|
||||
<form id="rules-reason-form" className="entity-form" autocomplete="off">
|
||||
<div id="rules-reason-extra" />
|
||||
<div className="form-field">
|
||||
<label htmlFor="rules-reason-text" data-i18n="admin.rules.modal.reason">Grund</label>
|
||||
<textarea
|
||||
id="rules-reason-text"
|
||||
className="admin-rules-input"
|
||||
rows={3}
|
||||
required
|
||||
minlength={10}
|
||||
placeholder="z. B. „Neue Regel für RoP.198 nach UPC-Reform 2026..."
|
||||
data-i18n-placeholder="admin.rules.modal.reason.placeholder"
|
||||
/>
|
||||
<p className="admin-rules-hint" data-i18n="admin.rules.modal.reason.hint">
|
||||
Mindestens 10 Zeichen.
|
||||
</p>
|
||||
</div>
|
||||
<p className="form-msg" id="rules-reason-msg" style="display:none" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="rules-reason-cancel" data-i18n="common.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary" id="rules-reason-submit" data-i18n="admin.rules.modal.confirm">
|
||||
Bestätigen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-rules-list.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,11 @@ export function renderAdmin(): string {
|
||||
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
|
||||
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
|
||||
</a>
|
||||
<a href="/admin/rules" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
|
||||
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
|
||||
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
|
||||
|
||||
667
frontend/src/client/admin-rules-edit.ts
Normal file
667
frontend/src/client/admin-rules-edit.ts
Normal file
@@ -0,0 +1,667 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule
|
||||
// row, drives every form field, the preview widget, the audit-log
|
||||
// timeline and the lifecycle action bar. Every write is gated behind
|
||||
// a reason modal — the ≥10-char rule is enforced client-side per
|
||||
// Slice 11a edge case #4.
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
proceeding_type_id?: number | null;
|
||||
parent_id?: string | null;
|
||||
// submission_code is the proceeding-prefixed identifier of this rule
|
||||
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
|
||||
// rule_code (legal citation, e.g. `RoP.013.1`).
|
||||
submission_code?: string | null;
|
||||
rule_code?: string | null;
|
||||
name: string;
|
||||
name_en: string;
|
||||
description?: string | null;
|
||||
primary_party?: string | null;
|
||||
event_type?: string | null;
|
||||
duration_value: number;
|
||||
duration_unit: string;
|
||||
timing?: string | null;
|
||||
alt_duration_value?: number | null;
|
||||
alt_duration_unit?: string | null;
|
||||
alt_rule_code?: string | null;
|
||||
anchor_alt?: string | null;
|
||||
combine_op?: string | null;
|
||||
legal_source?: string | null;
|
||||
deadline_notes?: string | null;
|
||||
deadline_notes_en?: string | null;
|
||||
priority: string;
|
||||
is_court_set: boolean;
|
||||
is_spawn: boolean;
|
||||
spawn_label?: string | null;
|
||||
spawn_proceeding_type_id?: number | null;
|
||||
trigger_event_id?: number | null;
|
||||
condition_expr?: unknown;
|
||||
sequence_order: number;
|
||||
concept_id?: string | null;
|
||||
lifecycle_state: string;
|
||||
draft_of?: string | null;
|
||||
published_at?: string | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
}
|
||||
|
||||
interface TriggerEvent {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_de: string;
|
||||
}
|
||||
|
||||
interface AuditEntry {
|
||||
id: string;
|
||||
rule_id: string;
|
||||
changed_by?: string | null;
|
||||
changed_by_display_name?: string | null;
|
||||
changed_at: string;
|
||||
action: string;
|
||||
before_json?: unknown;
|
||||
after_json?: unknown;
|
||||
reason: string;
|
||||
migration_exported: boolean;
|
||||
}
|
||||
|
||||
let ruleId = "";
|
||||
let rule: Rule | null = null;
|
||||
let proceedings: ProceedingType[] = [];
|
||||
let triggers: TriggerEvent[] = [];
|
||||
let auditEntries: AuditEntry[] = [];
|
||||
let auditOffset = 0;
|
||||
const AUDIT_PAGE = 20;
|
||||
let auditHasMore = false;
|
||||
let previewDebounce: number | undefined;
|
||||
|
||||
function esc(s: string | null | undefined): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s ?? "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return d.toLocaleString(locale, {
|
||||
year: "numeric", month: "2-digit", day: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function parseRuleIDFromPath(): string {
|
||||
// /admin/rules/{uuid}/edit
|
||||
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
|
||||
return m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("rules-edit-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
if (!isError) {
|
||||
setTimeout(() => { el.style.display = "none"; }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycleLabel(state: string): string {
|
||||
return tDyn(`admin.rules.lifecycle.${state}`) || state;
|
||||
}
|
||||
|
||||
function lifecycleClass(state: string): string {
|
||||
switch (state) {
|
||||
case "draft": return "admin-rules-pill admin-rules-pill-draft";
|
||||
case "published": return "admin-rules-pill admin-rules-pill-published";
|
||||
case "archived": return "admin-rules-pill admin-rules-pill-archived";
|
||||
default: return "admin-rules-pill";
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Loaders.
|
||||
// --------------------------------------------------------------------
|
||||
async function loadProceedings(): Promise<void> {
|
||||
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
||||
if (!resp.ok) return;
|
||||
proceedings = (await resp.json()) as ProceedingType[];
|
||||
fillProceedingSelect("f-proceeding", proceedings);
|
||||
fillProceedingSelect("f-spawn-proceeding", proceedings);
|
||||
}
|
||||
|
||||
async function loadTriggers(): Promise<void> {
|
||||
const resp = await fetch("/api/tools/trigger-events");
|
||||
if (!resp.ok) return;
|
||||
triggers = (await resp.json()) as TriggerEvent[];
|
||||
const sel = document.getElementById("f-trigger") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const placeholder = sel.querySelector('option[value=""]');
|
||||
sel.innerHTML = "";
|
||||
if (placeholder) sel.appendChild(placeholder);
|
||||
for (const te of triggers) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(te.id);
|
||||
opt.textContent = `${te.code} · ${getLang() === "en" ? te.name : te.name_de}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
|
||||
const sel = document.getElementById(selectId) as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const placeholder = sel.querySelector('option[value=""]');
|
||||
sel.innerHTML = "";
|
||||
if (placeholder) sel.appendChild(placeholder);
|
||||
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}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRule(): Promise<void> {
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 404) {
|
||||
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
|
||||
} else {
|
||||
showFeedback(t("admin.rules.edit.error.load") || "Konnte Regel nicht laden.", true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
rule = await resp.json() as Rule;
|
||||
populateForm();
|
||||
updateLifecycleUI();
|
||||
}
|
||||
|
||||
async function loadAudit(reset: boolean = true): Promise<void> {
|
||||
if (reset) {
|
||||
auditEntries = [];
|
||||
auditOffset = 0;
|
||||
}
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
|
||||
if (!resp.ok) return;
|
||||
const body = await resp.json();
|
||||
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
|
||||
auditEntries.push(...rows);
|
||||
auditOffset += rows.length;
|
||||
auditHasMore = rows.length === AUDIT_PAGE;
|
||||
renderAudit();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Form binding.
|
||||
// --------------------------------------------------------------------
|
||||
function setInput(id: string, val: unknown) {
|
||||
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null;
|
||||
if (!el) return;
|
||||
if (val == null) {
|
||||
el.value = "";
|
||||
return;
|
||||
}
|
||||
el.value = String(val);
|
||||
}
|
||||
|
||||
function setCheckbox(id: string, val: boolean) {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (!el) return;
|
||||
el.checked = !!val;
|
||||
}
|
||||
|
||||
function getInput(id: string): string {
|
||||
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null;
|
||||
return el ? el.value.trim() : "";
|
||||
}
|
||||
|
||||
function getCheckbox(id: string): boolean {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
return el ? el.checked : false;
|
||||
}
|
||||
|
||||
function getOptionalInt(id: string): number | null {
|
||||
const v = getInput(id);
|
||||
if (!v) return null;
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function getOptionalString(id: string): string | null {
|
||||
const v = getInput(id);
|
||||
return v ? v : null;
|
||||
}
|
||||
|
||||
function populateForm() {
|
||||
if (!rule) return;
|
||||
const heading = document.getElementById("rules-edit-heading") as HTMLElement;
|
||||
const idEl = document.getElementById("rules-edit-id") as HTMLElement;
|
||||
const lifecycleEl = document.getElementById("rules-edit-lifecycle") as HTMLElement;
|
||||
heading.textContent = (getLang() === "en" ? rule.name_en : rule.name) || rule.name;
|
||||
idEl.textContent = rule.id;
|
||||
lifecycleEl.className = lifecycleClass(rule.lifecycle_state);
|
||||
lifecycleEl.textContent = lifecycleLabel(rule.lifecycle_state);
|
||||
|
||||
setInput("f-name", rule.name);
|
||||
setInput("f-name-en", rule.name_en);
|
||||
setInput("f-description", rule.description ?? "");
|
||||
setInput("f-submission-code", rule.submission_code ?? "");
|
||||
setInput("f-rule-code", rule.rule_code ?? "");
|
||||
setInput("f-legal-source", rule.legal_source ?? "");
|
||||
setInput("f-proceeding", rule.proceeding_type_id ?? "");
|
||||
setInput("f-trigger", rule.trigger_event_id ?? "");
|
||||
setInput("f-parent", rule.parent_id ?? "");
|
||||
setInput("f-concept", rule.concept_id ?? "");
|
||||
setInput("f-sequence", rule.sequence_order);
|
||||
setInput("f-duration", rule.duration_value);
|
||||
setInput("f-duration-unit", rule.duration_unit);
|
||||
setInput("f-timing", rule.timing ?? "");
|
||||
setInput("f-combine-op", rule.combine_op ?? "");
|
||||
setInput("f-alt-duration", rule.alt_duration_value ?? "");
|
||||
setInput("f-alt-duration-unit", rule.alt_duration_unit ?? "");
|
||||
setInput("f-alt-rule-code", rule.alt_rule_code ?? "");
|
||||
setInput("f-anchor-alt", rule.anchor_alt ?? "");
|
||||
setInput("f-primary-party", rule.primary_party ?? "");
|
||||
setInput("f-event-type", rule.event_type ?? "");
|
||||
setInput("f-notes", rule.deadline_notes ?? "");
|
||||
setInput("f-notes-en", rule.deadline_notes_en ?? "");
|
||||
setInput("f-priority", rule.priority);
|
||||
setCheckbox("f-is-court-set", rule.is_court_set);
|
||||
setCheckbox("f-is-spawn", rule.is_spawn);
|
||||
setInput("f-spawn-label", rule.spawn_label ?? "");
|
||||
setInput("f-spawn-proceeding", rule.spawn_proceeding_type_id ?? "");
|
||||
toggleSpawnRow();
|
||||
setInput("f-condition-expr", rule.condition_expr ? JSON.stringify(rule.condition_expr, null, 2) : "");
|
||||
}
|
||||
|
||||
function toggleSpawnRow() {
|
||||
const row = document.getElementById("f-spawn-row") as HTMLElement | null;
|
||||
if (!row) return;
|
||||
row.style.display = getCheckbox("f-is-spawn") ? "" : "none";
|
||||
}
|
||||
|
||||
function updateLifecycleUI() {
|
||||
const draftOnly = (id: string, show: boolean) => {
|
||||
const el = document.getElementById(id) as HTMLElement | null;
|
||||
if (el) el.style.display = show ? "" : "none";
|
||||
};
|
||||
if (!rule) return;
|
||||
const isDraft = rule.lifecycle_state === "draft";
|
||||
const isPublished = rule.lifecycle_state === "published";
|
||||
const isArchived = rule.lifecycle_state === "archived";
|
||||
|
||||
draftOnly("action-save-draft", isDraft);
|
||||
draftOnly("action-publish", isDraft);
|
||||
draftOnly("action-clone", isPublished || isArchived);
|
||||
draftOnly("action-archive", isDraft || isPublished);
|
||||
draftOnly("action-restore", isArchived);
|
||||
|
||||
// Lock form fields when not editable (i.e. not draft). Published /
|
||||
// archived rules show the form read-only so editors can confirm
|
||||
// they're about to clone the right row.
|
||||
const readOnly = !isDraft;
|
||||
document.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
|
||||
"#rules-edit-form input, #rules-edit-form select, #rules-edit-form textarea",
|
||||
).forEach((el) => {
|
||||
el.disabled = readOnly;
|
||||
});
|
||||
}
|
||||
|
||||
function renderAudit() {
|
||||
const list = document.getElementById("rules-edit-audit") as HTMLElement | null;
|
||||
const more = document.getElementById("audit-loadmore") as HTMLElement | null;
|
||||
if (!list) return;
|
||||
if (auditEntries.length === 0) {
|
||||
list.innerHTML = `<li class="admin-rules-audit-empty">${esc(t("admin.rules.edit.audit.empty") || "Keine Audit-Einträge.")}</li>`;
|
||||
} else {
|
||||
list.innerHTML = auditEntries.map((e) => {
|
||||
const actor = e.changed_by_display_name || (e.changed_by ? e.changed_by.slice(0, 8) : (t("admin.rules.edit.audit.actor.system") || "System"));
|
||||
const actionLabel = tDyn(`admin.rules.edit.audit.action.${e.action}`) || e.action;
|
||||
const exported = e.migration_exported
|
||||
? `<span class="admin-rules-audit-badge">${esc(t("admin.rules.edit.audit.exported") || "exported")}</span>`
|
||||
: "";
|
||||
return `
|
||||
<li class="admin-rules-audit-entry admin-rules-audit-action-${esc(e.action)}">
|
||||
<div class="admin-rules-audit-head">
|
||||
<span class="admin-rules-audit-action">${esc(actionLabel)}</span>
|
||||
<span class="admin-rules-audit-time">${esc(fmtDateTime(e.changed_at))}</span>
|
||||
${exported}
|
||||
</div>
|
||||
<div class="admin-rules-audit-actor">${esc(actor)}</div>
|
||||
${e.reason ? `<div class="admin-rules-audit-reason">${esc(e.reason)}</div>` : ""}
|
||||
</li>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
if (more) more.style.display = auditHasMore ? "" : "none";
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Validation helpers.
|
||||
// --------------------------------------------------------------------
|
||||
function validateConditionExpr(): { ok: boolean; value: unknown | undefined; msg: string } {
|
||||
const raw = getInput("f-condition-expr");
|
||||
const msgEl = document.getElementById("f-condition-msg") as HTMLElement | null;
|
||||
if (!raw) {
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "";
|
||||
msgEl.className = "admin-rules-hint";
|
||||
}
|
||||
return { ok: true, value: undefined, msg: "" };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "✓ " + (t("admin.rules.edit.field.condition.valid") || "JSON gültig.");
|
||||
msgEl.className = "admin-rules-hint admin-rules-hint-ok";
|
||||
}
|
||||
return { ok: true, value: parsed, msg: "" };
|
||||
} catch (err) {
|
||||
const m = err instanceof Error ? err.message : String(err);
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "⚠ " + m;
|
||||
msgEl.className = "admin-rules-hint admin-rules-hint-error";
|
||||
}
|
||||
return { ok: false, value: undefined, msg: m };
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Action modal (reason + lifecycle handler).
|
||||
// --------------------------------------------------------------------
|
||||
type Action = "save-draft" | "publish" | "clone" | "archive" | "restore";
|
||||
|
||||
let pendingAction: Action | null = null;
|
||||
|
||||
function openActionModal(action: Action) {
|
||||
pendingAction = action;
|
||||
const modal = document.getElementById("rules-action-modal") as HTMLElement;
|
||||
const title = document.getElementById("rules-action-modal-title") as HTMLElement;
|
||||
const body = document.getElementById("rules-action-modal-body") as HTMLElement;
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const reasonInput = document.getElementById("rules-action-modal-reason") as HTMLTextAreaElement;
|
||||
msg.style.display = "none";
|
||||
reasonInput.value = "";
|
||||
switch (action) {
|
||||
case "save-draft":
|
||||
title.textContent = t("admin.rules.edit.modal.save_draft.title") || "Draft speichern";
|
||||
body.textContent = t("admin.rules.edit.modal.save_draft.body") || "Bitte einen Grund für die Änderung angeben (mind. 10 Zeichen). Wird ins Audit-Log geschrieben.";
|
||||
break;
|
||||
case "publish":
|
||||
title.textContent = t("admin.rules.edit.modal.publish.title") || "Publish";
|
||||
body.textContent = t("admin.rules.edit.modal.publish.body") || "Diese Draft-Regel wird live geschaltet. Bestehende publizierte Variante wird archiviert.";
|
||||
break;
|
||||
case "clone":
|
||||
title.textContent = t("admin.rules.edit.modal.clone.title") || "Als Draft klonen";
|
||||
body.textContent = t("admin.rules.edit.modal.clone.body") || "Eine neue Draft-Kopie dieser Regel wird angelegt. Sie werden auf die neue Draft-Seite weitergeleitet.";
|
||||
break;
|
||||
case "archive":
|
||||
title.textContent = t("admin.rules.edit.modal.archive.title") || "Archivieren";
|
||||
body.textContent = t("admin.rules.edit.modal.archive.body") || "Regel wird archiviert. Calculator nutzt sie nicht mehr.";
|
||||
break;
|
||||
case "restore":
|
||||
title.textContent = t("admin.rules.edit.modal.restore.title") || "Wiederherstellen";
|
||||
body.textContent = t("admin.rules.edit.modal.restore.body") || "Regel wird wiederhergestellt (archived → published).";
|
||||
break;
|
||||
}
|
||||
modal.style.display = "flex";
|
||||
reasonInput.focus();
|
||||
}
|
||||
|
||||
function closeActionModal() {
|
||||
(document.getElementById("rules-action-modal") as HTMLElement).style.display = "none";
|
||||
pendingAction = null;
|
||||
}
|
||||
|
||||
async function submitActionModal(ev: Event) {
|
||||
ev.preventDefault();
|
||||
if (!pendingAction || !rule) return;
|
||||
const reasonInput = document.getElementById("rules-action-modal-reason") as HTMLTextAreaElement;
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const submit = document.getElementById("rules-action-modal-submit") as HTMLButtonElement;
|
||||
const reason = reasonInput.value.trim();
|
||||
if (reason.length < 10) {
|
||||
msg.textContent = t("admin.rules.modal.reason.too_short") || "Grund muss mindestens 10 Zeichen enthalten.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
submit.disabled = true;
|
||||
try {
|
||||
if (pendingAction === "save-draft") {
|
||||
await doSaveDraft(reason);
|
||||
} else if (pendingAction === "publish") {
|
||||
await doLifecycle("publish", reason);
|
||||
} else if (pendingAction === "clone") {
|
||||
await doClone(reason);
|
||||
} else if (pendingAction === "archive") {
|
||||
await doLifecycle("archive", reason);
|
||||
} else if (pendingAction === "restore") {
|
||||
await doLifecycle("restore", reason);
|
||||
}
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPatchPayload(): Record<string, unknown> {
|
||||
const validation = validateConditionExpr();
|
||||
if (!validation.ok) throw new Error(validation.msg);
|
||||
const payload: Record<string, unknown> = {
|
||||
name: getInput("f-name"),
|
||||
name_en: getInput("f-name-en"),
|
||||
description: getInput("f-description"),
|
||||
primary_party: getInput("f-primary-party"),
|
||||
event_type: getInput("f-event-type"),
|
||||
duration_value: getOptionalInt("f-duration") ?? 0,
|
||||
duration_unit: getInput("f-duration-unit"),
|
||||
timing: getOptionalString("f-timing"),
|
||||
alt_duration_value: getOptionalInt("f-alt-duration"),
|
||||
alt_duration_unit: getOptionalString("f-alt-duration-unit"),
|
||||
alt_rule_code: getOptionalString("f-alt-rule-code"),
|
||||
anchor_alt: getOptionalString("f-anchor-alt"),
|
||||
combine_op: getOptionalString("f-combine-op"),
|
||||
rule_code: getOptionalString("f-rule-code"),
|
||||
legal_source: getOptionalString("f-legal-source"),
|
||||
deadline_notes: getInput("f-notes"),
|
||||
deadline_notes_en: getInput("f-notes-en"),
|
||||
priority: getInput("f-priority"),
|
||||
is_court_set: getCheckbox("f-is-court-set"),
|
||||
is_spawn: getCheckbox("f-is-spawn"),
|
||||
spawn_label: getOptionalString("f-spawn-label"),
|
||||
spawn_proceeding_type_id: getOptionalInt("f-spawn-proceeding"),
|
||||
trigger_event_id: getOptionalInt("f-trigger"),
|
||||
sequence_order: getOptionalInt("f-sequence") ?? 0,
|
||||
};
|
||||
if (validation.value !== undefined) {
|
||||
payload.condition_expr = validation.value;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function doSaveDraft(reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = buildPatchPayload();
|
||||
} catch (e) {
|
||||
msg.textContent = e instanceof Error ? e.message : String(e);
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
payload.reason = reason;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || (t("admin.rules.edit.action.save_draft.error") || "Speichern fehlgeschlagen.");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
rule = await resp.json() as Rule;
|
||||
closeActionModal();
|
||||
populateForm();
|
||||
updateLifecycleUI();
|
||||
await loadAudit(true);
|
||||
showFeedback(t("admin.rules.edit.action.save_draft.ok") || "Draft gespeichert.", false);
|
||||
}
|
||||
|
||||
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || (tDyn(`admin.rules.edit.action.${op}.error`) || "Aktion fehlgeschlagen.");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
rule = await resp.json() as Rule;
|
||||
closeActionModal();
|
||||
populateForm();
|
||||
updateLifecycleUI();
|
||||
await loadAudit(true);
|
||||
showFeedback(tDyn(`admin.rules.edit.action.${op}.ok`) || (t("admin.rules.edit.action.ok") || "Erledigt."), false);
|
||||
}
|
||||
|
||||
async function doClone(reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || (t("admin.rules.edit.action.clone.error") || "Klonen fehlgeschlagen.");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
const newRule = await resp.json() as Rule;
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Preview.
|
||||
// --------------------------------------------------------------------
|
||||
async function runPreview() {
|
||||
const out = document.getElementById("preview-result") as HTMLElement;
|
||||
if (!rule) return;
|
||||
if (rule.lifecycle_state !== "draft") {
|
||||
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(t("admin.rules.edit.preview.only_drafts") || "Preview ist nur für Drafts verfügbar.")}</p>`;
|
||||
out.style.display = "";
|
||||
return;
|
||||
}
|
||||
const triggerDate = getInput("preview-trigger-date");
|
||||
if (!triggerDate) {
|
||||
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(t("admin.rules.edit.preview.trigger_required") || "Bitte Trigger-Datum angeben.")}</p>`;
|
||||
out.style.display = "";
|
||||
return;
|
||||
}
|
||||
const flagsRaw = getInput("preview-flags");
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("trigger_date", triggerDate);
|
||||
if (flagsRaw) qs.set("flags", flagsRaw);
|
||||
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
|
||||
out.style.display = "";
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
renderPreview(body);
|
||||
}
|
||||
|
||||
function renderPreview(resp: unknown) {
|
||||
const out = document.getElementById("preview-result") as HTMLElement;
|
||||
type Result = { deadlines?: Array<{ name?: string; titleDE?: string; due_date?: string; dueDate?: string; ruleCode?: string; rule_code?: string }>; deadline?: Array<unknown> };
|
||||
const r = resp as Result;
|
||||
const list = (r && (r.deadlines || r.deadline)) as Array<Record<string, unknown>> | undefined;
|
||||
if (!list || list.length === 0) {
|
||||
out.innerHTML = `<p class="admin-rules-hint">${esc(t("admin.rules.edit.preview.empty") || "Keine Deadlines.")}</p>`;
|
||||
return;
|
||||
}
|
||||
out.innerHTML = `<ul class="admin-rules-preview-list">${list.map((d) => {
|
||||
const name = String(d.name || d.titleDE || d.title || "");
|
||||
const date = String(d.due_date || d.dueDate || "");
|
||||
const code = String(d.rule_code || d.ruleCode || "");
|
||||
return `<li>
|
||||
${code ? `<code>${esc(code)}</code>` : ""}
|
||||
<span class="admin-rules-preview-name">${esc(name)}</span>
|
||||
<span class="admin-rules-preview-date">${esc(date)}</span>
|
||||
</li>`;
|
||||
}).join("")}</ul>`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Init.
|
||||
// --------------------------------------------------------------------
|
||||
async function init() {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
ruleId = parseRuleIDFromPath();
|
||||
if (!ruleId) {
|
||||
showFeedback(t("admin.rules.edit.error.bad_id") || "Ungültige Regel-ID in der URL.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
(document.getElementById("rules-action-modal-close") as HTMLElement).addEventListener("click", closeActionModal);
|
||||
(document.getElementById("rules-action-modal-cancel") as HTMLElement).addEventListener("click", closeActionModal);
|
||||
(document.getElementById("rules-action-modal-form") as HTMLFormElement).addEventListener("submit", submitActionModal);
|
||||
|
||||
(document.getElementById("action-save-draft") as HTMLElement).addEventListener("click", () => openActionModal("save-draft"));
|
||||
(document.getElementById("action-publish") as HTMLElement).addEventListener("click", () => openActionModal("publish"));
|
||||
(document.getElementById("action-clone") as HTMLElement).addEventListener("click", () => openActionModal("clone"));
|
||||
(document.getElementById("action-archive") as HTMLElement).addEventListener("click", () => openActionModal("archive"));
|
||||
(document.getElementById("action-restore") as HTMLElement).addEventListener("click", () => openActionModal("restore"));
|
||||
|
||||
(document.getElementById("f-is-spawn") as HTMLInputElement).addEventListener("change", toggleSpawnRow);
|
||||
(document.getElementById("f-condition-expr") as HTMLTextAreaElement).addEventListener("input", () => {
|
||||
validateConditionExpr();
|
||||
});
|
||||
|
||||
(document.getElementById("preview-run") as HTMLElement).addEventListener("click", () => {
|
||||
window.clearTimeout(previewDebounce);
|
||||
previewDebounce = window.setTimeout(runPreview, 100);
|
||||
});
|
||||
(document.getElementById("audit-loadmore") as HTMLElement).addEventListener("click", () => loadAudit(false));
|
||||
|
||||
await Promise.all([loadProceedings(), loadTriggers()]);
|
||||
await loadRule();
|
||||
await loadAudit(true);
|
||||
|
||||
onLangChange(() => {
|
||||
if (rule) {
|
||||
populateForm();
|
||||
updateLifecycleUI();
|
||||
}
|
||||
renderAudit();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
100
frontend/src/client/admin-rules-export.ts
Normal file
100
frontend/src/client/admin-rules-export.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-export.ts — /admin/rules/export. Calls
|
||||
// GET /admin/api/rules/export-migrations[?since=<uuid>] and renders the
|
||||
// SQL blob server-side. Download builds a Blob URL and triggers a
|
||||
// fake <a> click; copy uses navigator.clipboard.
|
||||
|
||||
interface ExportResult {
|
||||
migration_sql: string;
|
||||
count: number;
|
||||
latest_audit_id: string;
|
||||
}
|
||||
|
||||
let latest: ExportResult | null = null;
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("export-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
if (!isError) setTimeout(() => { el.style.display = "none"; }, 4000);
|
||||
}
|
||||
|
||||
async function runExport() {
|
||||
const since = (document.getElementById("export-since") as HTMLInputElement).value.trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (since) qs.set("since", since);
|
||||
const url = "/admin/api/rules/export-migrations" + (qs.toString() ? "?" + qs.toString() : "");
|
||||
const out = document.getElementById("export-output") as HTMLElement;
|
||||
const summary = document.getElementById("export-summary") as HTMLElement;
|
||||
const dl = document.getElementById("export-download") as HTMLElement;
|
||||
const cp = document.getElementById("export-copy") as HTMLElement;
|
||||
out.textContent = t("admin.rules.export.running") || "Lade...";
|
||||
summary.style.display = "none";
|
||||
dl.style.display = "none";
|
||||
cp.style.display = "none";
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || (t("admin.rules.export.error") || "Export fehlgeschlagen."), true);
|
||||
out.textContent = "";
|
||||
return;
|
||||
}
|
||||
latest = await resp.json() as ExportResult;
|
||||
out.textContent = latest.migration_sql;
|
||||
summary.style.display = "";
|
||||
const countEl = document.getElementById("export-summary-count") as HTMLElement;
|
||||
const latestEl = document.getElementById("export-summary-latest") as HTMLElement;
|
||||
countEl.textContent = (t("admin.rules.export.count") || "Audit-Zeilen: {n}").replace("{n}", String(latest.count));
|
||||
if (latest.latest_audit_id) {
|
||||
latestEl.textContent = (t("admin.rules.export.latest") || "Letzte Audit-ID: {id}").replace("{id}", latest.latest_audit_id);
|
||||
} else {
|
||||
latestEl.textContent = "";
|
||||
}
|
||||
if (latest.count > 0) {
|
||||
dl.style.display = "";
|
||||
cp.style.display = "";
|
||||
showFeedback((t("admin.rules.export.ok") || "{n} Audit-Zeilen exportiert.").replace("{n}", String(latest.count)), false);
|
||||
} else {
|
||||
showFeedback(t("admin.rules.export.no_pending") || "Keine offenen Audit-Zeilen zum Export.", false);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
if (!latest) return;
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const name = `rules-export-${ts}.up.sql`;
|
||||
const blob = new Blob([latest.migration_sql], { type: "application/sql" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!latest) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(latest.migration_sql);
|
||||
showFeedback(t("admin.rules.export.copied") || "In Zwischenablage kopiert.", false);
|
||||
} catch (e) {
|
||||
showFeedback(t("admin.rules.export.copy_failed") || "Kopieren fehlgeschlagen.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
(document.getElementById("export-run") as HTMLElement).addEventListener("click", runExport);
|
||||
(document.getElementById("export-download") as HTMLElement).addEventListener("click", downloadFile);
|
||||
(document.getElementById("export-copy") as HTMLElement).addEventListener("click", copyToClipboard);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
524
frontend/src/client/admin-rules-list.ts
Normal file
524
frontend/src/client/admin-rules-list.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-list.ts — /admin/rules. Drives the rule table (filterable
|
||||
// by proceeding type, trigger event, lifecycle state, free-text query)
|
||||
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
|
||||
// a rule routes to /admin/rules/{id}/edit; orphan cards have their own
|
||||
// "Pick" affordance with an inline reason prompt that posts to
|
||||
// /admin/api/orphans/{id}/resolve.
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
proceeding_type_id?: number | null;
|
||||
// submission_code is the proceeding-prefixed identifier of this rule
|
||||
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
|
||||
// rule_code (the legal citation, e.g. `RoP.013.1`).
|
||||
submission_code?: string | null;
|
||||
rule_code?: string | null;
|
||||
name: string;
|
||||
name_en: string;
|
||||
priority: string;
|
||||
lifecycle_state: string;
|
||||
updated_at: string;
|
||||
trigger_event_id?: number | null;
|
||||
duration_value: number;
|
||||
duration_unit: string;
|
||||
}
|
||||
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface TriggerEvent {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_de: string;
|
||||
}
|
||||
|
||||
interface OrphanCandidate {
|
||||
id: string;
|
||||
rule_code?: string | null;
|
||||
name: string;
|
||||
name_en: string;
|
||||
}
|
||||
|
||||
interface Orphan {
|
||||
id: string;
|
||||
deadline_id: string;
|
||||
title: string;
|
||||
project_id?: string | null;
|
||||
project_title?: string | null;
|
||||
proceeding_code?: string | null;
|
||||
reason: string;
|
||||
candidate_count: number;
|
||||
candidate_ids: string[];
|
||||
candidates: OrphanCandidate[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let rules: Rule[] = [];
|
||||
let orphans: Orphan[] = [];
|
||||
let proceedings: ProceedingType[] = [];
|
||||
let triggerEvents: TriggerEvent[] = [];
|
||||
|
||||
let activeProceeding = "";
|
||||
let activeTrigger = "";
|
||||
let activeLifecycle = "";
|
||||
let activeQuery = "";
|
||||
let searchDebounce: number | undefined;
|
||||
|
||||
function esc(s: string | null | undefined): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s ?? "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return d.toLocaleString(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("rules-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
if (!isError) {
|
||||
setTimeout(() => { el.style.display = "none"; }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycleLabel(state: string): string {
|
||||
return tDyn(`admin.rules.lifecycle.${state}`) || state;
|
||||
}
|
||||
|
||||
function lifecycleClass(state: string): string {
|
||||
switch (state) {
|
||||
case "draft": return "admin-rules-pill admin-rules-pill-draft";
|
||||
case "published": return "admin-rules-pill admin-rules-pill-published";
|
||||
case "archived": return "admin-rules-pill admin-rules-pill-archived";
|
||||
default: return "admin-rules-pill";
|
||||
}
|
||||
}
|
||||
|
||||
function priorityLabel(p: string): string {
|
||||
return tDyn(`admin.rules.priority.${p}`) || p;
|
||||
}
|
||||
|
||||
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;
|
||||
return `${pt.code} · ${name}`;
|
||||
}
|
||||
|
||||
function buildFilterURL(): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (activeProceeding) qs.set("proceeding_type_id", activeProceeding);
|
||||
if (activeTrigger) qs.set("trigger_event_id", activeTrigger);
|
||||
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
|
||||
if (activeQuery) qs.set("q", activeQuery);
|
||||
qs.set("limit", "500");
|
||||
return "/admin/api/rules?" + qs.toString();
|
||||
}
|
||||
|
||||
async function loadProceedings(): Promise<void> {
|
||||
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
||||
if (!resp.ok) return;
|
||||
proceedings = (await resp.json()) as ProceedingType[];
|
||||
const sel = document.getElementById("rules-filter-proceeding") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
// Preserve the "Alle" placeholder option then append every proceeding.
|
||||
// The placeholder is the one with empty value already in the markup.
|
||||
const placeholder = sel.querySelector('option[value=""]');
|
||||
sel.innerHTML = "";
|
||||
if (placeholder) sel.appendChild(placeholder);
|
||||
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}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTriggerEvents(): Promise<void> {
|
||||
const resp = await fetch("/api/tools/trigger-events");
|
||||
if (!resp.ok) return;
|
||||
triggerEvents = (await resp.json()) as TriggerEvent[];
|
||||
const sel = document.getElementById("rules-filter-trigger") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const placeholder = sel.querySelector('option[value=""]');
|
||||
sel.innerHTML = "";
|
||||
if (placeholder) sel.appendChild(placeholder);
|
||||
for (const te of triggerEvents) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(te.id);
|
||||
opt.textContent = `${te.code} · ${getLang() === "en" ? te.name : te.name_de}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules(): Promise<void> {
|
||||
const resp = await fetch(buildFilterURL());
|
||||
if (!resp.ok) {
|
||||
showFeedback(t("admin.rules.error.load") || "Konnte Regeln nicht laden.", true);
|
||||
rules = [];
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
rules = Array.isArray(body) ? body as Rule[] : [];
|
||||
}
|
||||
|
||||
async function loadOrphans(): Promise<void> {
|
||||
const resp = await fetch("/admin/api/orphans");
|
||||
if (!resp.ok) {
|
||||
orphans = [];
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
orphans = Array.isArray(body) ? body as Orphan[] : [];
|
||||
updateOrphansBadge();
|
||||
}
|
||||
|
||||
function updateOrphansBadge() {
|
||||
const badge = document.getElementById("rules-orphans-badge") as HTMLElement | null;
|
||||
if (!badge) return;
|
||||
if (orphans.length === 0) {
|
||||
badge.style.display = "none";
|
||||
} else {
|
||||
badge.style.display = "";
|
||||
badge.textContent = String(orphans.length);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRulesTable() {
|
||||
const tbody = document.getElementById("rules-tbody") as HTMLElement | null;
|
||||
const empty = document.getElementById("rules-empty") as HTMLElement | null;
|
||||
if (!tbody || !empty) return;
|
||||
|
||||
if (rules.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
const name = (r: Rule) => (getLang() === "en" ? r.name_en : r.name) || r.name;
|
||||
tbody.innerHTML = rules.map((r) => `
|
||||
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
|
||||
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
|
||||
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
|
||||
<td>${esc(name(r))}</td>
|
||||
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
|
||||
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>
|
||||
<td><span class="${lifecycleClass(r.lifecycle_state)}">${esc(lifecycleLabel(r.lifecycle_state))}</span></td>
|
||||
<td class="admin-rules-col-modified">${esc(fmtDateTime(r.updated_at))}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
tbody.querySelectorAll<HTMLElement>(".admin-rules-row").forEach((row) => {
|
||||
row.addEventListener("click", (ev) => {
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (target && (target.closest("a") || target.closest("button"))) return;
|
||||
const id = row.dataset.rowId;
|
||||
if (!id) return;
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(id)}/edit`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderOrphans() {
|
||||
const list = document.getElementById("rules-orphans-list") as HTMLElement | null;
|
||||
if (!list) return;
|
||||
if (orphans.length === 0) {
|
||||
list.innerHTML = `<p class="entity-empty" data-i18n="admin.rules.orphans.empty">${esc(t("admin.rules.orphans.empty") || "Keine offenen Orphans. ✔")}</p>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = orphans.map((o) => renderOrphanCard(o)).join("");
|
||||
list.querySelectorAll<HTMLButtonElement>(".admin-rules-orphan-pick").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const orphanId = btn.dataset.orphanId!;
|
||||
const ruleId = btn.dataset.ruleId!;
|
||||
onPickOrphanCandidate(orphanId, ruleId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderOrphanCard(o: Orphan): string {
|
||||
const reasonLabel = tDyn(`admin.rules.orphans.reason.${o.reason}`) || o.reason;
|
||||
const meta = [
|
||||
o.project_title ? `<span class="admin-rules-orphan-meta">${esc(t("admin.rules.orphans.field.project") || "Projekt")}: ${esc(o.project_title)}</span>` : "",
|
||||
o.proceeding_code ? `<span class="admin-rules-orphan-meta">${esc(t("admin.rules.orphans.field.proceeding") || "Verfahren")}: <code>${esc(o.proceeding_code)}</code></span>` : "",
|
||||
`<span class="admin-rules-orphan-meta">${esc(t("admin.rules.orphans.field.reason") || "Grund")}: ${esc(reasonLabel)}</span>`,
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
let candidatesHTML = "";
|
||||
if (o.candidates.length === 0) {
|
||||
candidatesHTML = `<p class="admin-rules-orphan-empty">${esc(t("admin.rules.orphans.no_candidates") || "Keine Kandidaten gefunden. Bitte Regel manuell anlegen.")}</p>`;
|
||||
} else {
|
||||
candidatesHTML = `<div class="admin-rules-orphan-candidates">
|
||||
${o.candidates.map((c) => {
|
||||
const cname = getLang() === "en" ? c.name_en : c.name;
|
||||
return `<button type="button" class="admin-rules-orphan-pick"
|
||||
data-orphan-id="${esc(o.id)}" data-rule-id="${esc(c.id)}">
|
||||
<code>${esc(c.rule_code || "")}</code>
|
||||
<span class="admin-rules-orphan-pick-name">${esc(cname)}</span>
|
||||
</button>`;
|
||||
}).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="admin-rules-orphan-card" data-orphan-id="${esc(o.id)}">
|
||||
<div class="admin-rules-orphan-header">
|
||||
<div class="admin-rules-orphan-title">${esc(o.title)}</div>
|
||||
<div class="admin-rules-orphan-metas">${meta}</div>
|
||||
</div>
|
||||
${candidatesHTML}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Reason modal — shared between "+ Neue Regel" and orphan resolve.
|
||||
// --------------------------------------------------------------------
|
||||
type ModalContext =
|
||||
| { kind: "new-rule" }
|
||||
| { kind: "orphan-resolve"; orphanId: string; ruleId: string };
|
||||
|
||||
let modalCtx: ModalContext | null = null;
|
||||
|
||||
function openReasonModal(ctx: ModalContext) {
|
||||
modalCtx = ctx;
|
||||
const modal = document.getElementById("rules-reason-modal") as HTMLElement;
|
||||
const title = document.getElementById("rules-reason-title") as HTMLElement;
|
||||
const body = document.getElementById("rules-reason-body") as HTMLElement;
|
||||
const extra = document.getElementById("rules-reason-extra") as HTMLElement;
|
||||
const msg = document.getElementById("rules-reason-msg") as HTMLElement;
|
||||
const reasonInput = document.getElementById("rules-reason-text") as HTMLTextAreaElement;
|
||||
msg.style.display = "none";
|
||||
reasonInput.value = "";
|
||||
extra.innerHTML = "";
|
||||
|
||||
if (ctx.kind === "new-rule") {
|
||||
title.textContent = t("admin.rules.modal.new.title") || "Neue Regel anlegen";
|
||||
body.textContent = t("admin.rules.modal.new.body") || "Eine neue Regel wird als Draft angelegt. Bitte einen Grund angeben.";
|
||||
extra.innerHTML = `
|
||||
<div class="form-field">
|
||||
<label for="rules-new-name" data-i18n="admin.rules.modal.field.name">Name (DE)</label>
|
||||
<input type="text" id="rules-new-name" class="admin-rules-input" required minlength="2" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="rules-new-name-en" data-i18n="admin.rules.modal.field.name_en">Name (EN)</label>
|
||||
<input type="text" id="rules-new-name-en" class="admin-rules-input" required minlength="2" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="rules-new-duration" data-i18n="admin.rules.modal.field.duration">Dauer</label>
|
||||
<div class="admin-rules-duration-row">
|
||||
<input type="number" id="rules-new-duration" class="admin-rules-input" min="0" value="0" required />
|
||||
<select id="rules-new-unit" class="admin-rules-select">
|
||||
<option value="days">days</option>
|
||||
<option value="weeks">weeks</option>
|
||||
<option value="months">months</option>
|
||||
<option value="working_days">working_days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
title.textContent = t("admin.rules.modal.resolve.title") || "Orphan zuordnen";
|
||||
body.textContent = t("admin.rules.modal.resolve.body") || "Bitte einen Grund (mind. 10 Zeichen) angeben.";
|
||||
}
|
||||
modal.style.display = "flex";
|
||||
reasonInput.focus();
|
||||
}
|
||||
|
||||
function closeReasonModal() {
|
||||
const modal = document.getElementById("rules-reason-modal") as HTMLElement;
|
||||
modal.style.display = "none";
|
||||
modalCtx = null;
|
||||
}
|
||||
|
||||
async function submitReasonModal(ev: Event) {
|
||||
ev.preventDefault();
|
||||
if (!modalCtx) return;
|
||||
const reasonInput = document.getElementById("rules-reason-text") as HTMLTextAreaElement;
|
||||
const msg = document.getElementById("rules-reason-msg") as HTMLElement;
|
||||
const submit = document.getElementById("rules-reason-submit") as HTMLButtonElement;
|
||||
const reason = reasonInput.value.trim();
|
||||
if (reason.length < 10) {
|
||||
msg.textContent = t("admin.rules.modal.reason.too_short") || "Grund muss mindestens 10 Zeichen enthalten.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
submit.disabled = true;
|
||||
try {
|
||||
if (modalCtx.kind === "new-rule") {
|
||||
const name = (document.getElementById("rules-new-name") as HTMLInputElement).value.trim();
|
||||
const nameEn = (document.getElementById("rules-new-name-en") as HTMLInputElement).value.trim();
|
||||
const duration = parseInt((document.getElementById("rules-new-duration") as HTMLInputElement).value, 10);
|
||||
const unit = (document.getElementById("rules-new-unit") as HTMLSelectElement).value;
|
||||
if (!name || !nameEn) {
|
||||
msg.textContent = t("admin.rules.modal.error.name_required") || "Bitte Name und Name (EN) angeben.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
const resp = await fetch("/admin/api/rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
name_en: nameEn,
|
||||
duration_value: Number.isFinite(duration) ? duration : 0,
|
||||
duration_unit: unit,
|
||||
priority: "mandatory",
|
||||
is_court_set: false,
|
||||
is_spawn: false,
|
||||
sequence_order: 0,
|
||||
reason,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || t("admin.rules.modal.error.create") || "Anlegen fehlgeschlagen.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalCtx.kind === "orphan-resolve") {
|
||||
const resp = await fetch(`/admin/api/orphans/${encodeURIComponent(modalCtx.orphanId)}/resolve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rule_id: modalCtx.ruleId, reason }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || t("admin.rules.modal.error.resolve") || "Zuordnung fehlgeschlagen.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
closeReasonModal();
|
||||
showFeedback(t("admin.rules.orphans.resolved") || "Orphan zugeordnet.", false);
|
||||
await loadOrphans();
|
||||
renderOrphans();
|
||||
}
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPickOrphanCandidate(orphanId: string, ruleId: string) {
|
||||
openReasonModal({ kind: "orphan-resolve", orphanId, ruleId });
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Tabs + filter wiring.
|
||||
// --------------------------------------------------------------------
|
||||
function setActiveTab(name: "rules" | "orphans") {
|
||||
const paneRules = document.getElementById("rules-pane-rules") as HTMLElement;
|
||||
const paneOrphans = document.getElementById("rules-pane-orphans") as HTMLElement;
|
||||
const tabRules = document.getElementById("rules-tab-rules") as HTMLElement;
|
||||
const tabOrphans = document.getElementById("rules-tab-orphans") as HTMLElement;
|
||||
if (name === "rules") {
|
||||
paneRules.style.display = "";
|
||||
paneOrphans.style.display = "none";
|
||||
tabRules.classList.add("active");
|
||||
tabOrphans.classList.remove("active");
|
||||
} else {
|
||||
paneRules.style.display = "none";
|
||||
paneOrphans.style.display = "";
|
||||
tabRules.classList.remove("active");
|
||||
tabOrphans.classList.add("active");
|
||||
renderOrphans();
|
||||
}
|
||||
}
|
||||
|
||||
function wireFilters() {
|
||||
const proc = document.getElementById("rules-filter-proceeding") as HTMLSelectElement;
|
||||
const trig = document.getElementById("rules-filter-trigger") as HTMLSelectElement;
|
||||
const search = document.getElementById("rules-filter-search") as HTMLInputElement;
|
||||
proc.addEventListener("change", async () => {
|
||||
activeProceeding = proc.value;
|
||||
await loadRules();
|
||||
renderRulesTable();
|
||||
});
|
||||
trig.addEventListener("change", async () => {
|
||||
activeTrigger = trig.value;
|
||||
await loadRules();
|
||||
renderRulesTable();
|
||||
});
|
||||
search.addEventListener("input", () => {
|
||||
window.clearTimeout(searchDebounce);
|
||||
searchDebounce = window.setTimeout(async () => {
|
||||
activeQuery = search.value.trim();
|
||||
await loadRules();
|
||||
renderRulesTable();
|
||||
}, 220);
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("#rules-filter-lifecycle .admin-rules-chip").forEach((chip) => {
|
||||
chip.addEventListener("click", async () => {
|
||||
document.querySelectorAll(".admin-rules-chip").forEach((c) => c.classList.remove("active"));
|
||||
chip.classList.add("active");
|
||||
activeLifecycle = chip.dataset.state || "";
|
||||
await loadRules();
|
||||
renderRulesTable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireTabs() {
|
||||
(document.getElementById("rules-tab-rules") as HTMLElement).addEventListener("click", () => setActiveTab("rules"));
|
||||
(document.getElementById("rules-tab-orphans") as HTMLElement).addEventListener("click", () => setActiveTab("orphans"));
|
||||
}
|
||||
|
||||
function wireModal() {
|
||||
(document.getElementById("rules-new-btn") as HTMLElement).addEventListener("click", () => openReasonModal({ kind: "new-rule" }));
|
||||
(document.getElementById("rules-reason-cancel") as HTMLElement).addEventListener("click", closeReasonModal);
|
||||
(document.getElementById("rules-reason-close") as HTMLElement).addEventListener("click", closeReasonModal);
|
||||
(document.getElementById("rules-reason-form") as HTMLFormElement).addEventListener("submit", submitReasonModal);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
wireFilters();
|
||||
wireTabs();
|
||||
wireModal();
|
||||
await Promise.all([loadProceedings(), loadTriggerEvents()]);
|
||||
await Promise.all([loadRules(), loadOrphans()]);
|
||||
renderRulesTable();
|
||||
// Re-render proceeding labels when language changes
|
||||
onLangChange(() => {
|
||||
renderRulesTable();
|
||||
renderOrphans();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
@@ -1,16 +1,23 @@
|
||||
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
|
||||
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7,
|
||||
// retrofitted onto the unified modal primitive in t-paliad-217 Slice D).
|
||||
//
|
||||
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
|
||||
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
|
||||
// collects subject + body + (optional) template and posts to
|
||||
// /api/team/broadcast. On success it shows a per-recipient send report
|
||||
// and closes.
|
||||
// and closes after a short delay.
|
||||
//
|
||||
// Per-recipient privacy: each member receives their own envelope. The
|
||||
// modal lists every addressee so the sender knows exactly who will be
|
||||
// mailed; there is no surprise to-line.
|
||||
//
|
||||
// Migration notes (t-paliad-217 Slice D): the shell, ESC, backdrop,
|
||||
// close button, and browser back-button are now owned by openModal().
|
||||
// The body is built imperatively so the submit handler can read form
|
||||
// state from the modal-body element it constructed.
|
||||
|
||||
import { t } from "./i18n";
|
||||
import { openModal } from "./components/modal";
|
||||
|
||||
export interface BroadcastRecipient {
|
||||
user_id: string;
|
||||
@@ -35,6 +42,12 @@ interface EmailTemplateOption {
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
interface BroadcastResult {
|
||||
sent: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const RECIPIENT_CAP = 100;
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -78,69 +91,32 @@ export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing modal? Remove. Avoids stacking on rapid double-click.
|
||||
document.getElementById("broadcast-modal")?.remove();
|
||||
const body = renderBody(args);
|
||||
wireBody(body);
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "broadcast-modal";
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = renderShell(args);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Close handlers
|
||||
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
document.addEventListener("keydown", function escClose(e) {
|
||||
if (e.key === "Escape") {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", escClose);
|
||||
}
|
||||
});
|
||||
|
||||
// Recipient toggle
|
||||
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
||||
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
||||
if (!list) return;
|
||||
list.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
// Template dropdown
|
||||
const templateSelect = overlay.querySelector<HTMLSelectElement>("[data-broadcast-template]");
|
||||
templateSelect?.addEventListener("change", async () => {
|
||||
const key = templateSelect.value;
|
||||
if (!key) return;
|
||||
const lang = (document.documentElement.lang || "de") as "de" | "en";
|
||||
try {
|
||||
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
|
||||
if (!res.ok) return;
|
||||
const tpl = (await res.json()) as EmailTemplateOption;
|
||||
const subjectInput = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
|
||||
const bodyInput = overlay.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
|
||||
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
|
||||
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
|
||||
} catch {
|
||||
/* template load failure is non-fatal — sender keeps freeform mode. */
|
||||
}
|
||||
});
|
||||
|
||||
// Submit
|
||||
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(form, overlay, args);
|
||||
void openModal<BroadcastResult>({
|
||||
title: t("team.broadcast.title") || "E-Mail an Auswahl",
|
||||
body,
|
||||
size: "lg",
|
||||
primary: {
|
||||
label: `${t("team.broadcast.send") || "Senden"} (${args.recipients.length})`,
|
||||
handler: async (close) => {
|
||||
await onSubmit(body, args, close);
|
||||
},
|
||||
},
|
||||
secondary: { label: t("common.cancel") || "Abbrechen" },
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(args: OpenBroadcastModalArgs): string {
|
||||
function renderBody(args: OpenBroadcastModalArgs): HTMLElement {
|
||||
const root = document.createElement("div");
|
||||
root.className = "broadcast-body";
|
||||
const count = args.recipients.length;
|
||||
const previewItems = args.recipients
|
||||
.slice(0, 5)
|
||||
.map((r) => esc(r.display_name) + " <" + esc(r.email) + ">")
|
||||
.join(", ");
|
||||
const more = count > 5 ? ` +${count - 5}` : "";
|
||||
|
||||
const fullList = args.recipients
|
||||
.map(
|
||||
(r) =>
|
||||
@@ -150,65 +126,89 @@ function renderShell(args: OpenBroadcastModalArgs): string {
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
|
||||
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">×</button>
|
||||
</header>
|
||||
<form data-broadcast-form>
|
||||
<div class="modal-body">
|
||||
<div class="broadcast-recipient-summary">
|
||||
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
||||
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
||||
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
|
||||
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
|
||||
</a>
|
||||
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
||||
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
||||
<ul>${fullList}</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
|
||||
<select id="broadcast-template-select" data-broadcast-template>
|
||||
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
|
||||
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
|
||||
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
|
||||
</select>
|
||||
|
||||
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
|
||||
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
|
||||
|
||||
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
|
||||
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
|
||||
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
|
||||
</p>
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
|
||||
</p>
|
||||
|
||||
<div class="broadcast-error hidden" data-broadcast-error></div>
|
||||
<div class="broadcast-success hidden" data-broadcast-success></div>
|
||||
</div>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
|
||||
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
|
||||
</footer>
|
||||
</form>
|
||||
root.innerHTML = `
|
||||
<div class="broadcast-recipient-summary">
|
||||
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
||||
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
||||
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
|
||||
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
|
||||
</a>
|
||||
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
||||
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
||||
<ul>${fullList}</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
|
||||
<select id="broadcast-template-select" data-broadcast-template>
|
||||
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
|
||||
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
|
||||
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
|
||||
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
|
||||
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
|
||||
</div>
|
||||
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
|
||||
</p>
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
|
||||
</p>
|
||||
|
||||
<div class="broadcast-error hidden" data-broadcast-error></div>
|
||||
<div class="broadcast-success hidden" data-broadcast-success></div>
|
||||
`;
|
||||
return root;
|
||||
}
|
||||
|
||||
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
|
||||
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
|
||||
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
|
||||
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
|
||||
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
|
||||
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
|
||||
function wireBody(body: HTMLElement): void {
|
||||
// Recipient list toggle.
|
||||
body.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
||||
const list = body.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
||||
if (!list) return;
|
||||
list.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
// Template dropdown — populates subject/body from the selected template.
|
||||
const templateSelect = body.querySelector<HTMLSelectElement>("[data-broadcast-template]");
|
||||
templateSelect?.addEventListener("change", async () => {
|
||||
const key = templateSelect.value;
|
||||
if (!key) return;
|
||||
const lang = (document.documentElement.lang || "de") as "de" | "en";
|
||||
try {
|
||||
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
|
||||
if (!res.ok) return;
|
||||
const tpl = (await res.json()) as EmailTemplateOption;
|
||||
const subjectInput = body.querySelector<HTMLInputElement>("[data-broadcast-subject]");
|
||||
const bodyInput = body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
|
||||
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
|
||||
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
|
||||
} catch {
|
||||
/* template load failure is non-fatal — sender keeps freeform mode. */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit(
|
||||
body: HTMLElement,
|
||||
args: OpenBroadcastModalArgs,
|
||||
close: (result: BroadcastResult) => void,
|
||||
): Promise<void> {
|
||||
const subject = (body.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
|
||||
const bodyText = (body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
|
||||
const templateKey = body.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
|
||||
const errEl = body.querySelector<HTMLDivElement>("[data-broadcast-error]");
|
||||
const okEl = body.querySelector<HTMLDivElement>("[data-broadcast-success]");
|
||||
errEl?.classList.add("hidden");
|
||||
okEl?.classList.add("hidden");
|
||||
|
||||
@@ -216,17 +216,15 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
if (!body) {
|
||||
if (!bodyText) {
|
||||
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
|
||||
}
|
||||
|
||||
// The modal primary button lives in the footer (owned by openModal),
|
||||
// not in the body. We surface "sending..." feedback via the in-body
|
||||
// success/error areas; the primary button stays clickable but the
|
||||
// server-side idempotency + RECIPIENT_CAP make double-clicks safe.
|
||||
const recipientFilter: Record<string, unknown> = {};
|
||||
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
|
||||
if (args.projectID) recipientFilter.project_id = args.projectID;
|
||||
@@ -242,7 +240,7 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
body: JSON.stringify({
|
||||
project_id: args.projectID ?? null,
|
||||
subject,
|
||||
body,
|
||||
body: bodyText,
|
||||
template_key: templateKey || undefined,
|
||||
lang,
|
||||
recipient_filter: recipientFilter,
|
||||
@@ -252,13 +250,9 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
|
||||
showError(errEl, (errBody as { error?: string }).error || "Send failed");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const report = (await res.json()) as { sent: number; failed: number; total: number };
|
||||
const report = (await res.json()) as BroadcastResult;
|
||||
if (okEl) {
|
||||
okEl.classList.remove("hidden");
|
||||
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
|
||||
@@ -267,17 +261,10 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
.replace("{total}", String(report.total))
|
||||
.replace("{failed}", String(report.failed));
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
|
||||
}
|
||||
setTimeout(() => overlay.remove(), 2500);
|
||||
// Give the sender a moment to see the report, then close.
|
||||
setTimeout(() => close(report), 2500);
|
||||
} catch (e) {
|
||||
showError(errEl, String(e));
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
405
frontend/src/client/components/approval-edit-modal.ts
Normal file
405
frontend/src/client/components/approval-edit-modal.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
// t-paliad-216 Slice B (initial) + t-paliad-217 Slice C (rewrite) —
|
||||
// modal for the "Suggest changes" approval action.
|
||||
//
|
||||
// The approver authors a counter-proposal: edits any field on the
|
||||
// underlying deadline / appointment AND/OR leaves a free-text note. On
|
||||
// submit the caller POSTs to /api/approval-requests/{id}/suggest-changes,
|
||||
// which closes the OLD row as `changes_requested` and spawns a NEW pending
|
||||
// row authored by the approver carrying counter_payload as its payload.
|
||||
//
|
||||
// Scope (t-paliad-217 m's Q1 Reading A — 2026-05-20):
|
||||
// - Every editable field on the entity is in the form, not just the
|
||||
// date allowlist that triggers approval (t-paliad-138 §Q4). The
|
||||
// backend's counter-allowlist (buildCounterSetClauses in
|
||||
// approval_service.go) accepts the wider set:
|
||||
// deadline: title, due_date, original_due_date, warning_date,
|
||||
// description, notes, rule_code, event_type_ids
|
||||
// appointment: title, start_at, end_at, description, location,
|
||||
// appointment_type
|
||||
// - Lifecycle restriction: update-only. shape-list.ts hides the
|
||||
// suggest_changes button for create / complete / delete; this modal
|
||||
// refuses to open on them as defence-in-depth.
|
||||
//
|
||||
// Built on the unified openModal() primitive (t-paliad-217 Slice A) —
|
||||
// the primitive owns ESC, focus, backdrop, close button, browser
|
||||
// back-button, mobile takeover. This module only constructs the body.
|
||||
//
|
||||
// API:
|
||||
// const result = await openApprovalEditModal({
|
||||
// entityType: "deadline",
|
||||
// lifecycleEvent: "update",
|
||||
// payload: {...}, // requester's proposed values (= current entity row)
|
||||
// preImage: {...}, // pre-mutation values (for "vorher" diff hints)
|
||||
// });
|
||||
// if (result) {
|
||||
// // result.counterPayload + result.note ready to POST
|
||||
// } else {
|
||||
// // user cancelled
|
||||
// }
|
||||
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
fetchEventTypes,
|
||||
type PickerHandle,
|
||||
} from "../event-types";
|
||||
import { openModal } from "./modal";
|
||||
|
||||
export interface ApprovalEditModalArgs {
|
||||
entityType: "deadline" | "appointment";
|
||||
lifecycleEvent: string;
|
||||
payload: Record<string, unknown> | null;
|
||||
preImage: Record<string, unknown> | null;
|
||||
// Optional context for the read-only context section. The caller can
|
||||
// hydrate these from the row's API response (project_title,
|
||||
// requester_name, requested_at) when available; the modal degrades
|
||||
// gracefully when they're missing.
|
||||
projectTitle?: string;
|
||||
requesterName?: string;
|
||||
requestedAt?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalEditModalResult {
|
||||
counterPayload: Record<string, unknown>;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// FieldSpec — one editable input row. The type determines the <input>
|
||||
// (or <textarea>) shape; getValue / setValue normalise the form-element
|
||||
// value to the server-friendly counter_payload shape.
|
||||
interface FieldSpec {
|
||||
key: string;
|
||||
labelKey: string; // i18n key
|
||||
inputType: "text" | "date" | "datetime-local" | "textarea";
|
||||
// Required = title (NOT NULL on the column). Other fields are nullable;
|
||||
// empty string clears (server's addText helper handles this).
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const DEADLINE_FIELDS: ReadonlyArray<FieldSpec> = [
|
||||
{ key: "title", labelKey: "deadlines.field.title", inputType: "text", required: true },
|
||||
{ key: "due_date", labelKey: "deadlines.field.due", inputType: "date" },
|
||||
{ key: "original_due_date", labelKey: "approvals.suggest.field.original_due_date", inputType: "date" },
|
||||
{ key: "warning_date", labelKey: "approvals.suggest.field.warning_date", inputType: "date" },
|
||||
{ key: "rule_code", labelKey: "approvals.suggest.field.rule_code", inputType: "text" },
|
||||
{ key: "description", labelKey: "approvals.suggest.field.description", inputType: "textarea" },
|
||||
{ key: "notes", labelKey: "deadlines.field.notes", inputType: "textarea" },
|
||||
];
|
||||
|
||||
const APPOINTMENT_FIELDS: ReadonlyArray<FieldSpec> = [
|
||||
{ key: "title", labelKey: "appointments.field.title", inputType: "text", required: true },
|
||||
{ key: "start_at", labelKey: "appointments.field.start", inputType: "datetime-local" },
|
||||
{ key: "end_at", labelKey: "appointments.field.end", inputType: "datetime-local" },
|
||||
{ key: "location", labelKey: "appointments.field.location", inputType: "text" },
|
||||
{ key: "appointment_type", labelKey: "appointments.field.type", inputType: "text" },
|
||||
{ key: "description", labelKey: "appointments.field.description", inputType: "textarea" },
|
||||
];
|
||||
|
||||
export async function openApprovalEditModal(
|
||||
args: ApprovalEditModalArgs,
|
||||
): Promise<ApprovalEditModalResult | null> {
|
||||
if (args.lifecycleEvent !== "update") {
|
||||
window.alert(t("approvals.suggest.unsupported_lifecycle"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields = args.entityType === "deadline" ? DEADLINE_FIELDS : APPOINTMENT_FIELDS;
|
||||
const original = (args.payload ?? {}) as Record<string, unknown>;
|
||||
const preImage = (args.preImage ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Build the body element imperatively so we can wire input handlers
|
||||
// before openModal mounts the dialog.
|
||||
const body = document.createElement("div");
|
||||
body.className = "approval-suggest-body";
|
||||
|
||||
body.appendChild(renderIntro());
|
||||
body.appendChild(renderFieldsSection(fields, original, preImage));
|
||||
|
||||
// event_type_ids picker (deadline-only) — async because the picker
|
||||
// needs to fetch the firm's event-type catalogue. We attach a host
|
||||
// element synchronously and populate it once the fetch returns.
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let eventTypePickerLoaded = false;
|
||||
if (args.entityType === "deadline") {
|
||||
const pickerSection = renderEventTypePickerSection();
|
||||
body.appendChild(pickerSection.section);
|
||||
void (async () => {
|
||||
try {
|
||||
await fetchEventTypes();
|
||||
eventTypePicker = attachEventTypePicker(pickerSection.host, {
|
||||
initialIDs: (original.event_type_ids as string[] | undefined) ?? [],
|
||||
});
|
||||
eventTypePickerLoaded = true;
|
||||
} catch (_e) {
|
||||
// Fail-soft: leave the section empty; counter still works
|
||||
// without event_type_ids in the payload.
|
||||
pickerSection.host.textContent = t("approvals.suggest.event_type_picker_unavailable");
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
body.appendChild(renderContextSection(args, original));
|
||||
const noteEl = renderNoteSection();
|
||||
body.appendChild(noteEl.section);
|
||||
|
||||
// Read inputs back at submit time. The same list is what we listen to
|
||||
// for the dirty-state gate.
|
||||
const fieldInputs = Array.from(
|
||||
body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>("[data-suggest-field]"),
|
||||
);
|
||||
|
||||
return openModal<ApprovalEditModalResult>({
|
||||
title: `${t("approvals.suggest.modal_title")} — ${t(("approvals.entity." + args.entityType) as never)}`,
|
||||
body,
|
||||
size: "lg",
|
||||
primary: {
|
||||
label: t("approvals.suggest.submit"),
|
||||
handler: (close) => {
|
||||
const result = buildResult(fieldInputs, noteEl.textarea, original, eventTypePicker, eventTypePickerLoaded);
|
||||
if (!result.dirty && !result.note) {
|
||||
// Server enforces too. Client-side guard avoids the 400 round-trip.
|
||||
window.alert(t("approvals.suggest.submit_disabled_hint"));
|
||||
return;
|
||||
}
|
||||
close({
|
||||
counterPayload: result.counterPayload,
|
||||
note: result.note,
|
||||
});
|
||||
},
|
||||
},
|
||||
secondary: { label: t("approvals.suggest.cancel") },
|
||||
});
|
||||
}
|
||||
|
||||
function renderIntro(): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
p.className = "approval-suggest-intro muted";
|
||||
p.textContent = t("approvals.suggest.intro");
|
||||
return p;
|
||||
}
|
||||
|
||||
function renderFieldsSection(
|
||||
fields: ReadonlyArray<FieldSpec>,
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--editable";
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("approvals.suggest.section.editable");
|
||||
section.appendChild(h);
|
||||
|
||||
for (const f of fields) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-field approval-suggest-field";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = t(f.labelKey as never);
|
||||
wrap.appendChild(label);
|
||||
|
||||
const value = formatFieldForInput(original[f.key], f.inputType);
|
||||
|
||||
let input: HTMLInputElement | HTMLTextAreaElement;
|
||||
if (f.inputType === "textarea") {
|
||||
input = document.createElement("textarea");
|
||||
input.rows = 3;
|
||||
(input as HTMLTextAreaElement).value = value;
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
(input as HTMLInputElement).type = f.inputType;
|
||||
(input as HTMLInputElement).value = value;
|
||||
}
|
||||
input.dataset.suggestField = f.key;
|
||||
input.dataset.suggestOriginal = value;
|
||||
input.dataset.suggestInputType = f.inputType;
|
||||
if (f.required) input.required = true;
|
||||
|
||||
// Wire the <label> to focus the <input> on click.
|
||||
const inputID = `suggest-field-${f.key}`;
|
||||
input.id = inputID;
|
||||
label.setAttribute("for", inputID);
|
||||
|
||||
wrap.appendChild(input);
|
||||
|
||||
// "Vorher" hint when pre_image carries a distinct value for this field.
|
||||
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
|
||||
if (preVal && preVal !== value) {
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "approval-suggest-prehint";
|
||||
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
|
||||
wrap.appendChild(hint);
|
||||
}
|
||||
|
||||
section.appendChild(wrap);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderEventTypePickerSection(): { section: HTMLElement; host: HTMLElement } {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--editable";
|
||||
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("deadlines.field.event_type");
|
||||
section.appendChild(h);
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.className = "approval-suggest-event-type-picker";
|
||||
section.appendChild(host);
|
||||
|
||||
return { section, host };
|
||||
}
|
||||
|
||||
function renderContextSection(
|
||||
args: ApprovalEditModalArgs,
|
||||
original: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--context";
|
||||
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("approvals.suggest.section.context");
|
||||
section.appendChild(h);
|
||||
|
||||
const rows: Array<[string, string]> = [];
|
||||
if (args.projectTitle) {
|
||||
rows.push([t("approvals.suggest.context.project"), args.projectTitle]);
|
||||
}
|
||||
if (args.requesterName) {
|
||||
rows.push([t("approvals.suggest.context.requester"), args.requesterName]);
|
||||
}
|
||||
if (args.requestedAt) {
|
||||
rows.push([t("approvals.suggest.context.requested_at"), formatDateForDisplay(args.requestedAt)]);
|
||||
}
|
||||
// Approval status — entity row's current approval_status (typically
|
||||
// "pending" while the modal is open, but display the requester's
|
||||
// perspective for completeness).
|
||||
const approvalStatus = original.approval_status as string | undefined;
|
||||
if (approvalStatus) {
|
||||
rows.push([
|
||||
t("approvals.suggest.context.approval_status"),
|
||||
t(("approvals.status." + approvalStatus) as never) || approvalStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
section.style.display = "none";
|
||||
return section;
|
||||
}
|
||||
|
||||
const dl = document.createElement("dl");
|
||||
dl.className = "approval-suggest-context-grid";
|
||||
for (const [label, value] of rows) {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = label;
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = value;
|
||||
dl.appendChild(dt);
|
||||
dl.appendChild(dd);
|
||||
}
|
||||
section.appendChild(dl);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderNoteSection(): { section: HTMLElement; textarea: HTMLTextAreaElement } {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--note";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-field approval-suggest-note";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = t("approvals.suggest.note_label");
|
||||
label.setAttribute("for", "suggest-note");
|
||||
wrap.appendChild(label);
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.id = "suggest-note";
|
||||
textarea.rows = 3;
|
||||
textarea.placeholder = t("approvals.suggest.note_placeholder");
|
||||
textarea.dataset.suggestNote = "true";
|
||||
wrap.appendChild(textarea);
|
||||
|
||||
section.appendChild(wrap);
|
||||
return { section, textarea };
|
||||
}
|
||||
|
||||
interface BuildResult {
|
||||
counterPayload: Record<string, unknown>;
|
||||
note: string;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
fieldInputs: ReadonlyArray<HTMLInputElement | HTMLTextAreaElement>,
|
||||
noteEl: HTMLTextAreaElement,
|
||||
original: Record<string, unknown>,
|
||||
eventTypePicker: PickerHandle | null,
|
||||
eventTypePickerLoaded: boolean,
|
||||
): BuildResult {
|
||||
const counterPayload: Record<string, unknown> = {};
|
||||
let dirty = false;
|
||||
|
||||
for (const el of fieldInputs) {
|
||||
const key = el.dataset.suggestField || "";
|
||||
const orig = el.dataset.suggestOriginal || "";
|
||||
const inputType = el.dataset.suggestInputType || "text";
|
||||
if (el.value === orig) continue;
|
||||
counterPayload[key] = formatFieldForServer(el.value, inputType);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (eventTypePicker && eventTypePickerLoaded) {
|
||||
const currentIDs = eventTypePicker.getIDs().slice().sort();
|
||||
const originalIDs = ((original.event_type_ids as string[] | undefined) ?? []).slice().sort();
|
||||
if (currentIDs.length !== originalIDs.length
|
||||
|| currentIDs.some((id, i) => id !== originalIDs[i])) {
|
||||
counterPayload.event_type_ids = currentIDs;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
counterPayload,
|
||||
note: noteEl.value.trim(),
|
||||
dirty,
|
||||
};
|
||||
}
|
||||
|
||||
// formatFieldForInput — convert a server-side payload value to the format
|
||||
// the <input> wants. Dates round-trip as YYYY-MM-DD; datetime-local wants
|
||||
// YYYY-MM-DDTHH:MM. Server returns ISO 8601 / RFC 3339 timestamps; we
|
||||
// trim to the local-input shape. Text passes through verbatim.
|
||||
function formatFieldForInput(v: unknown, inputType: string): string {
|
||||
if (v == null) return "";
|
||||
const s = String(v);
|
||||
if (inputType === "date") {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
return m ? m[1] : s;
|
||||
}
|
||||
if (inputType === "datetime-local") {
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/);
|
||||
return m ? `${m[1]}T${m[2]}` : s;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// formatFieldForServer — convert input value back to server-friendly
|
||||
// shape. Empty string means "clear this nullable field"; the server's
|
||||
// addText helper writes NULL for "". Required fields (title) reach the
|
||||
// server's non-empty CHECK on the column, which surfaces as a 400.
|
||||
function formatFieldForServer(value: string, inputType: string): unknown {
|
||||
if (inputType === "date" || inputType === "datetime-local") {
|
||||
return value || null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatDateForDisplay(iso: string): string {
|
||||
const d = Date.parse(iso);
|
||||
if (isNaN(d)) return iso;
|
||||
return new Date(d).toLocaleString();
|
||||
}
|
||||
200
frontend/src/client/components/modal.ts
Normal file
200
frontend/src/client/components/modal.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
// Unified modal primitive — t-paliad-217.
|
||||
//
|
||||
// Native <dialog>-backed. The browser handles top-layer stacking, ESC,
|
||||
// ARIA, and focus trap. We layer back-button integration and focus
|
||||
// restoration on top so the modal behaves consistently on desktop and on
|
||||
// the iPhone PWA (m's checking surface).
|
||||
//
|
||||
// API:
|
||||
// const result = await openModal<MyResult>({
|
||||
// title: "…",
|
||||
// body: htmlStringOrElement,
|
||||
// primary: { label: "Speichern", handler: (close) => { close(result); } },
|
||||
// secondary: { label: "Abbrechen" }, // optional, defaults to "Abbrechen"
|
||||
// size: "sm" | "md" | "lg" | "full", // optional, defaults to "md"
|
||||
// onClose: () => { /* … */ },
|
||||
// classNames: "extra css classes on the <dialog>",
|
||||
// });
|
||||
// // result is the value passed to close(), or null if the user
|
||||
// // dismissed via ESC / backdrop / secondary / browser back-button.
|
||||
//
|
||||
// All dismiss paths are unified: ESC, backdrop click, secondary button,
|
||||
// the always-rendered close (×) button, and the browser back-button all
|
||||
// resolve the promise with null. Programmatic close from the primary
|
||||
// handler resolves with whatever was passed.
|
||||
//
|
||||
// Migration target: call sites that currently roll their own
|
||||
// modal-overlay + ESC handler + focus management replace all of it with
|
||||
// one openModal() call. broadcast.ts and approval-edit-modal.ts are the
|
||||
// first two call sites (t-paliad-217 Slices C + D); the other ~5 legacy
|
||||
// modals migrate in follow-up PRs.
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
export interface ModalConfig<T> {
|
||||
title: string;
|
||||
// body can be either a pre-built HTMLElement (the caller assembled the
|
||||
// DOM and may have local references for read-back) or an HTML string
|
||||
// (caller is responsible for escaping). Element is preferred when the
|
||||
// caller needs to read form state on submit.
|
||||
body: HTMLElement | string;
|
||||
primary: {
|
||||
label: string;
|
||||
handler: (close: (result: T) => void) => void | Promise<void>;
|
||||
};
|
||||
// secondary defaults to a Cancel button that just dismisses. Pass null
|
||||
// explicitly to suppress (rare — primary-only modals like a confirmation
|
||||
// toast).
|
||||
secondary?: { label: string } | null;
|
||||
size?: "sm" | "md" | "lg" | "full";
|
||||
// onClose fires on EVERY dismiss path (including primary handler
|
||||
// resolution). Use for analytics / dirty-state warnings.
|
||||
onClose?: () => void;
|
||||
classNames?: string;
|
||||
}
|
||||
|
||||
// openModal returns a promise that resolves with the value passed to
|
||||
// close() inside the primary handler, or null if the user dismissed via
|
||||
// any other path. Always non-throwing — the primary handler decides
|
||||
// whether to surface errors via its own UI (e.g. inline form errors)
|
||||
// rather than rejecting the promise.
|
||||
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
// Record + restore focus to whatever was focused before the modal
|
||||
// opened. Native <dialog> does NOT do this automatically.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = ["modal", config.classNames].filter(Boolean).join(" ");
|
||||
dialog.dataset.size = config.size ?? "md";
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.className = "modal__header";
|
||||
const titleEl = document.createElement("h2");
|
||||
titleEl.className = "modal__title";
|
||||
titleEl.textContent = config.title;
|
||||
header.appendChild(titleEl);
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.type = "button";
|
||||
closeBtn.className = "modal__close";
|
||||
closeBtn.setAttribute("aria-label", t("modal.close.label"));
|
||||
closeBtn.textContent = "×"; // ×
|
||||
header.appendChild(closeBtn);
|
||||
dialog.appendChild(header);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "modal__body";
|
||||
if (typeof config.body === "string") {
|
||||
body.innerHTML = config.body;
|
||||
} else {
|
||||
body.appendChild(config.body);
|
||||
}
|
||||
dialog.appendChild(body);
|
||||
|
||||
const footer = document.createElement("footer");
|
||||
footer.className = "modal__footer";
|
||||
const secondaryCfg = config.secondary === null
|
||||
? null
|
||||
: config.secondary ?? { label: t("common.cancel") };
|
||||
let secondaryBtn: HTMLButtonElement | null = null;
|
||||
if (secondaryCfg) {
|
||||
secondaryBtn = document.createElement("button");
|
||||
secondaryBtn.type = "button";
|
||||
secondaryBtn.className = "btn btn-ghost modal__secondary";
|
||||
secondaryBtn.textContent = secondaryCfg.label;
|
||||
footer.appendChild(secondaryBtn);
|
||||
}
|
||||
const primaryBtn = document.createElement("button");
|
||||
primaryBtn.type = "button";
|
||||
primaryBtn.className = "btn btn-primary modal__primary";
|
||||
primaryBtn.textContent = config.primary.label;
|
||||
footer.appendChild(primaryBtn);
|
||||
dialog.appendChild(footer);
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
// History integration (Q5): push a synthetic history state so the
|
||||
// browser back-button closes the modal instead of leaving the page.
|
||||
// We pop the state in finish() unless popstate already fired it.
|
||||
let historyEntryActive = false;
|
||||
try {
|
||||
history.pushState({ paliadModalOpen: true }, "");
|
||||
historyEntryActive = true;
|
||||
} catch (_e) {
|
||||
// pushState may throw in obscure embedded contexts; degrade gracefully.
|
||||
}
|
||||
|
||||
// resolved guards against double-resolution (e.g. ESC fires + then a
|
||||
// microtask-deferred primary handler also calls close).
|
||||
let resolved = false;
|
||||
|
||||
const finish = (value: T | null) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
|
||||
window.removeEventListener("popstate", onPopState);
|
||||
|
||||
// Pop our history entry if it's still on the stack. Skip when the
|
||||
// popstate listener already fired (otherwise we'd go back twice).
|
||||
if (historyEntryActive) {
|
||||
historyEntryActive = false;
|
||||
try { history.back(); } catch (_e) { /* same fallback as pushState */ }
|
||||
}
|
||||
|
||||
// Native dialog close. Use the close event's default rather than
|
||||
// the cancel event so we don't fight the browser's own dismissal.
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
|
||||
// Restore focus to whatever the user was on before. The dialog
|
||||
// teardown happens synchronously so the focus call lands on a
|
||||
// live element.
|
||||
if (previouslyFocused && document.body.contains(previouslyFocused)) {
|
||||
previouslyFocused.focus();
|
||||
}
|
||||
|
||||
config.onClose?.();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const close = (result: T) => finish(result);
|
||||
|
||||
// Dismiss paths.
|
||||
closeBtn.addEventListener("click", () => finish(null));
|
||||
secondaryBtn?.addEventListener("click", () => finish(null));
|
||||
dialog.addEventListener("click", (e) => {
|
||||
// Backdrop click — only when the click landed on the dialog element
|
||||
// itself (not on a child). Browsers report dialog.click events
|
||||
// through the backdrop too because the backdrop is conceptually
|
||||
// part of the dialog's box.
|
||||
if (e.target === dialog) finish(null);
|
||||
});
|
||||
// <dialog>'s cancel event fires on ESC. preventDefault stops the
|
||||
// browser's default close so we can run our finish() (history pop,
|
||||
// focus restore, onClose, resolve).
|
||||
dialog.addEventListener("cancel", (e) => {
|
||||
e.preventDefault();
|
||||
finish(null);
|
||||
});
|
||||
const onPopState = () => {
|
||||
// Browser back-button. Our history entry is gone by the time this
|
||||
// fires, so skip the history.back() in finish().
|
||||
historyEntryActive = false;
|
||||
finish(null);
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
|
||||
// Primary action.
|
||||
primaryBtn.addEventListener("click", () => {
|
||||
const result = config.primary.handler(close);
|
||||
// Allow async primary handlers (handler returns a promise) — we
|
||||
// don't wait for it explicitly; the handler is responsible for
|
||||
// calling close() when ready.
|
||||
void result;
|
||||
});
|
||||
|
||||
// Open the dialog in the top layer. showModal activates ARIA
|
||||
// role="dialog" + aria-modal=true + focus trap + backdrop.
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
@@ -65,14 +65,60 @@ interface DashboardData {
|
||||
upcoming_deadlines: UpcomingDeadline[];
|
||||
upcoming_appointments: UpcomingAppointment[];
|
||||
recent_activity: ActivityEntry[];
|
||||
inbox_summary?: InboxSummary;
|
||||
}
|
||||
|
||||
interface InboxEntry {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_title?: string | null;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
requested_at: string;
|
||||
requester_id: string;
|
||||
requester_name: string;
|
||||
}
|
||||
|
||||
interface InboxSummary {
|
||||
pending_count: number;
|
||||
top: InboxEntry[];
|
||||
}
|
||||
|
||||
// DashboardLayoutSpec mirrors the Go shape in
|
||||
// internal/services/dashboard_layout_spec.go. The client treats the spec
|
||||
// as advice: unknown widget keys are dropped silently (server is the
|
||||
// source of truth for the catalog).
|
||||
interface DashboardWidgetRef {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
settings?: { count?: number; horizon_days?: number };
|
||||
}
|
||||
interface DashboardLayoutSpec {
|
||||
v: number;
|
||||
widgets: DashboardWidgetRef[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PALIAD_DASHBOARD__?: DashboardData | null;
|
||||
__PALIAD_DASHBOARD_LAYOUT__?: DashboardLayoutSpec | null;
|
||||
__PALIAD_DASHBOARD_CATALOG__?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
let currentLayout: DashboardLayoutSpec | null = null;
|
||||
|
||||
// settingsFor returns the (possibly-empty) settings blob for a given
|
||||
// widget key in the active layout. Falls back to an empty object so
|
||||
// renderers can read `.count ?? defaultN` without null checks.
|
||||
function settingsFor(key: string): { count?: number; horizon_days?: number } {
|
||||
if (!currentLayout) return {};
|
||||
for (const w of currentLayout.widgets) {
|
||||
if (w.key === key) return w.settings ?? {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
// 30-day look-ahead matches the agenda.tsx default chip and the server's
|
||||
// default `to=today+30d` window — keeps the inline agenda visually
|
||||
@@ -110,7 +156,13 @@ function render(): void {
|
||||
renderAppointments(data.upcoming_appointments);
|
||||
renderAgenda();
|
||||
renderActivity(data.recent_activity);
|
||||
renderInbox(data.inbox_summary ?? { pending_count: 0, top: [] });
|
||||
toggleOnboardingHint(data.user);
|
||||
// Apply the saved layout AFTER renderers so the per-widget settings
|
||||
// applied above (count truncation, horizon filtering) are stable
|
||||
// before we toggle visibility + reorder. Failing to find the layout
|
||||
// is non-fatal — the factory default markup order takes over.
|
||||
applyLayout();
|
||||
}
|
||||
|
||||
function renderGreeting(user: DashboardUser | null): void {
|
||||
@@ -162,6 +214,13 @@ function renderDeadlines(items: UpcomingDeadline[]): void {
|
||||
const list = document.getElementById("dashboard-deadlines-list")!;
|
||||
const empty = document.getElementById("dashboard-deadlines-empty")!;
|
||||
|
||||
// Per-widget settings: truncate by count + filter by horizon. Backend
|
||||
// returns 40 rows / 60d; the widget settings narrow it. Defaults match
|
||||
// the catalog (10 rows, 30 days).
|
||||
const s = settingsFor("upcoming-deadlines");
|
||||
items = filterByHorizonDays(items, s.horizon_days ?? 30, (d) => d.due_date);
|
||||
items = items.slice(0, s.count ?? 10);
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
@@ -191,6 +250,10 @@ function renderAppointments(items: UpcomingAppointment[]): void {
|
||||
const list = document.getElementById("dashboard-appointments-list")!;
|
||||
const empty = document.getElementById("dashboard-appointments-empty")!;
|
||||
|
||||
const s = settingsFor("upcoming-appointments");
|
||||
items = filterByHorizonDays(items, s.horizon_days ?? 30, (a) => a.start_at);
|
||||
items = items.slice(0, s.count ?? 10);
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
@@ -226,6 +289,9 @@ function renderActivity(items: ActivityEntry[]): void {
|
||||
const list = document.getElementById("dashboard-activity-list")!;
|
||||
const empty = document.getElementById("dashboard-activity-empty")!;
|
||||
|
||||
const s = settingsFor("recent-activity");
|
||||
items = items.slice(0, s.count ?? 10);
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
@@ -344,8 +410,10 @@ function renderAgenda(): void {
|
||||
}
|
||||
|
||||
async function loadAgenda(): Promise<void> {
|
||||
const s = settingsFor("inline-agenda");
|
||||
const horizon = s.horizon_days ?? AGENDA_LOOKAHEAD_DAYS;
|
||||
const from = toAgendaDate(startOfToday());
|
||||
const to = toAgendaDate(addDays(startOfToday(), AGENDA_LOOKAHEAD_DAYS - 1));
|
||||
const to = toAgendaDate(addDays(startOfToday(), horizon - 1));
|
||||
try {
|
||||
const resp = await fetch(`/api/agenda?from=${from}&to=${to}&types=deadlines,appointments`);
|
||||
if (!resp.ok) {
|
||||
@@ -439,6 +507,125 @@ function syncCollapseAriaLabels(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderInbox(s: InboxSummary): void {
|
||||
const summary = document.getElementById("dashboard-inbox-summary");
|
||||
const list = document.getElementById("dashboard-inbox-list");
|
||||
const empty = document.getElementById("dashboard-inbox-empty");
|
||||
if (!summary || !list || !empty) return;
|
||||
|
||||
const settings = settingsFor("inbox-approvals");
|
||||
const cap = settings.count ?? 3;
|
||||
const top = s.top.slice(0, cap);
|
||||
|
||||
if (s.pending_count === 0) {
|
||||
summary.style.display = "none";
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
summary.style.display = "block";
|
||||
summary.textContent = getLang() === "de"
|
||||
? `${s.pending_count} offene Freigaben warten auf dich.`
|
||||
: `${s.pending_count} open approvals are waiting for you.`;
|
||||
list.style.display = "";
|
||||
list.innerHTML = top.map((e) => {
|
||||
const entityLabel = e.entity_type === "deadline"
|
||||
? tDyn("dashboard.inbox.entity.deadline")
|
||||
: (e.entity_type === "appointment"
|
||||
? tDyn("dashboard.inbox.entity.appointment")
|
||||
: e.entity_type);
|
||||
const title = e.entity_title || entityLabel;
|
||||
return `<li class="dashboard-list-item">
|
||||
<a href="/inbox" class="dashboard-list-link">
|
||||
<div class="dashboard-list-main">
|
||||
<span class="dashboard-list-title">${esc(title)}</span>
|
||||
<span class="dashboard-list-ref" title="${escAttr(`${e.project_title} · ${e.requester_name}`)}">${esc(e.project_title)} · ${esc(e.requester_name)}</span>
|
||||
</div>
|
||||
<div class="dashboard-list-meta">
|
||||
<span class="dashboard-appt-time">${esc(formatDateTime(e.requested_at))}</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// applyLayout walks the saved DashboardLayoutSpec and hides widgets whose
|
||||
// keys are `visible: false`, then reorders the visible ones to match the
|
||||
// layout's order. Widgets in the layout but missing from the DOM are
|
||||
// ignored (the catalog must define the markup for them — Slice A has
|
||||
// every catalog widget pre-rendered in dashboard.tsx). Widgets in the
|
||||
// DOM but missing from the layout (e.g. a deploy added markup ahead of a
|
||||
// migration) stay in their authored position so nothing disappears
|
||||
// silently.
|
||||
//
|
||||
// Reordering target: the visible widgets live in two parents — the
|
||||
// outer .container and the .dashboard-columns 2-up grid. We respect
|
||||
// that boundary: widgets inside .dashboard-columns are reordered within
|
||||
// it; widgets outside are reordered relative to each other inside
|
||||
// .container. This keeps the existing 2-up behaviour for the
|
||||
// deadlines+appointments pair without forcing a full container flatten.
|
||||
function applyLayout(): void {
|
||||
if (!currentLayout || !Array.isArray(currentLayout.widgets)) return;
|
||||
|
||||
// Discover widget elements once. data-widget-key set in dashboard.tsx.
|
||||
const allWidgets = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-widget-key]"),
|
||||
);
|
||||
if (!allWidgets.length) return;
|
||||
const byKey = new Map<string, HTMLElement>();
|
||||
allWidgets.forEach((el) => {
|
||||
const k = el.dataset.widgetKey;
|
||||
if (k) byKey.set(k, el);
|
||||
});
|
||||
|
||||
// Hide widgets whose layout entry says visible:false. Anything not in
|
||||
// the layout at all stays untouched.
|
||||
const seenInLayout = new Set<string>();
|
||||
for (const w of currentLayout.widgets) {
|
||||
seenInLayout.add(w.key);
|
||||
const el = byKey.get(w.key);
|
||||
if (!el) continue;
|
||||
el.style.display = w.visible ? "" : "none";
|
||||
}
|
||||
|
||||
// Reorder visible widgets inside each parent. We group widgets by their
|
||||
// current parent element so we don't move them out of .dashboard-columns
|
||||
// and lose the 2-up grid layout.
|
||||
const groups = new Map<HTMLElement, HTMLElement[]>();
|
||||
for (const w of currentLayout.widgets) {
|
||||
if (!w.visible) continue;
|
||||
const el = byKey.get(w.key);
|
||||
if (!el || !el.parentElement) continue;
|
||||
const arr = groups.get(el.parentElement) ?? [];
|
||||
arr.push(el);
|
||||
groups.set(el.parentElement, arr);
|
||||
}
|
||||
groups.forEach((widgets, parent) => {
|
||||
widgets.forEach((el) => parent.appendChild(el));
|
||||
});
|
||||
}
|
||||
|
||||
// filterByHorizonDays drops items whose key date is more than `days`
|
||||
// days from today. Items without a parseable date stay in (we don't
|
||||
// want to silently hide rows on bad data). today is inclusive.
|
||||
function filterByHorizonDays<T>(items: T[], days: number, key: (t: T) => string): T[] {
|
||||
if (!Number.isFinite(days) || days <= 0) return items;
|
||||
const cutoff = new Date();
|
||||
cutoff.setHours(0, 0, 0, 0);
|
||||
cutoff.setDate(cutoff.getDate() + days);
|
||||
return items.filter((t) => {
|
||||
const raw = key(t);
|
||||
if (!raw) return true;
|
||||
// due_date is "YYYY-MM-DD"; start_at is RFC 3339. Both parseable
|
||||
// by Date.
|
||||
const d = new Date(raw.length === 10 ? raw + "T00:00:00" : raw);
|
||||
if (isNaN(d.getTime())) return true;
|
||||
return d.getTime() <= cutoff.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleOnboardingHint(user: DashboardUser | null): void {
|
||||
// Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
|
||||
// already redirects users without a paliad.users row to /onboarding before
|
||||
@@ -518,6 +705,23 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
syncCollapseAriaLabels();
|
||||
});
|
||||
|
||||
// Configurable layout (t-paliad-219). The Go shell handler splices
|
||||
// the user's saved layout into __PALIAD_DASHBOARD_LAYOUT__. If it's
|
||||
// missing (knowledge-platform-only deploy, hydration failure), the
|
||||
// dashboard renders the factory order baked into dashboard.tsx; the
|
||||
// client also kicks off a best-effort fetch so a slow-hydrating user
|
||||
// still gets their saved layout on the next render pass.
|
||||
const layoutInline = window.__PALIAD_DASHBOARD_LAYOUT__;
|
||||
if (layoutInline) {
|
||||
currentLayout = layoutInline;
|
||||
} else if (layoutInline === undefined) {
|
||||
void fetch("/api/me/dashboard-layout").then(async (r) => {
|
||||
if (!r.ok) return;
|
||||
currentLayout = (await r.json()) as DashboardLayoutSpec;
|
||||
if (data) render();
|
||||
}).catch(() => { /* silent — factory order is the fallback */ });
|
||||
}
|
||||
|
||||
// Inline agenda fetch is independent of the main dashboard payload.
|
||||
// Kicked off in parallel so the agenda section paints as soon as the
|
||||
// /api/agenda response lands instead of waiting on the dashboard
|
||||
|
||||
@@ -126,11 +126,12 @@ const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
|
||||
{ value: "all", key: "events.filter.status.all" },
|
||||
{ value: "upcoming", key: "events.filter.status.upcoming" },
|
||||
{ value: "today", key: "deadlines.filter.today" },
|
||||
{ value: "this_week", key: "deadlines.filter.thisweek" },
|
||||
{ value: "next_week", key: "deadlines.filter.nextweek" },
|
||||
{ value: "later", key: "deadlines.filter.later" },
|
||||
{ value: "all", key: "events.filter.status.all" },
|
||||
];
|
||||
|
||||
function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
|
||||
@@ -139,7 +140,7 @@ function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
|
||||
}
|
||||
|
||||
function defaultStatusFor(type: EventTypeChoice): string {
|
||||
return type === "appointment" ? "all" : "pending";
|
||||
return type === "appointment" ? "upcoming" : "pending";
|
||||
}
|
||||
|
||||
let currentType: EventTypeChoice = "deadline";
|
||||
@@ -728,6 +729,13 @@ function wireRowHandlers(tbody: HTMLElement) {
|
||||
if (cb && !cb.disabled) {
|
||||
cb.addEventListener("change", async () => {
|
||||
if (!cb.checked) return;
|
||||
const titleCell = row.querySelector<HTMLElement>(".events-title");
|
||||
const title = (titleCell?.textContent || "").trim();
|
||||
const msg = t("deadlines.complete.confirm").replace("{title}", title || "?");
|
||||
if (!window.confirm(msg)) {
|
||||
cb.checked = false;
|
||||
return;
|
||||
}
|
||||
cb.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/deadlines/${id}/complete`, { method: "PATCH" });
|
||||
|
||||
@@ -162,10 +162,11 @@ function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
{ value: "changes_requested", key: "views.bar.approval_status.changes_requested" },
|
||||
];
|
||||
|
||||
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { AxisKey } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
import { openApprovalEditModal } from "./components/approval-edit-modal";
|
||||
|
||||
// /inbox client — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
@@ -123,11 +124,20 @@ function paint(
|
||||
|
||||
function wireApprovalActions(host: HTMLElement): void {
|
||||
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
||||
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
|
||||
const action = btn.dataset.action as
|
||||
| "approve"
|
||||
| "reject"
|
||||
| "revoke"
|
||||
| "suggest_changes"
|
||||
| undefined;
|
||||
const li = btn.closest<HTMLLIElement>(".views-approval-row");
|
||||
const id = li?.dataset.requestId;
|
||||
if (!action || !id) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (action === "suggest_changes") {
|
||||
await handleSuggestChanges(btn, id, li!);
|
||||
return;
|
||||
}
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
@@ -141,8 +151,8 @@ function wireApprovalActions(host: HTMLElement): void {
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
alert(mapApprovalError(body.error || "internal"));
|
||||
const body = await r.json().catch(() => ({} as { error?: string; code?: string }));
|
||||
alert(mapApprovalError(body.code || body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
@@ -156,14 +166,109 @@ function wireApprovalActions(host: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// handleSuggestChanges — t-paliad-216. Open the edit modal with the
|
||||
// requester's original payload + pre_image pre-populated. If the user
|
||||
// submits non-empty changes / note, POST to
|
||||
// /api/approval-requests/{id}/suggest-changes; refresh the bar on success
|
||||
// so the OLD row flips to changes_requested and the NEW pending row
|
||||
// appears.
|
||||
async function handleSuggestChanges(
|
||||
btn: HTMLButtonElement,
|
||||
requestID: string,
|
||||
li: HTMLLIElement,
|
||||
): Promise<void> {
|
||||
// Read the row's detail blob off the data-attrs the shape-list stamped.
|
||||
// shape-list serialises payload/pre_image inline; we fetch fresh via
|
||||
// the per-row API to avoid relying on stale list data.
|
||||
let payload: Record<string, unknown> | null = null;
|
||||
let preImage: Record<string, unknown> | null = null;
|
||||
let entityType: "deadline" | "appointment" = "deadline";
|
||||
let lifecycleEvent = "update";
|
||||
let projectTitle: string | undefined;
|
||||
let requesterName: string | undefined;
|
||||
let requestedAt: string | undefined;
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${requestID}`, { credentials: "include" });
|
||||
if (r.ok) {
|
||||
const body = (await r.json()) as {
|
||||
entity_type?: "deadline" | "appointment";
|
||||
lifecycle_event?: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
project_title?: string;
|
||||
requester_name?: string;
|
||||
requested_at?: string;
|
||||
};
|
||||
payload = body.payload ?? null;
|
||||
preImage = body.pre_image ?? null;
|
||||
if (body.entity_type === "appointment") entityType = "appointment";
|
||||
if (body.lifecycle_event) lifecycleEvent = body.lifecycle_event;
|
||||
projectTitle = body.project_title;
|
||||
requesterName = body.requester_name;
|
||||
requestedAt = body.requested_at;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Modal still opens with empty defaults if the fetch fails; the
|
||||
// server-side schema validation catches a misshapen counter.
|
||||
}
|
||||
|
||||
const result = await openApprovalEditModal({
|
||||
entityType,
|
||||
lifecycleEvent,
|
||||
payload,
|
||||
preImage,
|
||||
projectTitle,
|
||||
requesterName,
|
||||
requestedAt,
|
||||
});
|
||||
if (!result) return; // cancel
|
||||
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${requestID}/suggest-changes`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
counter_payload: result.counterPayload,
|
||||
note: result.note,
|
||||
}),
|
||||
});
|
||||
const body = (await r.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
new_request_id?: string;
|
||||
};
|
||||
if (!r.ok) {
|
||||
alert(mapApprovalError(body.code || body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await bar?.refresh();
|
||||
await refreshInboxBadge();
|
||||
btn.disabled = false;
|
||||
|
||||
// Surface the new row's id on the OLD row's <li> so callers (e.g.
|
||||
// tests, future inspection) can find it without re-querying.
|
||||
if (body.new_request_id) {
|
||||
li.dataset.spawnedRequestId = body.new_request_id;
|
||||
}
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked": return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending": return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized": return t("approvals.error.not_authorized");
|
||||
case "request_not_pending": return t("approvals.error.request_not_pending");
|
||||
default: return key;
|
||||
case "self_approval_blocked": return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending": return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized": return t("approvals.error.not_authorized");
|
||||
case "request_not_pending": return t("approvals.error.request_not_pending");
|
||||
case "suggestion_requires_change": return t("approvals.error.suggestion_requires_change");
|
||||
case "suggestion_lifecycle_invalid": return t("approvals.error.suggestion_lifecycle_invalid");
|
||||
default: return key;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec } from "./views/types";
|
||||
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
|
||||
import { loadAndRenderSubmissions } from "./submissions";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -158,7 +159,8 @@ type TabId =
|
||||
| "deadlines"
|
||||
| "appointments"
|
||||
| "notes"
|
||||
| "checklists";
|
||||
| "checklists"
|
||||
| "submissions";
|
||||
|
||||
const VALID_TABS: TabId[] = [
|
||||
"history",
|
||||
@@ -169,6 +171,7 @@ const VALID_TABS: TabId[] = [
|
||||
"appointments",
|
||||
"notes",
|
||||
"checklists",
|
||||
"submissions",
|
||||
];
|
||||
|
||||
// Legacy German tab slugs that may appear in bookmarked URLs after the
|
||||
@@ -1472,7 +1475,7 @@ function initCounterclaimRoute(
|
||||
msg.className = "form-msg";
|
||||
}
|
||||
// Populate proceeding-type select on first open. Only UPC types
|
||||
// make sense for a CCR (Nichtigkeit/CCI); pre-select UPC_REV.
|
||||
// make sense for a CCR (Nichtigkeit/CCI); pre-select upc.rev.cfi.
|
||||
if (procedureSel && procedureSel.options.length === 0) {
|
||||
const types = await loadProceedingTypes();
|
||||
const upcTypes = types.filter((t) => (t.jurisdiction ?? "").toUpperCase() === "UPC");
|
||||
@@ -1481,7 +1484,7 @@ function initCounterclaimRoute(
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(ty.id);
|
||||
opt.textContent = `${ty.code} — ${langEN ? ty.name_en || ty.name : ty.name}`;
|
||||
if (ty.code === "UPC_REV") opt.selected = true;
|
||||
if (ty.code === "upc.rev.cfi") opt.selected = true;
|
||||
procedureSel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
@@ -1610,6 +1613,9 @@ function showTab(tab: TabId) {
|
||||
if (tab === "checklists" && project) {
|
||||
void loadAndRenderChecklistInstances(project.id);
|
||||
}
|
||||
if (tab === "submissions" && project) {
|
||||
void loadAndRenderSubmissions(project.id);
|
||||
}
|
||||
}
|
||||
|
||||
let checklistInstancesInited = false;
|
||||
@@ -2058,6 +2064,7 @@ async function main() {
|
||||
initAttachUnitForm(id);
|
||||
initNotesContainer(id);
|
||||
mountVerlaufFilterBar(id);
|
||||
wireExportButton(id);
|
||||
showTab(parseTab());
|
||||
}
|
||||
|
||||
@@ -2680,6 +2687,41 @@ function canManagePartnerUnits(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// canExportProject mirrors the §4 server-side gate for /api/projects/{id}/export:
|
||||
// global_admin OR direct team responsibility ∈ {lead, member}. Used to
|
||||
// reveal the export button — server still re-enforces on the request.
|
||||
function canExportProject(): boolean {
|
||||
if (!me || !project) return false;
|
||||
if (me.global_role === "global_admin") return true;
|
||||
return teamMembers.some(
|
||||
(m) =>
|
||||
m.user_id === me!.id &&
|
||||
m.project_id === project!.id &&
|
||||
(m.responsibility === "lead" || m.responsibility === "member"),
|
||||
);
|
||||
}
|
||||
|
||||
// wireExportButton reveals + hooks up the project-export button on the
|
||||
// tabs nav. Triggers a download via a transient <a download> — same
|
||||
// pattern as the personal export in client/settings.ts.
|
||||
function wireExportButton(projectID: string): void {
|
||||
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
if (!canExportProject()) {
|
||||
btn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
btn.style.display = "";
|
||||
btn.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
|
||||
@@ -51,8 +51,8 @@ interface SyncLogEntry {
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
type TabName = "profil" | "benachrichtigungen" | "caldav";
|
||||
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav"];
|
||||
type TabName = "profil" | "benachrichtigungen" | "caldav" | "export";
|
||||
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "export"];
|
||||
const DEFAULT_TAB: TabName = "profil";
|
||||
|
||||
let me: Me | null = null;
|
||||
@@ -115,6 +115,7 @@ function showTab(tab: TabName, pushHistory: boolean) {
|
||||
if (tab === "profil") void loadProfilTab();
|
||||
else if (tab === "benachrichtigungen") void loadPrefsTab();
|
||||
else if (tab === "caldav") void loadCalDAVTab();
|
||||
else if (tab === "export") void loadExportTab();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +412,11 @@ async function loadCalDAVTab() {
|
||||
fillCalDAVForm();
|
||||
renderCalDAVStatus();
|
||||
await loadCalDAVLog();
|
||||
// Slice 2b — multi-calendar bindings. loadBindingProjects feeds the
|
||||
// project picker for scope=project; runs in parallel with the binding
|
||||
// list fetch.
|
||||
void loadBindingProjects();
|
||||
await loadBindings();
|
||||
}
|
||||
|
||||
async function loadCalDAVConfig(): Promise<boolean> {
|
||||
@@ -596,6 +602,415 @@ async function deleteCalDAVConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- CalDAV bindings (Slice 2b multi-calendar picker) ---------------------
|
||||
|
||||
interface UserCalendarBinding {
|
||||
id: string;
|
||||
user_id: string;
|
||||
calendar_path: string;
|
||||
display_name: string;
|
||||
scope_kind: "all_visible" | "personal_only" | "project" | "client" | "litigation" | "patent" | "case";
|
||||
scope_id?: string | null;
|
||||
include_personal: boolean;
|
||||
enabled: boolean;
|
||||
last_sync_at?: string | null;
|
||||
last_sync_error?: string | null;
|
||||
}
|
||||
|
||||
interface DiscoveredCalendar {
|
||||
href: string;
|
||||
display_name: string;
|
||||
supported_components?: string[];
|
||||
}
|
||||
|
||||
interface ProjectListItem {
|
||||
id: string;
|
||||
reference?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
let bindings: UserCalendarBinding[] = [];
|
||||
let discoveredCalendars: DiscoveredCalendar[] = [];
|
||||
let bindingProjects: ProjectListItem[] = [];
|
||||
let editingBindingID: string | null = null;
|
||||
// Slice 2c — capability cached from /api/caldav-discover. null = unprobed,
|
||||
// true = MKCALENDAR supported (show "Create new calendar" radio),
|
||||
// false = degrade UX (hide radio, surface bilingual notice).
|
||||
let supportsMKCalendar: boolean | null = null;
|
||||
|
||||
async function loadBindings(): Promise<void> {
|
||||
const section = document.getElementById("caldav-bindings-section");
|
||||
if (!section) return;
|
||||
try {
|
||||
const resp = await fetch("/api/caldav-bindings");
|
||||
if (resp.status === 501) return; // CalDAV unavailable; leave hidden
|
||||
if (!resp.ok) return;
|
||||
bindings = (await resp.json()) as UserCalendarBinding[];
|
||||
section.style.display = "";
|
||||
renderBindingsList();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function renderBindingsList(): void {
|
||||
const list = document.getElementById("caldav-bindings-list")!;
|
||||
const empty = document.getElementById("caldav-bindings-empty")!;
|
||||
if (!bindings.length) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = bindings.map(renderBindingCard).join("");
|
||||
// Wire per-card buttons.
|
||||
for (const b of bindings) {
|
||||
const card = document.getElementById(`caldav-binding-card-${b.id}`);
|
||||
if (!card) continue;
|
||||
card.querySelector(".caldav-binding-edit-btn")?.addEventListener("click", () => openBindingModal(b));
|
||||
card.querySelector(".caldav-binding-delete-btn")?.addEventListener("click", () => deleteBinding(b));
|
||||
const toggle = card.querySelector(".caldav-binding-enabled-toggle") as HTMLInputElement | null;
|
||||
toggle?.addEventListener("change", () => toggleBindingEnabled(b, toggle.checked));
|
||||
}
|
||||
}
|
||||
|
||||
function renderBindingCard(b: UserCalendarBinding): string {
|
||||
const label = b.display_name || b.calendar_path;
|
||||
const scope = scopeLabel(b);
|
||||
const last = b.last_sync_at ? fmtDateTime(b.last_sync_at) : t("caldav.never");
|
||||
const err = b.last_sync_error ? `<span class="caldav-status-error">${esc(b.last_sync_error)}</span>` : "";
|
||||
return `<div class="caldav-binding-card" id="caldav-binding-card-${esc(b.id)}">
|
||||
<div class="caldav-binding-card-row">
|
||||
<div class="caldav-binding-card-title">
|
||||
<strong>${esc(label)}</strong>
|
||||
<span class="caldav-binding-scope-chip">${esc(scope)}</span>
|
||||
</div>
|
||||
<label class="caldav-toggle-label">
|
||||
<input type="checkbox" class="caldav-binding-enabled-toggle" ${b.enabled ? "checked" : ""} />
|
||||
<span data-i18n="caldav.bindings.card.enabled">Aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="caldav-binding-card-row caldav-binding-card-meta">
|
||||
<span class="caldav-binding-path">${esc(b.calendar_path)}</span>
|
||||
<span class="caldav-binding-last-sync">${esc(t("caldav.status.last_sync"))} ${esc(last)} ${err}</span>
|
||||
</div>
|
||||
<div class="caldav-binding-card-actions">
|
||||
<button type="button" class="btn-secondary caldav-binding-edit-btn" data-i18n="caldav.bindings.card.edit">Bearbeiten</button>
|
||||
<button type="button" class="btn-danger caldav-binding-delete-btn" data-i18n="caldav.bindings.card.remove">Entfernen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function scopeLabel(b: UserCalendarBinding): string {
|
||||
switch (b.scope_kind) {
|
||||
case "all_visible":
|
||||
return t("caldav.bindings.scope.all_visible");
|
||||
case "personal_only":
|
||||
return t("caldav.bindings.scope.personal_only");
|
||||
case "project": {
|
||||
const p = bindingProjects.find((p) => p.id === b.scope_id);
|
||||
const name = p ? p.title || p.reference || p.id.slice(0, 8) : "?";
|
||||
return `${t("caldav.bindings.scope.project")}: ${name}`;
|
||||
}
|
||||
default:
|
||||
return b.scope_kind;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBindingProjects(): Promise<void> {
|
||||
if (bindingProjects.length) return;
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.ok) bindingProjects = (await resp.json()) as ProjectListItem[];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiscoveredCalendars(): Promise<void> {
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.loading"))}</option>`;
|
||||
try {
|
||||
const resp = await fetch("/api/caldav-discover");
|
||||
if (!resp.ok) {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
||||
supportsMKCalendar = null;
|
||||
syncBindingSourceModeUI();
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as {
|
||||
calendars: DiscoveredCalendar[];
|
||||
supports_mkcalendar?: boolean | null;
|
||||
};
|
||||
discoveredCalendars = data.calendars || [];
|
||||
supportsMKCalendar = data.supports_mkcalendar ?? null;
|
||||
if (!discoveredCalendars.length) {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_empty"))}</option>`;
|
||||
} else {
|
||||
sel.innerHTML = discoveredCalendars
|
||||
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
|
||||
.join("");
|
||||
}
|
||||
syncBindingSourceModeUI();
|
||||
} catch {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
||||
supportsMKCalendar = null;
|
||||
syncBindingSourceModeUI();
|
||||
}
|
||||
}
|
||||
|
||||
// syncBindingSourceModeUI shows / hides the "Neuen Kalender erstellen"
|
||||
// radio + the Google-degrade notice based on the cached
|
||||
// supports_mkcalendar capability. Also flips the visible input
|
||||
// (dropdown vs URL text box) to match the currently selected mode.
|
||||
function syncBindingSourceModeUI(): void {
|
||||
const createRow = document.getElementById("caldav-binding-source-mode-create-row");
|
||||
const degrade = document.getElementById("caldav-binding-degrade-notice");
|
||||
if (createRow) createRow.style.display = supportsMKCalendar === true ? "" : "none";
|
||||
if (degrade) degrade.style.display = supportsMKCalendar === false ? "" : "none";
|
||||
|
||||
// If supports_mkcalendar flipped to false while "create" was selected,
|
||||
// fall back to "existing" so the user isn't staring at a hidden radio.
|
||||
if (supportsMKCalendar !== true) {
|
||||
const createRadio = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="create"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (createRadio?.checked) {
|
||||
const existing = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="existing"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (existing) existing.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
const mode = currentBindingSourceMode();
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
sel.style.display = mode === "existing" ? "" : "none";
|
||||
customInput.style.display = mode === "custom" ? "" : "none";
|
||||
}
|
||||
|
||||
function currentBindingSourceMode(): "existing" | "create" | "custom" {
|
||||
const checked = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"]:checked',
|
||||
) as HTMLInputElement | null;
|
||||
return (checked?.value as "existing" | "create" | "custom") ?? "existing";
|
||||
}
|
||||
|
||||
function openBindingModal(b: UserCalendarBinding | null) {
|
||||
editingBindingID = b ? b.id : null;
|
||||
const modal = document.getElementById("caldav-binding-modal")!;
|
||||
const title = document.getElementById("caldav-binding-modal-title")!;
|
||||
const submitBtn = document.getElementById("caldav-binding-submit-btn")!;
|
||||
const sourceField = document.getElementById("caldav-binding-source-field")!;
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
const msg = document.getElementById("caldav-binding-msg")!;
|
||||
msg.textContent = "";
|
||||
|
||||
if (b) {
|
||||
title.textContent = t("caldav.bindings.modal.edit_title");
|
||||
submitBtn.textContent = t("caldav.bindings.modal.submit_edit");
|
||||
sourceField.style.display = "none";
|
||||
nameInput.value = b.display_name;
|
||||
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="${b.scope_kind}"]`) as HTMLInputElement | null;
|
||||
if (radio) radio.checked = true;
|
||||
} else {
|
||||
title.textContent = t("caldav.bindings.modal.add_title");
|
||||
submitBtn.textContent = t("caldav.bindings.modal.submit_add");
|
||||
sourceField.style.display = "";
|
||||
// Reset the 3-way source-mode radio to "existing" (most common path).
|
||||
const existingRadio = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="existing"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (existingRadio) existingRadio.checked = true;
|
||||
customInput.value = "";
|
||||
nameInput.value = "";
|
||||
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="all_visible"]`) as HTMLInputElement;
|
||||
radio.checked = true;
|
||||
void loadDiscoveredCalendars();
|
||||
}
|
||||
|
||||
// Project picker — populate options when project scope is picked.
|
||||
projectSel.innerHTML = bindingProjects
|
||||
.map((p) => `<option value="${esc(p.id)}">${esc((p.title || p.reference || p.id.slice(0, 8)))}</option>`)
|
||||
.join("");
|
||||
if (b && b.scope_kind === "project" && b.scope_id) {
|
||||
projectSel.value = b.scope_id;
|
||||
projectSel.disabled = false;
|
||||
}
|
||||
syncBindingScopeUI();
|
||||
syncBindingSourceModeUI();
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeBindingModal() {
|
||||
document.getElementById("caldav-binding-modal")!.style.display = "none";
|
||||
editingBindingID = null;
|
||||
}
|
||||
|
||||
function syncBindingScopeUI(): void {
|
||||
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
projectSel.disabled = scope !== "project";
|
||||
}
|
||||
|
||||
async function submitBindingModal(ev: Event): Promise<void> {
|
||||
ev.preventDefault();
|
||||
const msg = document.getElementById("caldav-binding-msg")!;
|
||||
msg.textContent = "";
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
const submitBtn = document.getElementById("caldav-binding-submit-btn") as HTMLButtonElement;
|
||||
|
||||
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
|
||||
if (!scope) {
|
||||
msg.textContent = t("caldav.bindings.error.scope");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (scope === "project" && !projectSel.value) {
|
||||
msg.textContent = t("caldav.bindings.error.scope_project");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
if (editingBindingID) {
|
||||
const patchPayload: Record<string, unknown> = {
|
||||
display_name: nameInput.value.trim(),
|
||||
scope_kind: scope,
|
||||
enabled: true,
|
||||
};
|
||||
if (scope === "project") patchPayload.scope_id = projectSel.value;
|
||||
const resp = await fetch(`/api/caldav-bindings/${editingBindingID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patchPayload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const mode = currentBindingSourceMode();
|
||||
if (mode === "create") {
|
||||
// Slice 2c MKCALENDAR path.
|
||||
const displayName = nameInput.value.trim();
|
||||
if (!displayName) {
|
||||
msg.textContent = t("caldav.bindings.error.create_name_required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const createPayload: Record<string, unknown> = {
|
||||
display_name: displayName,
|
||||
scope_kind: scope,
|
||||
};
|
||||
if (scope === "project") createPayload.scope_id = projectSel.value;
|
||||
const resp = await fetch("/api/caldav-mkcalendar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(createPayload),
|
||||
});
|
||||
if (resp.status === 501) {
|
||||
// Race: probe flipped to false between modal-open and submit.
|
||||
// Re-sync the UI and surface a helpful message.
|
||||
supportsMKCalendar = false;
|
||||
syncBindingSourceModeUI();
|
||||
msg.textContent = t("caldav.bindings.error.create_unsupported");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
msg.textContent = t("caldav.bindings.error.create_name_taken");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// existing | custom — POST /api/caldav-bindings with the path.
|
||||
const path = mode === "custom" ? customInput.value.trim() : sel.value;
|
||||
if (!path) {
|
||||
msg.textContent = t("caldav.bindings.error.path");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const postPayload: Record<string, unknown> = {
|
||||
calendar_path: path,
|
||||
display_name: nameInput.value.trim(),
|
||||
scope_kind: scope,
|
||||
enabled: true,
|
||||
};
|
||||
if (scope === "project") postPayload.scope_id = projectSel.value;
|
||||
if (!postPayload.display_name && mode === "existing") {
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
postPayload.display_name = opt ? opt.text : "";
|
||||
}
|
||||
const resp = await fetch("/api/caldav-bindings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(postPayload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
closeBindingModal();
|
||||
await loadBindings();
|
||||
} catch {
|
||||
msg.textContent = t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBinding(b: UserCalendarBinding): Promise<void> {
|
||||
if (!confirm(t("caldav.bindings.delete.confirm"))) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/caldav-bindings/${b.id}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204 && resp.status !== 202) {
|
||||
alert(t("caldav.bindings.delete.failed"));
|
||||
return;
|
||||
}
|
||||
await loadBindings();
|
||||
} catch {
|
||||
alert(t("caldav.bindings.delete.failed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBindingEnabled(b: UserCalendarBinding, enabled: boolean): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`/api/caldav-bindings/${b.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
b.enabled = enabled;
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// --- "Meine Partner Units" card on the profile tab -------------------------
|
||||
//
|
||||
// Read-only summary of the current user's structural memberships. Membership
|
||||
@@ -662,6 +1077,48 @@ async function renderMyPartnerUnits(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Export tab (t-paliad-214 Slice 1) -------------------------------------
|
||||
|
||||
// Personal data export. One button; on click hits GET /api/me/export and the
|
||||
// browser handles the download via Content-Disposition. We use an anchor +
|
||||
// hidden iframe pattern so any non-200 response can surface inline instead
|
||||
// of silently triggering a save dialog with an error-html body.
|
||||
async function loadExportTab(): Promise<void> {
|
||||
// Nothing to fetch on render; the tab is static text + button. Wired in
|
||||
// the DOMContentLoaded handler.
|
||||
}
|
||||
|
||||
function runExport(): void {
|
||||
const msg = document.getElementById("export-msg");
|
||||
const btn = document.getElementById("export-btn") as HTMLButtonElement | null;
|
||||
if (msg) msg.textContent = "";
|
||||
if (btn) btn.disabled = true;
|
||||
// Trigger a navigation to the endpoint. The server sets
|
||||
// Content-Disposition: attachment which the browser respects.
|
||||
// We use a transient <a download> so the click goes through the
|
||||
// normal download path even on browsers that try to render text/json.
|
||||
const a = document.createElement("a");
|
||||
a.href = "/api/me/export";
|
||||
// download="" tells the browser to keep the server-provided filename
|
||||
// when one is set via Content-Disposition.
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Re-enable after a short timeout so users can re-trigger if needed.
|
||||
// We don't try to detect download completion — there's no portable
|
||||
// browser API for it.
|
||||
if (btn) {
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
if (msg)
|
||||
msg.textContent =
|
||||
t("einstellungen.export.started") ||
|
||||
"Download gestartet. Falls nichts passiert, prüfen Sie Ihren Browser-Downloadordner.";
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init -------------------------------------------------------------------
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
@@ -675,6 +1132,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("caldav-test-btn")!.addEventListener("click", testCalDAVConnection);
|
||||
document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteCalDAVConfig);
|
||||
|
||||
// CalDAV bindings (Slice 2b + 2c) — add/edit modal wiring.
|
||||
document.getElementById("caldav-bindings-add-btn")?.addEventListener("click", () => openBindingModal(null));
|
||||
document.getElementById("caldav-binding-modal-close")?.addEventListener("click", closeBindingModal);
|
||||
document.getElementById("caldav-binding-cancel-btn")?.addEventListener("click", closeBindingModal);
|
||||
document.getElementById("caldav-binding-form")?.addEventListener("submit", submitBindingModal);
|
||||
document.querySelectorAll('input[name="caldav-binding-source-mode"]').forEach((el) => {
|
||||
el.addEventListener("change", syncBindingSourceModeUI);
|
||||
});
|
||||
document.querySelectorAll('input[name="caldav-binding-scope"]').forEach((el) => {
|
||||
el.addEventListener("change", syncBindingScopeUI);
|
||||
});
|
||||
const exportBtn = document.getElementById("export-btn");
|
||||
if (exportBtn) exportBtn.addEventListener("click", runExport);
|
||||
|
||||
onLangChange(() => {
|
||||
if (loadedTabs.has("profil")) renderOfficeOptions();
|
||||
if (loadedTabs.has("caldav")) {
|
||||
|
||||
208
frontend/src/client/submissions.ts
Normal file
208
frontend/src/client/submissions.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// Submissions panel — fetches the project's submission catalog and
|
||||
// renders one row per filing-type rule, with a [Generieren] action
|
||||
// when a .docx template resolves server-side.
|
||||
//
|
||||
// t-paliad-215 Slice 1. Loaded lazily by the projects-detail tab
|
||||
// switcher so projects without the Schriftsätze tab open don't pay
|
||||
// for the per-row template-availability probes.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
interface SubmissionEntry {
|
||||
submission_code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
event_type?: string;
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
}
|
||||
|
||||
interface SubmissionListResponse {
|
||||
project_id: string;
|
||||
proceeding_type_id?: number;
|
||||
entries: SubmissionEntry[];
|
||||
}
|
||||
|
||||
// Module state — set once per page load when the user first opens the
|
||||
// tab. Subsequent activations re-use the cached result so the lawyer
|
||||
// doesn't pay for repeat list calls flipping between tabs.
|
||||
let cached: { projectID: string; data: SubmissionListResponse } | null = null;
|
||||
let loading = false;
|
||||
|
||||
/**
|
||||
* Load + render the submissions panel for the given project.
|
||||
*
|
||||
* Idempotent: safe to call on every tab activation. The second call
|
||||
* paints from cache instantly; the first call shows a loading state
|
||||
* until the list response arrives.
|
||||
*/
|
||||
export async function loadAndRenderSubmissions(projectID: string): Promise<void> {
|
||||
if (loading) return;
|
||||
if (cached && cached.projectID === projectID) {
|
||||
render(cached.data);
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${projectID}/submissions`);
|
||||
if (!resp.ok) {
|
||||
renderError();
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as SubmissionListResponse;
|
||||
cached = { projectID, data };
|
||||
render(data);
|
||||
} catch {
|
||||
renderError();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function render(data: SubmissionListResponse): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
const wrap = document.getElementById("project-submissions-tablewrap");
|
||||
const body = document.getElementById("project-submissions-body");
|
||||
if (!empty || !noProc || !wrap || !body) return;
|
||||
|
||||
if (data.proceeding_type_id == null || data.proceeding_type_id === 0) {
|
||||
noProc.style.display = "";
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
noProc.style.display = "none";
|
||||
if (data.entries.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
|
||||
const isEN = document.documentElement.lang === "en";
|
||||
body.innerHTML = data.entries.map((entry) => {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const action = entry.has_template
|
||||
? `<button type="button" class="btn-primary btn-cta-lime btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(data.project_id)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`
|
||||
: `<span class="submission-no-template" data-i18n="projects.detail.submissions.action.no_template">${isEN ? "No template" : "Keine Vorlage"}</span>`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
// Wire button clicks. One click handler per render to avoid stale
|
||||
// closures from the previous render's data.
|
||||
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onGenerateClick(btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderError(): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
const wrap = document.getElementById("project-submissions-tablewrap");
|
||||
if (!empty || !noProc || !wrap) return;
|
||||
noProc.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "";
|
||||
empty.textContent = document.documentElement.lang === "en"
|
||||
? "Failed to load submissions list."
|
||||
: "Schriftsatzliste konnte nicht geladen werden.";
|
||||
}
|
||||
|
||||
function formatParty(role: string | undefined, isEN: boolean): string {
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "claimant": return isEN ? "Claimant" : "Klägerin";
|
||||
case "defendant": return isEN ? "Defendant" : "Beklagte";
|
||||
case "both": return isEN ? "Both" : "Beide";
|
||||
case "court": return isEN ? "Court" : "Gericht";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// onGenerateClick triggers a download. Disables the button while the
|
||||
// request is in flight to prevent double-submits and surfaces an
|
||||
// inline error on failure.
|
||||
async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
|
||||
const code = btn.dataset.code;
|
||||
const projectID = btn.dataset.project;
|
||||
if (!code || !projectID) return;
|
||||
|
||||
const originalLabel = btn.textContent ?? "";
|
||||
btn.disabled = true;
|
||||
btn.textContent = document.documentElement.lang === "en" ? "Generating…" : "Wird generiert…";
|
||||
|
||||
try {
|
||||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = await resp.json() as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
alert(
|
||||
(document.documentElement.lang === "en"
|
||||
? "Generation failed."
|
||||
: "Generieren fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "")
|
||||
?? `${code}.docx`;
|
||||
triggerDownload(blob, filename);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// parseFilename pulls the filename out of a Content-Disposition
|
||||
// header. Supports both unquoted and quoted forms.
|
||||
function parseFilename(header: string): string | null {
|
||||
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// triggerDownload creates an <a> with an object URL, clicks it, and
|
||||
// revokes the URL. Standard browser-side download pattern.
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Revoke on next tick so the click actually triggers the download
|
||||
// before the URL is gone.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
@@ -17,14 +17,66 @@ import {
|
||||
populateCourtPicker,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
// user's chosen date. Cleared whenever the trigger changes (proceeding,
|
||||
// trigger date, flag toggle) so a fresh calc starts unanchored — same
|
||||
// semantic as /tools/fristenrechner.
|
||||
const anchorOverrides = new Map<string, string>();
|
||||
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// Notes toggle — when off (default), per-rule descriptive notes render
|
||||
// as a compact ⓘ icon next to the meta line (hover for full text). When
|
||||
// on, the full notes block expands under each card. Choice persists in
|
||||
// localStorage so a reload or recalc keeps the user's preference.
|
||||
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
|
||||
function readNotesPref(): boolean {
|
||||
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
function writeNotesPref(on: boolean): void {
|
||||
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
||||
}
|
||||
let showNotes = readNotesPref();
|
||||
|
||||
// Jurisdiction display prefix for the proceeding-summary chip + the
|
||||
// trigger-event placeholder. Same forum slugs the .proceeding-group
|
||||
// `data-forum` attribute carries in verfahrensablauf.tsx /
|
||||
// fristenrechner.tsx (upc / de / epa / dpma). Disambiguates the
|
||||
// 4 redundancies in the corpus (UPC Verletzungsverfahren vs DE
|
||||
// Verletzungsklage etc.) once the picker collapses.
|
||||
const FORUM_LABEL: Record<string, string> = {
|
||||
upc: "UPC",
|
||||
de: "DE",
|
||||
epa: "EPA",
|
||||
dpma: "DPMA",
|
||||
};
|
||||
|
||||
function jurisdictionFor(btn: HTMLButtonElement): string {
|
||||
const group = btn.closest<HTMLElement>(".proceeding-group");
|
||||
const forum = group?.dataset.forum || "";
|
||||
return FORUM_LABEL[forum] || "";
|
||||
}
|
||||
|
||||
function proceedingDisplayName(btn: HTMLButtonElement): string {
|
||||
const name = btn.querySelector("strong")?.textContent || "";
|
||||
const jur = jurisdictionFor(btn);
|
||||
return jur ? `${jur} ${name}` : name;
|
||||
}
|
||||
|
||||
function activeProceedingButton(): HTMLButtonElement | null {
|
||||
return document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
|
||||
}
|
||||
|
||||
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
|
||||
// so rapid input changes never let a stale response overwrite a fresh
|
||||
// one.
|
||||
@@ -46,6 +98,31 @@ function showStep(n: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read the proceeding-specific flag checkboxes and assemble the
|
||||
// payload the calculator expects. Mirrors fristenrechner.ts so the
|
||||
// gating semantics stay identical: with_amend on upc.inf.cfi is
|
||||
// nested under with_ccr (R.30 is only available with a CCR);
|
||||
// upc.rev.cfi exposes with_amend + with_cci as two independent
|
||||
// gates. R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18
|
||||
// call): it's just an always-available optional submission, so it
|
||||
// has no checkbox.
|
||||
function readFlags(): string[] {
|
||||
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||||
const revAmend = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
|
||||
const revCci = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
|
||||
const flags: string[] = [];
|
||||
if (selectedType === "upc.inf.cfi") {
|
||||
if (ccr?.checked) flags.push("with_ccr");
|
||||
if (ccr?.checked && infAmend?.checked) flags.push("with_amend");
|
||||
}
|
||||
if (selectedType === "upc.rev.cfi") {
|
||||
if (revAmend?.checked) flags.push("with_amend");
|
||||
if (revCci?.checked) flags.push("with_cci");
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
async function doCalc() {
|
||||
const seq = ++calcSeq;
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
@@ -58,9 +135,14 @@ async function doCalc() {
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
flags: readFlags(),
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
@@ -70,25 +152,56 @@ async function doCalc() {
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||
// label from the calc response. The root rule (isRootEvent=true) is
|
||||
// the first event in the proceeding — e.g. Klageerhebung for
|
||||
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
|
||||
// active proceeding name if no root rule fires (shouldn't happen for
|
||||
// healthy data, but safer than a blank).
|
||||
function triggerEventLabelFor(data: DeadlineResponse): string {
|
||||
const root = data.deadlines.find((d) => d.isRootEvent);
|
||||
if (root) {
|
||||
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
}
|
||||
return data.proceedingName || "";
|
||||
}
|
||||
|
||||
function syncTriggerEventLabel() {
|
||||
const triggerEventEl = document.getElementById("trigger-event");
|
||||
if (!triggerEventEl) return;
|
||||
if (lastResponse) {
|
||||
triggerEventEl.textContent = triggerEventLabelFor(lastResponse);
|
||||
} else {
|
||||
triggerEventEl.textContent = "—";
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults(data: DeadlineResponse) {
|
||||
const container = document.getElementById("timeline-container");
|
||||
if (!container) return;
|
||||
const printBtn = document.getElementById("fristen-print-btn");
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
|
||||
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
|
||||
// Header shows the picked proceeding with its jurisdiction prefix
|
||||
// so the user can tell UPC Verletzungsverfahren apart from DE
|
||||
// Verletzungsklage once the picker collapses.
|
||||
const activeBtn = activeProceedingButton();
|
||||
const procName = activeBtn ? proceedingDisplayName(activeBtn)
|
||||
: tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
|
||||
const headerHtml = `<div class="timeline-header">
|
||||
<strong>${procName}</strong>
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data)
|
||||
: renderTimelineBody(data);
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
if (toggle) toggle.style.display = "";
|
||||
|
||||
syncTriggerEventLabel();
|
||||
}
|
||||
|
||||
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
|
||||
@@ -100,18 +213,52 @@ function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string)
|
||||
if (summaryName && displayName) summaryName.textContent = displayName;
|
||||
}
|
||||
|
||||
// syncFlagRows shows/hides the proceeding-specific checkbox rows
|
||||
// based on selectedType. Same disposition as fristenrechner.ts —
|
||||
// the with_amend nested-under-ccr semantic is enforced via
|
||||
// syncInfAmendEnabled().
|
||||
function syncFlagRows() {
|
||||
const show = (id: string, when: boolean) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = when ? "" : "none";
|
||||
};
|
||||
show("ccr-flag-row", selectedType === "upc.inf.cfi");
|
||||
show("inf-amend-flag-row", selectedType === "upc.inf.cfi");
|
||||
show("rev-amend-flag-row", selectedType === "upc.rev.cfi");
|
||||
show("rev-cci-flag-row", selectedType === "upc.rev.cfi");
|
||||
syncInfAmendEnabled();
|
||||
}
|
||||
|
||||
// R.30 amendment-application is only available with a CCR — disable
|
||||
// (and clear) the nested inf-amend checkbox while ccr is off so the
|
||||
// calc payload stays coherent. Mirrors fristenrechner.ts.
|
||||
function syncInfAmendEnabled() {
|
||||
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||||
if (!ccr || !infAmend) return;
|
||||
infAmend.disabled = !ccr.checked;
|
||||
if (!ccr.checked) infAmend.checked = false;
|
||||
}
|
||||
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
selectedType = btn.dataset.code || "";
|
||||
const nextType = btn.dataset.code || "";
|
||||
// Different proceeding tree → previously-set overrides reference
|
||||
// rule codes that don't exist in the new tree. Clear before the
|
||||
// next calc so the fresh proceeding starts unanchored.
|
||||
if (selectedType !== nextType) clearAnchorOverrides();
|
||||
selectedType = nextType;
|
||||
|
||||
const name = btn.querySelector("strong")?.textContent || "";
|
||||
const triggerEventEl = document.getElementById("trigger-event");
|
||||
if (triggerEventEl) triggerEventEl.textContent = name;
|
||||
// Trigger-event label fires from the calc response (root rule).
|
||||
// Until step 3 renders, fall back to an em-dash placeholder.
|
||||
lastResponse = null;
|
||||
syncTriggerEventLabel();
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
|
||||
setProceedingPickerCollapsed(true, name);
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
showStep(2);
|
||||
scheduleCalc(0);
|
||||
@@ -169,18 +316,62 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
|
||||
|
||||
// Flag-checkbox listeners — each flip triggers a fresh calc so the
|
||||
// timeline re-projects with the new gating. ccr-flag additionally
|
||||
// enables/disables the nested inf-amend row.
|
||||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
if (ccrFlag) ccrFlag.addEventListener("change", () => {
|
||||
syncInfAmendEnabled();
|
||||
scheduleCalc(0);
|
||||
});
|
||||
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
|
||||
const cb = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
|
||||
});
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
// Click-to-edit on timeline / column date cells — same delegated
|
||||
// pattern as /tools/fristenrechner. Survives renderResults()'s
|
||||
// innerHTML rewrites because the listener lives on the container.
|
||||
const timelineContainer = document.getElementById("timeline-container");
|
||||
if (timelineContainer) {
|
||||
wireDateEditClicks(timelineContainer, (ruleCode, newValue) => {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Notes toggle — restores last preference on load + re-renders when
|
||||
// the user flips it. Lives in the same toggle bar as the view picker.
|
||||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||||
if (notesShowCb) {
|
||||
notesShowCb.checked = showNotes;
|
||||
notesShowCb.addEventListener("change", () => {
|
||||
showNotes = notesShowCb.checked;
|
||||
writeNotesPref(showNotes);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
|
||||
onLangChange(() => {
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
|
||||
// Active-button name updates with language change (the data-i18n
|
||||
// pass swaps the inner <strong>'s text). Re-collapse the summary
|
||||
// chip and re-derive the trigger event label from the lang-current
|
||||
// calc response.
|
||||
const activeBtn = activeProceedingButton();
|
||||
if (activeBtn) {
|
||||
const name = activeBtn.querySelector("strong")?.textContent || "";
|
||||
const triggerEventEl = document.getElementById("trigger-event");
|
||||
if (triggerEventEl) triggerEventEl.textContent = name;
|
||||
const summary = document.getElementById("proceeding-summary-name");
|
||||
if (summary) summary.textContent = proceedingDisplayName(activeBtn);
|
||||
}
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
syncTriggerEventLabel();
|
||||
});
|
||||
|
||||
// Pre-select the first proceeding tile so users see a timeline
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { initI18n, t, type I18nKey } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
|
||||
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape, DataSource } from "./views/types";
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
import { renderCardsShape } from "./views/shape-cards";
|
||||
import { renderCalendarShape } from "./views/shape-calendar";
|
||||
import { renderTimelineShape } from "./views/shape-timeline-cv";
|
||||
import type { ChartHandle } from "./views/shape-timeline-chart";
|
||||
import { mountFilterBar, type BarHandle, type AxisKey } from "./filter-bar";
|
||||
|
||||
// /views and /views/{slug} client. Loads the saved or system view, runs
|
||||
// it via /api/views/{slug}/run, and dispatches to the matching render-
|
||||
// shape component. Shape-switcher chips toggle the live render without
|
||||
// re-fetching (the rows are already in memory).
|
||||
//
|
||||
// t-paliad-211 — the per-view filter bar (`mountFilterBar`) lives between
|
||||
// the shape chips and the render hosts. The saved view's filter_spec is
|
||||
// the baseline; the bar overlays the user's per-session tweaks and POSTs
|
||||
// `/api/views/{slug}/run` with the effective spec as override (the
|
||||
// substrate accepts `{filter: ...}` per views.go:283). Axes are picked
|
||||
// from the spec's data sources so a deadline-only view doesn't expose
|
||||
// the appointment-type chip cluster and vice versa.
|
||||
|
||||
initI18n();
|
||||
initSidebar();
|
||||
@@ -30,6 +39,8 @@ interface ViewMeta {
|
||||
|
||||
let currentMeta: ViewMeta | null = null;
|
||||
let currentRows: ViewRunResult | null = null;
|
||||
let currentRender: RenderSpec | null = null;
|
||||
let bar: BarHandle | null = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bindShapeChips();
|
||||
@@ -54,9 +65,10 @@ async function hydrate(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
currentMeta = meta;
|
||||
currentRender = meta.render;
|
||||
document.title = `${meta.name} — Paliad`;
|
||||
updateHeader(meta);
|
||||
await runAndRender(meta);
|
||||
mountBar(meta);
|
||||
if (meta.user_view_id) {
|
||||
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
|
||||
}
|
||||
@@ -97,57 +109,97 @@ async function resolveMeta(slug: string): Promise<ViewMeta | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runAndRender(meta: ViewMeta): Promise<void> {
|
||||
// mountBar wires the filter-bar to the view's saved spec. The bar runs
|
||||
// the spec through `/api/views/{slug}/run` whenever the user tweaks an
|
||||
// axis, and the onResult callback re-paints into the active shape host.
|
||||
function mountBar(meta: ViewMeta): void {
|
||||
const host = document.getElementById("views-filter-bar");
|
||||
const toolbar = document.getElementById("views-toolbar");
|
||||
const loading = document.getElementById("views-loading");
|
||||
if (loading) loading.hidden = false;
|
||||
if (toolbar) toolbar.hidden = false;
|
||||
if (host) host.hidden = false;
|
||||
if (!host) return;
|
||||
|
||||
// Tear down any prior bar (re-mount on lang change isn't supported
|
||||
// here, but a future Phase-2 axis switch may need this).
|
||||
if (bar) {
|
||||
bar.destroy();
|
||||
bar = null;
|
||||
}
|
||||
|
||||
const axes = axesForSources(meta.filter.sources);
|
||||
// surfaceKey scoped per-view-slug so two views remember their own
|
||||
// density/sort prefs independently.
|
||||
const surfaceKey = `views.${meta.slug}`;
|
||||
|
||||
bar = mountFilterBar(host, {
|
||||
baseFilter: meta.filter,
|
||||
baseRender: meta.render,
|
||||
axes,
|
||||
surfaceKey,
|
||||
systemViewSlug: meta.slug,
|
||||
// The saved view IS the baseline; "Speichern als Sicht" remains
|
||||
// available for users who want to fork.
|
||||
showSaveAsView: !meta.is_system,
|
||||
userViewId: meta.user_view_id,
|
||||
onResult: (result, effective) => {
|
||||
if (loading) loading.hidden = true;
|
||||
currentRows = result;
|
||||
currentRender = effective.render;
|
||||
paintRows(result, effective.render);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// axesForSources picks the filter-bar axes a saved view's data sources
|
||||
// support. Universal axes (time / personal_only / sort) always render;
|
||||
// per-source predicates only render when the view's spec actually
|
||||
// queries that source — otherwise the chip would be a no-op.
|
||||
function axesForSources(sources: DataSource[]): AxisKey[] {
|
||||
const set = new Set(sources);
|
||||
const out: AxisKey[] = ["time"];
|
||||
if (set.has("deadline")) out.push("deadline_status");
|
||||
if (set.has("appointment")) out.push("appointment_type");
|
||||
if (set.has("approval_request")) {
|
||||
out.push("approval_viewer_role");
|
||||
out.push("approval_status");
|
||||
out.push("approval_entity_type");
|
||||
}
|
||||
if (set.has("project_event")) out.push("project_event_kind");
|
||||
out.push("personal_only");
|
||||
out.push("sort");
|
||||
return out;
|
||||
}
|
||||
|
||||
function paintRows(result: ViewRunResult, render: RenderSpec): void {
|
||||
const empty = document.getElementById("views-empty");
|
||||
const errorEl = document.getElementById("views-error");
|
||||
const toolbar = document.getElementById("views-toolbar");
|
||||
if (loading) loading.hidden = false;
|
||||
if (empty) empty.hidden = true;
|
||||
if (errorEl) errorEl.hidden = true;
|
||||
if (toolbar) toolbar.hidden = false;
|
||||
|
||||
let result: ViewRunResult;
|
||||
try {
|
||||
const r = await fetch(`/api/views/${encodeURIComponent(meta.slug)}/run`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
showError(`${r.status}: ${r.statusText}`);
|
||||
return;
|
||||
}
|
||||
result = (await r.json()) as ViewRunResult;
|
||||
} catch (e) {
|
||||
showError(t("views.error.network"));
|
||||
return;
|
||||
}
|
||||
if (loading) loading.hidden = true;
|
||||
|
||||
currentRows = result;
|
||||
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
|
||||
showInaccessibleToast(result.inaccessible_project_ids.length);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
setActiveShape(null);
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
const hint = document.getElementById("views-empty-hint");
|
||||
if (hint) hint.textContent = filterSummary(meta.filter);
|
||||
if (hint && currentMeta) hint.textContent = filterSummary(currentMeta.filter);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (empty) empty.hidden = true;
|
||||
|
||||
setActiveShape(meta.render.shape);
|
||||
renderShape(meta.render.shape, meta.render, result.rows);
|
||||
setActiveShape(render.shape);
|
||||
renderShape(render.shape, render, result.rows);
|
||||
}
|
||||
|
||||
function setActiveShape(shape: RenderShape): void {
|
||||
function setActiveShape(shape: RenderShape | null): void {
|
||||
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar", "views-shape-timeline"]) {
|
||||
const el = document.getElementById(host);
|
||||
if (el) el.hidden = !host.endsWith("-" + shape);
|
||||
if (el) el.hidden = shape === null ? true : !host.endsWith("-" + shape);
|
||||
}
|
||||
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.shape === shape);
|
||||
@@ -223,9 +275,10 @@ function bindShapeChips(): void {
|
||||
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const shape = (btn.dataset.shape ?? "list") as RenderShape;
|
||||
if (!currentMeta || !currentRows) return;
|
||||
if (!currentRows || !currentRender) return;
|
||||
// Override the shape transiently — doesn't mutate the saved spec.
|
||||
const overrideRender = { ...currentMeta.render, shape };
|
||||
const overrideRender = { ...currentRender, shape };
|
||||
currentRender = overrideRender;
|
||||
setActiveShape(shape);
|
||||
renderShape(shape, overrideRender, currentRows.rows);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { t, type I18nKey, getLang } from "../i18n";
|
||||
import { t, tDyn, type I18nKey, getLang } from "../i18n";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// shape-calendar: month grid. Toggleable to week-view via per-shape
|
||||
// config. Mirrors the look of /events?view=calendar but generic across
|
||||
// sources.
|
||||
// shape-calendar: month / week / day views. The view switcher is rendered
|
||||
// inline above the grid; the active view persists in the URL via
|
||||
// ?cal_view= so /views/<slug>?cal_view=day&cal_date=2026-05-18 is a
|
||||
// shareable deep-link. Each view buckets the same flat ViewRow[] by
|
||||
// ISO-date — only the rendering differs.
|
||||
|
||||
type CalView = "month" | "week" | "day";
|
||||
|
||||
const VIEW_PARAM = "cal_view";
|
||||
const DATE_PARAM = "cal_date";
|
||||
const MAX_PILLS_PER_MONTH_CELL = 3;
|
||||
|
||||
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const cfg = render.calendar ?? {};
|
||||
const view = cfg.default_view ?? "month";
|
||||
|
||||
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
|
||||
// screens). Documented in design §9 trade-off 8.
|
||||
@@ -19,15 +26,121 @@ export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render:
|
||||
host.appendChild(notice);
|
||||
}
|
||||
|
||||
const initialView = readView(cfg.default_view);
|
||||
const anchor = readAnchor(rows);
|
||||
paint(host, rows, anchor, initialView);
|
||||
}
|
||||
|
||||
// paint redraws the calendar in the supplied view + anchor. Called from
|
||||
// the view switcher and from the day/week navigation buttons. Each paint
|
||||
// clears the host so we don't leak prior DOM.
|
||||
function paint(host: HTMLElement, rows: ViewRow[], anchor: Date, view: CalView): void {
|
||||
// Keep the mobile-notice (first child) if present; everything else is
|
||||
// re-rendered each time.
|
||||
const notice = host.querySelector<HTMLElement>(".views-calendar-mobile-notice");
|
||||
host.innerHTML = "";
|
||||
if (notice) host.appendChild(notice);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `views-calendar views-calendar--${view}`;
|
||||
wrap.appendChild(renderToolbar(view, anchor, (nextView, nextAnchor) => {
|
||||
writeURL(nextView, nextAnchor);
|
||||
paint(host, rows, nextAnchor, nextView);
|
||||
}));
|
||||
|
||||
if (view === "month") {
|
||||
wrap.appendChild(renderMonth(anchor, rows, (clickedDate) => {
|
||||
writeURL("day", clickedDate);
|
||||
paint(host, rows, clickedDate, "day");
|
||||
}));
|
||||
} else if (view === "week") {
|
||||
wrap.appendChild(renderWeek(anchor, rows));
|
||||
} else {
|
||||
wrap.appendChild(renderDay(anchor, rows));
|
||||
}
|
||||
|
||||
const monthRef = pickMonthAnchor(rows);
|
||||
wrap.appendChild(renderMonth(monthRef, rows));
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
|
||||
function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
// --- Toolbar -------------------------------------------------------------
|
||||
|
||||
function renderToolbar(
|
||||
view: CalView,
|
||||
anchor: Date,
|
||||
onNav: (view: CalView, anchor: Date) => void,
|
||||
): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "views-calendar-toolbar";
|
||||
|
||||
// View switcher: month / week / day chips.
|
||||
const switcher = document.createElement("div");
|
||||
switcher.className = "views-calendar-view-switcher agenda-chip-row";
|
||||
switcher.setAttribute("role", "tablist");
|
||||
for (const v of ["month", "week", "day"] as CalView[]) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
|
||||
chip.dataset.calView = v;
|
||||
chip.setAttribute("role", "tab");
|
||||
chip.setAttribute("aria-selected", v === view ? "true" : "false");
|
||||
chip.textContent = t(`cal.view.${v}` as I18nKey);
|
||||
chip.addEventListener("click", () => {
|
||||
if (v === view) return;
|
||||
onNav(v, anchor);
|
||||
});
|
||||
switcher.appendChild(chip);
|
||||
}
|
||||
bar.appendChild(switcher);
|
||||
|
||||
// Prev / current-label / next. Step size depends on the view.
|
||||
const nav = document.createElement("div");
|
||||
nav.className = "views-calendar-nav";
|
||||
|
||||
const prev = document.createElement("button");
|
||||
prev.type = "button";
|
||||
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
|
||||
prev.textContent = "‹";
|
||||
prev.addEventListener("click", () => onNav(view, shift(anchor, view, -1)));
|
||||
nav.appendChild(prev);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "views-calendar-nav-label";
|
||||
label.textContent = formatRangeLabel(view, anchor);
|
||||
nav.appendChild(label);
|
||||
|
||||
const next = document.createElement("button");
|
||||
next.type = "button";
|
||||
next.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
|
||||
next.textContent = "›";
|
||||
next.addEventListener("click", () => onNav(view, shift(anchor, view, 1)));
|
||||
nav.appendChild(next);
|
||||
|
||||
// Day/week view: provide a "Zurück zum Monat" link so users can climb
|
||||
// back without hunting for the switcher chip.
|
||||
if (view !== "month") {
|
||||
const backToMonth = document.createElement("button");
|
||||
backToMonth.type = "button";
|
||||
backToMonth.className = "btn-link views-calendar-back-to-month";
|
||||
backToMonth.textContent = t("cal.day.back_to_month");
|
||||
backToMonth.addEventListener("click", () => onNav("month", anchor));
|
||||
nav.appendChild(backToMonth);
|
||||
}
|
||||
|
||||
bar.appendChild(nav);
|
||||
return bar;
|
||||
}
|
||||
|
||||
function navLabelKey(view: CalView, dir: "prev" | "next"): I18nKey {
|
||||
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
|
||||
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
|
||||
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
|
||||
}
|
||||
|
||||
// --- Month view ----------------------------------------------------------
|
||||
|
||||
function renderMonth(anchor: Date, rows: ViewRow[], onDayDrill: (d: Date) => void): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-month";
|
||||
@@ -37,20 +150,22 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
wrap.appendChild(header);
|
||||
|
||||
// Weekday headers (Mon-Sun, ISO week).
|
||||
const weekdayBar = document.createElement("div");
|
||||
weekdayBar.className = "views-calendar-weekdays";
|
||||
const weekdayKeys: I18nKey[] = ["cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu", "cal.day.fri", "cal.day.sat", "cal.day.sun"];
|
||||
// Single grid with one column-template that the weekday row and the day
|
||||
// cells share. The header row is added with `grid-column: span 7` so
|
||||
// it spans the full width above the day grid (laid out below).
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-grid";
|
||||
|
||||
const weekdayKeys: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
for (const k of weekdayKeys) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-weekday";
|
||||
cell.textContent = t(k);
|
||||
weekdayBar.appendChild(cell);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
wrap.appendChild(weekdayBar);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-grid";
|
||||
|
||||
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
|
||||
@@ -63,47 +178,16 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
// Bucket rows by ISO date (yyyy-mm-dd).
|
||||
const byDate = new Map<string, ViewRow[]>();
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
if (d.getMonth() !== anchor.getMonth() || d.getFullYear() !== anchor.getFullYear()) continue;
|
||||
const key = isoDate(d);
|
||||
const arr = byDate.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else byDate.set(key, [row]);
|
||||
}
|
||||
// Bucket rows by ISO date (yyyy-mm-dd) within the visible month.
|
||||
const byDate = bucketByDate(rows, (d) =>
|
||||
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
|
||||
);
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell";
|
||||
const dayLabel = document.createElement("div");
|
||||
dayLabel.className = "views-calendar-cell-day";
|
||||
dayLabel.textContent = String(day);
|
||||
cell.appendChild(dayLabel);
|
||||
|
||||
const dateKey = isoDate(new Date(anchor.getFullYear(), anchor.getMonth(), day));
|
||||
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
|
||||
const dateKey = isoDate(dayDate);
|
||||
const dayRows = byDate.get(dateKey) ?? [];
|
||||
if (dayRows.length > 0) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-pills";
|
||||
const visible = dayRows.slice(0, 3);
|
||||
for (const row of visible) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
|
||||
li.textContent = row.title;
|
||||
li.title = row.title + (row.project_title ? ` — ${row.project_title}` : "");
|
||||
ul.appendChild(li);
|
||||
}
|
||||
if (dayRows.length > visible.length) {
|
||||
const more = document.createElement("li");
|
||||
more.className = "views-calendar-pill views-calendar-pill--more";
|
||||
more.textContent = `+${dayRows.length - visible.length}`;
|
||||
ul.appendChild(more);
|
||||
}
|
||||
cell.appendChild(ul);
|
||||
}
|
||||
const cell = renderMonthCell(dayDate, day, dayRows, onDayDrill);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
@@ -111,14 +195,269 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function pickMonthAnchor(rows: ViewRow[]): Date {
|
||||
// Anchor on the first row's month, or "this month" if empty.
|
||||
function renderMonthCell(
|
||||
dayDate: Date,
|
||||
dayNum: number,
|
||||
dayRows: ViewRow[],
|
||||
onDayDrill: (d: Date) => void,
|
||||
): HTMLElement {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell";
|
||||
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
|
||||
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
|
||||
|
||||
// Day-number is a click-target that switches to the day view. We render
|
||||
// it as a button to keep keyboard semantics; the surrounding cell stays
|
||||
// a div so it doesn't compete with the inner row anchors.
|
||||
const dayLabel = document.createElement("button");
|
||||
dayLabel.type = "button";
|
||||
dayLabel.className = "views-calendar-cell-day";
|
||||
dayLabel.textContent = String(dayNum);
|
||||
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
dayLabel.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onDayDrill(dayDate);
|
||||
});
|
||||
cell.appendChild(dayLabel);
|
||||
|
||||
if (dayRows.length > 0) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-pills";
|
||||
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
|
||||
for (const row of visible) {
|
||||
ul.appendChild(renderPill(row));
|
||||
}
|
||||
if (dayRows.length > visible.length) {
|
||||
const more = document.createElement("li");
|
||||
const moreBtn = document.createElement("button");
|
||||
moreBtn.type = "button";
|
||||
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
|
||||
moreBtn.textContent = `+${dayRows.length - visible.length}`;
|
||||
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
moreBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onDayDrill(dayDate);
|
||||
});
|
||||
more.appendChild(moreBtn);
|
||||
ul.appendChild(more);
|
||||
}
|
||||
cell.appendChild(ul);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
// --- Week view -----------------------------------------------------------
|
||||
|
||||
function renderWeek(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-week";
|
||||
|
||||
const weekStart = startOfWeek(anchor);
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
|
||||
wrap.appendChild(header);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-week-grid";
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(weekStart);
|
||||
day.setDate(weekStart.getDate() + i);
|
||||
const col = renderWeekColumn(day, rows);
|
||||
grid.appendChild(col);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderWeekColumn(day: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const col = document.createElement("div");
|
||||
col.className = "views-calendar-week-column";
|
||||
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "views-calendar-week-head";
|
||||
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
|
||||
const dow = document.createElement("span");
|
||||
dow.className = "views-calendar-week-dow";
|
||||
dow.textContent = t(weekdayKey);
|
||||
const dnum = document.createElement("span");
|
||||
dnum.className = "views-calendar-week-dnum";
|
||||
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
head.appendChild(dow);
|
||||
head.appendChild(dnum);
|
||||
col.appendChild(head);
|
||||
|
||||
// No 3-row cap on week / day views — show everything for that day.
|
||||
const dayRows = filterByDay(rows, day);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-week-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
col.appendChild(empty);
|
||||
return col;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-week-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "week"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
col.appendChild(ul);
|
||||
return col;
|
||||
}
|
||||
|
||||
// --- Day view ------------------------------------------------------------
|
||||
|
||||
function renderDay(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-day-wrap";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, {
|
||||
weekday: "long", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
wrap.appendChild(header);
|
||||
|
||||
const dayRows = filterByDay(rows, anchor);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-day-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
wrap.appendChild(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-day-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "day"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
wrap.appendChild(ul);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// --- Row rendering -------------------------------------------------------
|
||||
|
||||
function renderPill(row: ViewRow): HTMLElement {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
|
||||
a.href = rowHref(row);
|
||||
a.textContent = row.title;
|
||||
a.title = row.title + (row.project_title ? ` — ${row.project_title}` : "");
|
||||
// Pills are anchors — month-cell day-button click ignores them via
|
||||
// stopPropagation on the button; cell-level handlers would intercept
|
||||
// them otherwise.
|
||||
a.addEventListener("click", (e) => e.stopPropagation());
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderRowAnchor(row: ViewRow, density: "week" | "day"): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
|
||||
a.href = rowHref(row);
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
|
||||
a.appendChild(dot);
|
||||
|
||||
const body = document.createElement("span");
|
||||
body.className = "views-calendar-row-body";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "views-calendar-row-title";
|
||||
title.textContent = row.title;
|
||||
body.appendChild(title);
|
||||
|
||||
const metaParts: string[] = [];
|
||||
metaParts.push(tDyn("views.kind." + row.kind));
|
||||
if (row.project_reference) metaParts.push(row.project_reference);
|
||||
else if (row.project_title) metaParts.push(row.project_title);
|
||||
if (metaParts.length > 0) {
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "views-calendar-row-meta";
|
||||
meta.textContent = metaParts.join(" · ");
|
||||
body.appendChild(meta);
|
||||
}
|
||||
|
||||
a.appendChild(body);
|
||||
return a;
|
||||
}
|
||||
|
||||
function rowHref(row: ViewRow): string {
|
||||
switch (row.kind) {
|
||||
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
|
||||
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
|
||||
case "approval_request": return `/inbox`;
|
||||
case "project_event":
|
||||
// project_events surface on the project's Verlauf — best we can do
|
||||
// is link to the project. If no project, leave as a non-link target.
|
||||
return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bucketing / date helpers --------------------------------------------
|
||||
|
||||
const WEEKDAY_KEYS: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
|
||||
function bucketByDate(rows: ViewRow[], filter: (d: Date) => boolean): Map<string, ViewRow[]> {
|
||||
const out = new Map<string, ViewRow[]>();
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
if (isNaN(d.getTime())) continue;
|
||||
if (!filter(d)) continue;
|
||||
const key = isoDate(d);
|
||||
const arr = out.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else out.set(key, [row]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterByDay(rows: ViewRow[], day: Date): ViewRow[] {
|
||||
const key = isoDate(day);
|
||||
return rows.filter((r) => {
|
||||
const d = new Date(r.event_date);
|
||||
if (isNaN(d.getTime())) return false;
|
||||
return isoDate(d) === key;
|
||||
});
|
||||
}
|
||||
|
||||
function startOfWeek(d: Date): Date {
|
||||
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const offset = (out.getDay() + 6) % 7; // Mon=0
|
||||
out.setDate(out.getDate() - offset);
|
||||
return out;
|
||||
}
|
||||
|
||||
function shift(d: Date, view: CalView, dir: number): Date {
|
||||
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
|
||||
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
|
||||
}
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
@@ -127,3 +466,60 @@ function isoDate(d: Date): string {
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function formatRangeLabel(view: CalView, anchor: Date): string {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
if (view === "month") {
|
||||
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
}
|
||||
if (view === "week") {
|
||||
const start = startOfWeek(anchor);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
return formatWeekHeader(start, end, lang);
|
||||
}
|
||||
return anchor.toLocaleDateString(lang, {
|
||||
weekday: "short", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatWeekHeader(start: Date, end: Date, lang: string): string {
|
||||
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
|
||||
// --- URL state -----------------------------------------------------------
|
||||
|
||||
function readView(defaultView: CalView | undefined): CalView {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(VIEW_PARAM);
|
||||
if (raw === "month" || raw === "week" || raw === "day") return raw;
|
||||
return defaultView ?? "month";
|
||||
}
|
||||
|
||||
function readAnchor(rows: ViewRow[]): Date {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(DATE_PARAM);
|
||||
if (raw) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
||||
if (m) {
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
}
|
||||
// No URL anchor — pick the first row's date, or today.
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (!isNaN(d.getTime())) return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
|
||||
function writeURL(view: CalView, anchor: Date): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(VIEW_PARAM, view);
|
||||
url.searchParams.set(DATE_PARAM, isoDate(anchor));
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
@@ -196,8 +196,25 @@ interface ApprovalDetail {
|
||||
requester_kind?: "user" | "agent";
|
||||
decider_name?: string;
|
||||
decision_note?: string;
|
||||
// counter_payload + next_request_id — populated on the OLD row of a
|
||||
// suggest-changes pair (t-paliad-216). The new row's id lets us
|
||||
// render a back-link "→ Neuer Vorschlag von {decider}". Both stay
|
||||
// unset on any non-changes_requested status.
|
||||
counter_payload?: Record<string, unknown> | null;
|
||||
next_request_id?: string;
|
||||
// Per-viewer eligibility flags resolved server-side against the caller
|
||||
// (t-paliad-202). Used to grey out actions the server would reject.
|
||||
// Optional so an older payload still renders — falsy means "treat as
|
||||
// disabled" for the safety side (no false enables).
|
||||
viewer_can_approve?: boolean;
|
||||
viewer_is_requester?: boolean;
|
||||
}
|
||||
|
||||
// Pending-row action set. suggest_changes was added in t-paliad-216 as
|
||||
// the fourth action — the approver authors a counter-proposal which
|
||||
// becomes a NEW pending row authored by them.
|
||||
type ApprovalAction = "approve" | "reject" | "revoke" | "suggest_changes";
|
||||
|
||||
function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list views-approval-list";
|
||||
@@ -256,13 +273,22 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// The bar's approval_viewer_role distinguishes which actions are
|
||||
// appropriate. The surface inspects the active role and decides
|
||||
// which buttons to keep — but for default rendering we stamp all
|
||||
// three with role-class hints and let the surface filter.
|
||||
actions.appendChild(actionBtn("approve"));
|
||||
actions.appendChild(actionBtn("reject"));
|
||||
actions.appendChild(actionBtn("revoke"));
|
||||
// All four actions are stamped on every pending row; the per-viewer
|
||||
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
|
||||
// decide which are enabled vs. greyed out with a tooltip. m's ask
|
||||
// (2026-05-17): show what's possible but disable what isn't, rather
|
||||
// than alert-after-click. The server still enforces — disabled buttons
|
||||
// are a UI hint, not a security gate.
|
||||
//
|
||||
// suggest_changes is hidden for non-update lifecycles (the backend
|
||||
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
|
||||
// so we don't even render the button for them).
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
if (detail.lifecycle_event === "update") {
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
}
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
} else if (detail.status) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
@@ -277,6 +303,22 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
// Back-link from the OLD changes_requested row to the NEW pending
|
||||
// counter row (t-paliad-216). Hydrated server-side as
|
||||
// detail.next_request_id; the surface renders a link that scrolls
|
||||
// / filters to the new row. Falsy next_request_id = no link (e.g.
|
||||
// older rows pre-mig-103, or rows where the server hasn't joined the
|
||||
// back-pointer).
|
||||
if (detail.status === "changes_requested" && detail.next_request_id) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "inbox-row-next-request";
|
||||
link.href = `#request-${detail.next_request_id}`;
|
||||
link.dataset.nextRequestId = detail.next_request_id;
|
||||
const deciderName = detail.decider_name || "";
|
||||
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
|
||||
li.appendChild(link);
|
||||
}
|
||||
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
@@ -312,16 +354,46 @@ function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionBtn(action: "approve" | "reject" | "revoke"): HTMLButtonElement {
|
||||
function approvalActionBtn(
|
||||
action: ApprovalAction,
|
||||
detail: ApprovalDetail,
|
||||
): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.action = action;
|
||||
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
|
||||
// suggest_changes shares the secondary style with revoke; reject is
|
||||
// danger (terminal "no"); approve is primary.
|
||||
const cls = action === "approve"
|
||||
? "btn-primary"
|
||||
: action === "reject"
|
||||
? "btn-danger"
|
||||
: "btn-secondary";
|
||||
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
|
||||
// approve / reject / suggest_changes share the canApprove eligibility
|
||||
// gate; revoke is requester-only.
|
||||
const reason = disabledReasonFor(action, detail);
|
||||
if (reason) {
|
||||
btn.disabled = true;
|
||||
btn.title = t(reason);
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
|
||||
function disabledReasonFor(
|
||||
action: ApprovalAction,
|
||||
detail: ApprovalDetail,
|
||||
): I18nKey | null {
|
||||
if (action === "revoke") {
|
||||
return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester";
|
||||
}
|
||||
// approve / reject / suggest_changes — same gate as the server's canApprove.
|
||||
if (detail.viewer_can_approve) return null;
|
||||
if (detail.viewer_is_requester) return "approvals.disabled.self_approval";
|
||||
return "approvals.disabled.not_authorized";
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
|
||||
@@ -467,6 +467,11 @@ export function paint(
|
||||
}
|
||||
|
||||
// Lane separators — horizontal lines between rows + labels in the gutter.
|
||||
// Labels live inside <foreignObject> so HTML/CSS handles ellipsis +
|
||||
// tooltip cleanly. SVG <text> has no auto-clipping and long titles
|
||||
// would bleed into the chart canvas (t-paliad-211).
|
||||
const labelPadding = 8;
|
||||
const labelMaxWidth = Math.max(0, chart.viewport.laneLabelWidth - labelPadding * 2);
|
||||
for (let i = 0; i < chart.laneRows.length; i++) {
|
||||
const row = chart.laneRows[i];
|
||||
if (i > 0) {
|
||||
@@ -479,13 +484,19 @@ export function paint(
|
||||
}));
|
||||
}
|
||||
if (row.label) {
|
||||
const labelEl = svg("text", {
|
||||
class: "chart-lane-label",
|
||||
x: 8,
|
||||
y: row.y + row.height / 2 + 4,
|
||||
const fo = svg("foreignObject", {
|
||||
class: "chart-lane-label-fo",
|
||||
x: labelPadding,
|
||||
y: row.y,
|
||||
width: labelMaxWidth,
|
||||
height: row.height,
|
||||
});
|
||||
labelEl.textContent = row.label;
|
||||
gGrid.appendChild(labelEl);
|
||||
const div = document.createElement("div");
|
||||
div.className = "chart-lane-label";
|
||||
div.textContent = row.label;
|
||||
div.title = row.label;
|
||||
fo.appendChild(div);
|
||||
gGrid.appendChild(fo);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "./shape-timeline-chart";
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
import { t } from "../i18n";
|
||||
|
||||
// shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views
|
||||
// host for the chart renderer.
|
||||
@@ -23,6 +24,12 @@ import type { RenderSpec, ViewRow } from "./types";
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4.
|
||||
|
||||
// Zoom levels in ascending span (t-paliad-211). Width-only — the chart's
|
||||
// existing range presets already provide three meaningful zoom levels.
|
||||
// Stored in URL as ?tl_zoom=1y|2y|all.
|
||||
const ZOOM_LEVELS: RangePreset[] = ["1y", "2y", "all"];
|
||||
const ZOOM_PARAM = "tl_zoom";
|
||||
|
||||
export function renderTimelineShape(
|
||||
host: HTMLElement,
|
||||
rows: ReadonlyArray<ViewRow>,
|
||||
@@ -35,21 +42,127 @@ export function renderTimelineShape(
|
||||
const { events, lanes } = adapt(rows);
|
||||
const cfg = render.timeline ?? {};
|
||||
|
||||
// Resolve the initial zoom: URL > render spec > "1y" default.
|
||||
const initialZoom = resolveInitialZoom(cfg.range_preset);
|
||||
|
||||
// Toolbar lives above the chart in its own row so it doesn't compete
|
||||
// with the date-axis / lane labels for space.
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "views-timeline-toolbar";
|
||||
host.appendChild(toolbar);
|
||||
|
||||
const chartHost = document.createElement("div");
|
||||
chartHost.className = "views-timeline-chart-host-inner";
|
||||
host.appendChild(chartHost);
|
||||
|
||||
// The CV adapter has no per-project "id" to fetch live timeline data
|
||||
// for — we hand mount() a placeholder projectId and the staticData
|
||||
// pre-loaded array so it skips the project endpoint entirely. If the
|
||||
// user clicks a mark, the renderer's default click handler still
|
||||
// resolves /deadlines/{id} / /appointments/{id} from the adapted
|
||||
// event's id field, so deep-links land on the correct entity page.
|
||||
return mount(host, {
|
||||
const handle = mount(chartHost, {
|
||||
projectId: "cv",
|
||||
staticData: { events, lanes },
|
||||
palette: (cfg.palette as Palette | undefined) ?? "default",
|
||||
density: (cfg.density as Density | undefined) ?? "standard",
|
||||
rangePreset: (cfg.range_preset as RangePreset | undefined) ?? "1y",
|
||||
rangePreset: initialZoom,
|
||||
rangeFrom: cfg.range_from,
|
||||
rangeTo: cfg.range_to,
|
||||
});
|
||||
|
||||
let currentZoom = initialZoom;
|
||||
const setZoom = (next: RangePreset) => {
|
||||
if (next === currentZoom) return;
|
||||
currentZoom = next;
|
||||
handle.setRange(next);
|
||||
writeZoomURL(next);
|
||||
paintToolbar();
|
||||
};
|
||||
|
||||
const paintToolbar = () => {
|
||||
toolbar.innerHTML = "";
|
||||
|
||||
const zoomGroup = document.createElement("div");
|
||||
zoomGroup.className = "views-timeline-zoom-group";
|
||||
|
||||
const zoomLabel = document.createElement("span");
|
||||
zoomLabel.className = "views-timeline-zoom-label";
|
||||
zoomLabel.textContent = t("views.timeline.zoom.label");
|
||||
zoomGroup.appendChild(zoomLabel);
|
||||
|
||||
const zoomOut = document.createElement("button");
|
||||
zoomOut.type = "button";
|
||||
zoomOut.className = "btn-secondary btn-small views-timeline-zoom-btn";
|
||||
zoomOut.setAttribute("aria-label", t("views.timeline.zoom.out"));
|
||||
zoomOut.title = t("views.timeline.zoom.out");
|
||||
zoomOut.textContent = "−";
|
||||
zoomOut.disabled = currentZoom === ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
|
||||
zoomOut.addEventListener("click", () => {
|
||||
const idx = ZOOM_LEVELS.indexOf(currentZoom);
|
||||
if (idx < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[idx + 1]);
|
||||
});
|
||||
zoomGroup.appendChild(zoomOut);
|
||||
|
||||
// Active-level chips (1y / 2y / all). Clicking jumps directly.
|
||||
const chips = document.createElement("div");
|
||||
chips.className = "views-timeline-zoom-chips agenda-chip-row";
|
||||
for (const level of ZOOM_LEVELS) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "agenda-chip views-timeline-zoom-chip"
|
||||
+ (level === currentZoom ? " agenda-chip-active" : "");
|
||||
chip.dataset.zoom = level;
|
||||
chip.textContent = t(zoomLevelKey(level));
|
||||
chip.addEventListener("click", () => setZoom(level));
|
||||
chips.appendChild(chip);
|
||||
}
|
||||
zoomGroup.appendChild(chips);
|
||||
|
||||
const zoomIn = document.createElement("button");
|
||||
zoomIn.type = "button";
|
||||
zoomIn.className = "btn-secondary btn-small views-timeline-zoom-btn";
|
||||
zoomIn.setAttribute("aria-label", t("views.timeline.zoom.in"));
|
||||
zoomIn.title = t("views.timeline.zoom.in");
|
||||
zoomIn.textContent = "+";
|
||||
zoomIn.disabled = currentZoom === ZOOM_LEVELS[0];
|
||||
zoomIn.addEventListener("click", () => {
|
||||
const idx = ZOOM_LEVELS.indexOf(currentZoom);
|
||||
if (idx > 0) setZoom(ZOOM_LEVELS[idx - 1]);
|
||||
});
|
||||
zoomGroup.appendChild(zoomIn);
|
||||
|
||||
toolbar.appendChild(zoomGroup);
|
||||
};
|
||||
|
||||
paintToolbar();
|
||||
|
||||
// Apply the URL zoom if it differed from the spec — mount() already
|
||||
// used initialZoom so this is a no-op when URL was empty. But when URL
|
||||
// disagreed with the spec, mount() honoured the URL and the toolbar
|
||||
// already reflects that, so nothing extra to do here.
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
function zoomLevelKey(level: RangePreset): "views.timeline.zoom.1y" | "views.timeline.zoom.2y" | "views.timeline.zoom.all" {
|
||||
if (level === "1y") return "views.timeline.zoom.1y";
|
||||
if (level === "2y") return "views.timeline.zoom.2y";
|
||||
return "views.timeline.zoom.all";
|
||||
}
|
||||
|
||||
function resolveInitialZoom(spec: string | undefined): RangePreset {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(ZOOM_PARAM);
|
||||
if (raw && (ZOOM_LEVELS as string[]).includes(raw)) return raw as RangePreset;
|
||||
if (spec && (ZOOM_LEVELS as string[]).includes(spec)) return spec as RangePreset;
|
||||
return "1y";
|
||||
}
|
||||
|
||||
function writeZoomURL(zoom: RangePreset): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(ZOOM_PARAM, zoom);
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
export interface AdapterResult {
|
||||
|
||||
67
frontend/src/client/views/verfahrensablauf-core.test.ts
Normal file
67
frontend/src/client/views/verfahrensablauf-core.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
deadlineCardHtml,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
// cells (m/paliad#59). When CardOpts.editable=true the card renderer must
|
||||
// emit `class="… frist-date-edit"` with `data-rule-code` + `data-current-
|
||||
// date` on the date span. Pages then attach a delegated click handler that
|
||||
// resolves that selector to swap in an inline `<input type="date">`. If a
|
||||
// future refactor drops the attrs, /tools/verfahrensablauf and
|
||||
// /tools/fristenrechner both silently lose click-to-edit (no script error,
|
||||
// nothing happens on click). These tests pin the contract.
|
||||
//
|
||||
// Fixture leaves ruleRef/legalSource* empty so deadlineCardHtml stays
|
||||
// inside its non-DOM code paths (escHtml is DOM-backed and bun test runs
|
||||
// in plain Node without jsdom).
|
||||
|
||||
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
|
||||
code: "upc-rop-12",
|
||||
name: "Klageerwiderung",
|
||||
nameEN: "Statement of Defence",
|
||||
party: "defendant",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-07-15",
|
||||
originalDate: "2026-07-15",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
test("date span carries frist-date-edit class + data-rule-code + data-current-date", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true, editable: true });
|
||||
expect(html).toContain('class="timeline-date frist-date-edit"');
|
||||
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||
expect(html).toContain('data-current-date="2026-07-15"');
|
||||
expect(html).toContain('role="button"');
|
||||
expect(html).toContain('tabindex="0"');
|
||||
});
|
||||
|
||||
test("editable=false (default) emits the date span without click-to-edit attrs", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).toContain("timeline-date");
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
expect(html).not.toContain('role="button"');
|
||||
});
|
||||
|
||||
test("root event suppresses editable even when editable=true (root has no override semantic)", () => {
|
||||
const html = deadlineCardHtml(dl({ isRootEvent: true }), { showParty: true, editable: true });
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
|
||||
test("isCourtSet renders the court-set placeholder with click-to-edit so users can pin a real date", () => {
|
||||
const html = deadlineCardHtml(dl({ isCourtSet: true }), { showParty: true, editable: true });
|
||||
expect(html).toContain("timeline-court-set frist-date-edit");
|
||||
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||
});
|
||||
|
||||
test("empty rule code with editable=true still suppresses click-to-edit (no anchor target)", () => {
|
||||
const html = deadlineCardHtml(dl({ code: "" }), { showParty: true, editable: true });
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
});
|
||||
@@ -32,9 +32,20 @@ export interface CalculatedDeadline {
|
||||
name: string;
|
||||
nameEN: string;
|
||||
party: string;
|
||||
isMandatory: boolean;
|
||||
// Priority is the canonical 4-way enum (Slice 8 made it canonical;
|
||||
// Slice 9 dropped the legacy isMandatory / isOptional pair from the
|
||||
// wire). priorityRendering(d) below branches on it.
|
||||
priority: "mandatory" | "recommended" | "optional" | "informational";
|
||||
ruleRef: string;
|
||||
legalSource?: string;
|
||||
// legalSourceDisplay is the pretty form ("UPC RoP R.220(1)") produced
|
||||
// by FormatLegalSourceDisplay on the backend. Renderer prefers this
|
||||
// over ruleRef when set; falls back to ruleRef otherwise.
|
||||
legalSourceDisplay?: string;
|
||||
// legalSourceURL is the youpc.org/laws permalink when the cited body
|
||||
// is hosted there (UPCRoP / UPCA / UPCS today). Empty for DE/EPA/EU
|
||||
// bodies — the renderer shows display text without a link.
|
||||
legalSourceURL?: string;
|
||||
notes?: string;
|
||||
notesEN?: string;
|
||||
dueDate: string;
|
||||
@@ -44,8 +55,41 @@ export interface CalculatedDeadline {
|
||||
isRootEvent: boolean;
|
||||
isCourtSet: boolean;
|
||||
isCourtSetIndirect?: boolean;
|
||||
isOptional?: boolean;
|
||||
isOverridden?: boolean;
|
||||
// conditionExpr surfaces the jsonb gate predicate (design §2.4) so
|
||||
// the rule-editor + admin views can render the rule's gating shape.
|
||||
// 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;
|
||||
}
|
||||
|
||||
// priorityRendering returns the per-priority UX hints the save-modal
|
||||
// uses. Maps the unified Priority enum to:
|
||||
// - preChecked: whether the save-modal pre-checks the row
|
||||
// - hideSave: whether the row renders without a save button at all
|
||||
// (informational = notice card, no save action)
|
||||
//
|
||||
// Phase 3 Slice 9 (t-paliad-195) dropped the legacy
|
||||
// (isMandatory, isOptional) fallback that pre-Slice-8 backends
|
||||
// emitted. The backend now always populates `priority`; an unknown
|
||||
// value falls back to "render as mandatory" (safe default — never
|
||||
// silently drop a rule).
|
||||
export function priorityRendering(
|
||||
d: CalculatedDeadline,
|
||||
): { preChecked: boolean; hideSave: boolean } {
|
||||
switch (d.priority) {
|
||||
case "mandatory":
|
||||
case "recommended":
|
||||
return { preChecked: true, hideSave: false };
|
||||
case "optional":
|
||||
return { preChecked: false, hideSave: false };
|
||||
case "informational":
|
||||
return { preChecked: false, hideSave: true };
|
||||
}
|
||||
// Unknown priority value: pre-Slice-8 backend or a forward-compat
|
||||
// future value. Safe default: render as mandatory so the rule is
|
||||
// surfaced + saved. Never silently drop.
|
||||
return { preChecked: true, hideSave: false };
|
||||
}
|
||||
|
||||
export interface DeadlineResponse {
|
||||
@@ -175,6 +219,13 @@ export interface CardOpts {
|
||||
// verfahrensablauf abstract-browse surface keeps editable=false because
|
||||
// there's no anchor-override state on that page in Slice 1.
|
||||
editable?: boolean;
|
||||
// showNotes controls how the per-rule descriptive notes render:
|
||||
// true → expanded `<div class="timeline-notes">…</div>` below the card
|
||||
// false → compact ⓘ icon next to the meta line, full text on hover
|
||||
// (browser-native `title` attribute) and screen-reader-readable
|
||||
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
|
||||
// re-renders. Default false — notes are noisy on long timelines.
|
||||
showNotes?: boolean;
|
||||
}
|
||||
|
||||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||||
@@ -191,9 +242,12 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
|
||||
const mandatoryBadge = dl.isMandatory
|
||||
? ""
|
||||
: '<span class="optional-badge">optional</span>';
|
||||
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
|
||||
// priority directly. Optional badge fires only on 'optional'
|
||||
// priority (RoP.151-style opt-in deadlines).
|
||||
const mandatoryBadge = dl.priority === "optional"
|
||||
? '<span class="optional-badge">optional</span>'
|
||||
: "";
|
||||
|
||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||
|
||||
@@ -201,19 +255,35 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
|
||||
: "";
|
||||
|
||||
const ruleRef = dl.ruleRef
|
||||
? `<span class="timeline-rule">${dl.ruleRef}</span>`
|
||||
: "";
|
||||
// Prefer the structured legalSource (pretty display + youpc.org link
|
||||
// when hosted there) over the bare rule_code fallback. UPC.RoP rules
|
||||
// link to /laws/UPCRoP/<n>; DE / EPA / EU bodies have no youpc home
|
||||
// yet so we render display text plain.
|
||||
const legalDisplay = dl.legalSourceDisplay || "";
|
||||
const legalURL = dl.legalSourceURL || "";
|
||||
let ruleRef = "";
|
||||
if (legalDisplay && legalURL) {
|
||||
ruleRef = `<a class="timeline-rule timeline-rule--link" href="${escAttr(legalURL)}" target="_blank" rel="noopener noreferrer">${escHtml(legalDisplay)}</a>`;
|
||||
} else if (legalDisplay) {
|
||||
ruleRef = `<span class="timeline-rule">${escHtml(legalDisplay)}</span>`;
|
||||
} else if (dl.ruleRef) {
|
||||
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
|
||||
}
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const notes = noteText
|
||||
const showNotes = opts.showNotes === true;
|
||||
const notesBlock = noteText && showNotes
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
: "";
|
||||
const noteHint = noteText && !showNotes
|
||||
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef)
|
||||
const meta = (opts.showParty || ruleRef || noteHint)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${ruleRef}
|
||||
${noteHint}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
@@ -226,7 +296,88 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
${notes}`;
|
||||
${notesBlock}`;
|
||||
}
|
||||
|
||||
// ─── inline date editor (click-to-edit per-rule due date) ────────────────
|
||||
//
|
||||
// The renderer emits `<span class="frist-date-edit" data-rule-code="…"
|
||||
// data-current-date="YYYY-MM-DD" role="button" tabindex="0">…</span>` when
|
||||
// CardOpts.editable is true. Pages call wireDateEditClicks() on their
|
||||
// result container once, and the delegated click/keydown handlers swap a
|
||||
// clicked span for a `<input type="date">` editor via openInlineDateEditor.
|
||||
// The caller's onCommit callback receives (ruleCode, newValue) — an empty
|
||||
// newValue means "revert" (clear the anchor override and let the calculator
|
||||
// re-project). The actual recompute is the caller's job — they own the
|
||||
// anchor-overrides map + the calc dispatch.
|
||||
|
||||
export function openInlineDateEditor(
|
||||
span: HTMLElement,
|
||||
onCommit: (ruleCode: string, newValue: string) => void,
|
||||
): void {
|
||||
const ruleCode = span.dataset.ruleCode || "";
|
||||
if (!ruleCode) return;
|
||||
const current = span.dataset.currentDate || "";
|
||||
const editor = document.createElement("input");
|
||||
editor.type = "date";
|
||||
editor.className = "frist-date-edit-input";
|
||||
editor.value = current;
|
||||
|
||||
let done = false;
|
||||
const cancel = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
editor.replaceWith(span);
|
||||
};
|
||||
const commit = (newValue: string) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
onCommit(ruleCode, newValue);
|
||||
};
|
||||
|
||||
editor.addEventListener("blur", () => {
|
||||
if (editor.value !== current) commit(editor.value);
|
||||
else cancel();
|
||||
});
|
||||
editor.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key === "Enter") {
|
||||
e.preventDefault();
|
||||
editor.blur();
|
||||
} else if (ke.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
span.replaceWith(editor);
|
||||
editor.focus();
|
||||
if (editor.value) editor.select();
|
||||
}
|
||||
|
||||
// wireDateEditClicks attaches delegated click + keyboard handlers to the
|
||||
// timeline result container so click-to-edit survives every innerHTML
|
||||
// rewrite the page does on recalc. Idempotent — re-calling on the same
|
||||
// container does nothing (the dataset flag short-circuits).
|
||||
export function wireDateEditClicks(
|
||||
container: HTMLElement,
|
||||
onCommit: (ruleCode: string, newValue: string) => void,
|
||||
): void {
|
||||
if (container.dataset.dateEditWired === "1") return;
|
||||
container.dataset.dateEditWired = "1";
|
||||
container.addEventListener("click", (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
openInlineDateEditor(target, onCommit);
|
||||
});
|
||||
container.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
e.preventDefault();
|
||||
openInlineDateEditor(target, onCommit);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
@@ -300,7 +451,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
@@ -374,23 +525,23 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
const courtCache = new Map<string, CourtRow[]>();
|
||||
|
||||
export function courtTypesFor(proceedingType: string): string[] {
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
|
||||
return ["UPC-CoA"];
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
if (proceedingType === "upc.rev.cfi") {
|
||||
return ["UPC-CD", "UPC-LD"];
|
||||
}
|
||||
if (proceedingType.startsWith("UPC_")) {
|
||||
if (proceedingType.startsWith("upc.")) {
|
||||
return ["UPC-LD"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function defaultCourtFor(proceedingType: string): string {
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
|
||||
return "upc-coa-luxembourg";
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
if (proceedingType === "upc.rev.cfi") {
|
||||
return "upc-cd-paris";
|
||||
}
|
||||
return "upc-ld-muenchen";
|
||||
|
||||
@@ -64,28 +64,28 @@ export function ProjectFormFields(): string {
|
||||
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-client-number" data-i18n="projects.field.client_number">Client-Nr. (7 Ziffern)</label>
|
||||
<label htmlFor="project-client-number" data-i18n="projects.field.client_number">Client-Nr. (6 Ziffern)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project-client-number"
|
||||
pattern="[0-9]{7}"
|
||||
maxLength={7}
|
||||
placeholder="0001234"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
placeholder="001234"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-matter-number" data-i18n="projects.field.matter_number">Matter-Nr. (7 Ziffern)</label>
|
||||
<label htmlFor="project-matter-number" data-i18n="projects.field.matter_number">Matter-Nr. (6 Ziffern)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project-matter-number"
|
||||
pattern="[0-9]{7}"
|
||||
maxLength={7}
|
||||
placeholder="0000567"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
placeholder="000567"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="form-hint" data-i18n="projects.field.clientmatter.hint">
|
||||
{`${FIRM}-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt
|
||||
{`${FIRM}-Billing-Nummern. Format CCCCCC.MMMMMM. Client-Nr. wird an Unterprojekte vererbt
|
||||
(überschreibbar).`}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -199,6 +199,8 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
|
||||
|
||||
@@ -5,12 +5,14 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// The /* __PALIAD_DASHBOARD_DATA__ */ token below is replaced at request time
|
||||
// by the Go handler (internal/handlers/dashboard_shell.go) with a JSON blob
|
||||
// assigned to window.__PALIAD_DASHBOARD__. Keep the token intact and exactly
|
||||
// once in the output.
|
||||
// The three /* __PALIAD_DASHBOARD_*__ */ tokens below are replaced at
|
||||
// request time by the Go handler (internal/handlers/dashboard_shell.go)
|
||||
// with JSON blobs assigned to window.__PALIAD_DASHBOARD__,
|
||||
// window.__PALIAD_DASHBOARD_LAYOUT__, and window.__PALIAD_DASHBOARD_CATALOG__.
|
||||
// Keep each token intact and exactly once in the output. The latter two
|
||||
// power the per-user configurable layout (t-paliad-219).
|
||||
const HYDRATION_SCRIPT =
|
||||
"/*__PALIAD_DASHBOARD_DATA__*/";
|
||||
"/*__PALIAD_DASHBOARD_DATA__*//*__PALIAD_DASHBOARD_LAYOUT__*//*__PALIAD_DASHBOARD_CATALOG__*/";
|
||||
|
||||
// Chevron used as the collapsible-section disclosure indicator. CSS rotates
|
||||
// it 90deg clockwise when the section is open via the
|
||||
@@ -23,12 +25,13 @@ const ICON_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
// renders all sections expanded so unstyled fallback is sensible.
|
||||
function CollapsibleSection(props: {
|
||||
id: string;
|
||||
widgetKey: string;
|
||||
headingI18n: string;
|
||||
headingDe: string;
|
||||
children: any;
|
||||
}): string {
|
||||
return (
|
||||
<section className="dashboard-section" data-collapse-key={props.id} aria-expanded="true">
|
||||
<section className="dashboard-section" data-collapse-key={props.id} data-widget-key={props.widgetKey} aria-expanded="true">
|
||||
<button type="button" className="dashboard-section-toggle" aria-expanded="true">
|
||||
<h3 className="dashboard-section-heading" data-i18n={props.headingI18n}>{props.headingDe}</h3>
|
||||
<span className="dashboard-section-chevron" aria-hidden="true"
|
||||
@@ -88,7 +91,7 @@ export function renderDashboard(): string {
|
||||
</div>
|
||||
|
||||
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
|
||||
<CollapsibleSection id="summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<CollapsibleSection id="summary" widgetKey="deadline-summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
|
||||
@@ -116,7 +119,7 @@ export function renderDashboard(): string {
|
||||
{/* Matter summary card — single tappable card, kept outside the
|
||||
collapsible scaffold because its h3 is internal to the card
|
||||
and doubles as the navigation affordance. */}
|
||||
<section className="dashboard-matters">
|
||||
<section className="dashboard-matters" data-widget-key="matter-summary">
|
||||
<a href="/projects" className="dashboard-matter-card">
|
||||
<div className="dashboard-matter-header">
|
||||
<h3 data-i18n="dashboard.matters.heading">Meine Akten</h3>
|
||||
@@ -145,14 +148,14 @@ export function renderDashboard(): string {
|
||||
layout still applies; collapse hides the body of each col
|
||||
but leaves the heading row in the grid. */}
|
||||
<div className="dashboard-columns">
|
||||
<CollapsibleSection id="deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
<CollapsibleSection id="deadlines" widgetKey="upcoming-deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
|
||||
Keine Fristen in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection id="appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<CollapsibleSection id="appointments" widgetKey="upcoming-appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
|
||||
Keine Termine in den nächsten 7 Tagen.
|
||||
@@ -166,7 +169,7 @@ export function renderDashboard(): string {
|
||||
no chip filters, no URL state — a 30-day window of
|
||||
upcoming items grouped by day. The standalone /agenda
|
||||
route is unchanged for direct-link compatibility. */}
|
||||
<CollapsibleSection id="agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<CollapsibleSection id="agenda" widgetKey="inline-agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<div className="dashboard-agenda">
|
||||
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
|
||||
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
|
||||
@@ -178,9 +181,26 @@ export function renderDashboard(): string {
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Inbox-approvals widget (t-paliad-219 — new in v1). The
|
||||
list mirrors /inbox's "Approver" axis but capped at the
|
||||
widget's count setting. Renders the empty state when
|
||||
the user has no open approvals to review. */}
|
||||
<CollapsibleSection id="inbox-approvals" widgetKey="inbox-approvals" headingI18n="dashboard.inbox.heading" headingDe="Offene Freigaben">
|
||||
<div className="dashboard-inbox">
|
||||
<p className="dashboard-inbox-summary" id="dashboard-inbox-summary" style="display:none"></p>
|
||||
<ul className="dashboard-list" id="dashboard-inbox-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-inbox-empty" style="display:none" data-i18n="dashboard.inbox.empty">
|
||||
Keine offenen Freigaben.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/inbox" data-i18n="dashboard.inbox.full_link">Vollständigen Posteingang öffnen →</a>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Activity feed — moved under Agenda per m's design call
|
||||
(t-paliad-162). */}
|
||||
<CollapsibleSection id="activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<CollapsibleSection id="activity" widgetKey="recent-activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
|
||||
Noch keine Aktivität erfasst.
|
||||
|
||||
@@ -54,34 +54,44 @@ function quickChip(c: QuickChip): string {
|
||||
}
|
||||
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "UPC_INF", i18nKey: "deadlines.upc_inf", name: "Verletzungsverfahren" },
|
||||
{ code: "UPC_REV", i18nKey: "deadlines.upc_rev", name: "Nichtigkeitsklage" },
|
||||
{ code: "UPC_PI", i18nKey: "deadlines.upc_pi", name: "Einstw. Ma\u00dfnahmen" },
|
||||
{ code: "UPC_APP", i18nKey: "deadlines.upc_app", name: "Berufung" },
|
||||
{ code: "UPC_DAMAGES", i18nKey: "deadlines.upc_damages", name: "Schadensbemessung" },
|
||||
{ code: "UPC_DISCOVERY", i18nKey: "deadlines.upc_discovery", name: "Bucheinsicht" },
|
||||
{ code: "UPC_COST_APPEAL", i18nKey: "deadlines.upc_cost_appeal", name: "Berufung Kosten" },
|
||||
{ code: "UPC_APP_ORDERS", i18nKey: "deadlines.upc_app_orders", name: "Berufung Anordnungen" },
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Ma\u00dfnahmen" },
|
||||
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
|
||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
const DE_TYPES: ProceedingDef[] = [
|
||||
{ code: "DE_INF", i18nKey: "deadlines.de_inf", name: "Verletzungsklage (LG)" },
|
||||
{ code: "DE_INF_OLG", i18nKey: "deadlines.de_inf_olg", name: "Berufung OLG" },
|
||||
{ code: "DE_INF_BGH", i18nKey: "deadlines.de_inf_bgh", name: "Revision/NZB BGH" },
|
||||
{ code: "DE_NULL", i18nKey: "deadlines.de_null", name: "Nichtigkeitsverfahren" },
|
||||
{ code: "DE_NULL_BGH", i18nKey: "deadlines.de_null_bgh", name: "Berufung BGH (Nichtigk.)" },
|
||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
|
||||
// so a user scanning the picker sees the instance-and-role at a glance
|
||||
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
|
||||
// verfahren". Sub-group headers convey the type grouping. Combined-
|
||||
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
|
||||
const DE_INF_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
|
||||
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
|
||||
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
|
||||
];
|
||||
|
||||
const DE_NULL_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
|
||||
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "EPA_OPP", i18nKey: "deadlines.epa_opp", name: "Einspruchsverfahren" },
|
||||
{ code: "EPA_APP", i18nKey: "deadlines.epa_app", name: "Beschwerdeverfahren" },
|
||||
{ code: "EP_GRANT", i18nKey: "deadlines.ep_grant", name: "EP-Erteilungsverfahren" },
|
||||
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
|
||||
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
|
||||
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "DPMA_OPP", i18nKey: "deadlines.dpma_opp", name: "Einspruch DPMA" },
|
||||
{ code: "DPMA_BPATG_BESCHWERDE", i18nKey: "deadlines.dpma_bpatg_beschwerde", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "DPMA_BGH_RB", i18nKey: "deadlines.dpma_bgh_rb", name: "Rechtsbeschwerde BGH" },
|
||||
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
|
||||
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
export function renderFristenrechner(): string {
|
||||
@@ -234,78 +244,74 @@ export function renderFristenrechner(): string {
|
||||
<span data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
|
||||
</h2>
|
||||
|
||||
<div className="fristen-mode-toggle" role="radiogroup" aria-label="B1/B2 mode">
|
||||
<label className="fristen-mode-toggle-option">
|
||||
<input type="radio" name="fristen-b-mode" value="tree" id="fristen-b-mode-tree" />
|
||||
<span data-i18n="deadlines.pathway.b.mode.tree">Schritt-für-Schritt (Entscheidungsbaum)</span>
|
||||
</label>
|
||||
<label className="fristen-mode-toggle-option">
|
||||
<input type="radio" name="fristen-b-mode" value="filter" id="fristen-b-mode-filter" />
|
||||
<span data-i18n="deadlines.pathway.b.mode.filter">Filter / Suche</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* B1 panel — decision tree above + concept-card results below.
|
||||
fristen-b1-cascade hosts the breadcrumb / question / button row.
|
||||
fristen-b1-results hosts the narrowing concept-card list,
|
||||
populated by runB1Search() in fristenrechner.ts. The cards
|
||||
reuse renderConceptCard() (B2's card shape).
|
||||
|
||||
m/paliad#15 follow-up: the inbox-channel chip lives at the
|
||||
top of THIS panel (not page-level) — m's call: "inside the
|
||||
decision tree because it helps us to determine what to do
|
||||
next". The chip narrows the cascade entry-points + B2 fine
|
||||
forum filter; Pathway A's Verlauf doesn't see it. */}
|
||||
{/* B1 panel — row-stack cascade.
|
||||
`#fristen-row-stack` hosts the perspective / inbox /
|
||||
cascade rows (t-paliad-180 Slice 1; t-paliad-197 Slice 2
|
||||
added project-driven prefills + auto-walk). The
|
||||
stack-header above carries the inline-search trigger
|
||||
(t-paliad-198 Slice 3 — clicking expands
|
||||
`#fristen-row-search-panel` over the row stack instead
|
||||
of routing to the legacy B2 surface) and the reset link.
|
||||
`#fristen-b1-results` is unchanged — it renders concept
|
||||
cards for both cascade-narrowing AND inline-search
|
||||
results, so users see the same card layout regardless
|
||||
of how they reached a deadline rule. */}
|
||||
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
|
||||
{/* Slice 3c — perspective chip strip. Klägerseite vs
|
||||
Beklagtenseite hides cascade leaves whose party tag
|
||||
contradicts the user's side. "Beide" / no chip
|
||||
leaves the cascade unfiltered. */}
|
||||
<div className="fristen-perspective-bar" id="fristen-perspective-bar" role="group" aria-label="Perspective">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.perspective.label">Ich vertrete:</span>
|
||||
<div className="fristen-inbox-chips">
|
||||
<button type="button" className="fristen-inbox-chip" data-perspective="claimant"
|
||||
data-i18n-title="deadlines.perspective.claimant.title" title="Klägerseite (Proactive)">
|
||||
<span data-i18n="deadlines.perspective.claimant.short">Kläger</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-perspective="defendant"
|
||||
data-i18n-title="deadlines.perspective.defendant.title" title="Beklagtenseite (Reactive)">
|
||||
<span data-i18n="deadlines.perspective.defendant.short">Beklagter</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-perspective-clear>
|
||||
<span data-i18n="deadlines.perspective.both.short">Beide</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* t-paliad-164 — predefined-from-Akte hint. Hidden by
|
||||
default; client/fristenrechner.ts shows it when the
|
||||
active perspective came from project.our_side. The
|
||||
user can still click another chip to override. */}
|
||||
<span className="fristen-perspective-hint" id="fristen-perspective-hint"
|
||||
data-i18n="deadlines.perspective.predefined_hint" hidden>
|
||||
vorgegeben durch Akte
|
||||
</span>
|
||||
<div className="fristen-row-stack-header" id="fristen-row-stack-header">
|
||||
<button type="button" className="fristen-row-search-link" id="fristen-row-search-link"
|
||||
data-i18n-title="deadlines.row.search.link.title"
|
||||
aria-expanded="false"
|
||||
aria-controls="fristen-row-search-panel"
|
||||
title="Direkt nach einer Frist suchen">
|
||||
<span aria-hidden="true">🔍</span>{" "}
|
||||
<span data-i18n="deadlines.row.search.link">Direkt suchen</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-row-reset-link" id="fristen-row-reset"
|
||||
data-i18n-title="deadlines.row.reset.title"
|
||||
title="Pfad zurücksetzen — alle Cascade-Antworten verwerfen">
|
||||
<span aria-hidden="true">↺</span>{" "}
|
||||
<span data-i18n="deadlines.row.reset">Pfad zurücksetzen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>
|
||||
<div className="fristen-inbox-chips">
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="cms"
|
||||
data-i18n-title="deadlines.inbox.cms.title" title="UPC — über CMS">
|
||||
CMS
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="bea"
|
||||
data-i18n-title="deadlines.inbox.bea.title" title="Nationale Verfahren — über beA">
|
||||
beA
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="posteingang"
|
||||
data-i18n-title="deadlines.inbox.posteingang.title" title="Nationale Verfahren — Postzustellung">
|
||||
<span data-i18n="deadlines.inbox.posteingang">Posteingang</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-inbox-clear>
|
||||
<span data-i18n="deadlines.inbox.all">Alle</span>
|
||||
|
||||
{/* Inline search overlay (t-paliad-198 Slice 3). Hidden by
|
||||
default; the search icon-button in the stack header
|
||||
toggles it open / closed. While open, the row stack is
|
||||
hidden and the search input drives `#fristen-b1-results`
|
||||
directly — same surface the cascade leaf populates so
|
||||
the user sees one consistent concept-card list. */}
|
||||
<div className="fristen-row-search-panel" id="fristen-row-search-panel" hidden role="search">
|
||||
<button type="button" className="fristen-row-search-panel-back" id="fristen-row-search-panel-back"
|
||||
data-i18n-title="deadlines.row.search.panel.back.title"
|
||||
title="Zurück zum Entscheidungsbaum">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.row.search.panel.back">Zurück zum Entscheidungsbaum</span>
|
||||
</button>
|
||||
<div className="fristen-row-search-panel-input-wrap">
|
||||
<svg className="fristen-row-search-panel-icon" width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="fristen-row-search-panel-input"
|
||||
className="fristen-row-search-panel-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.row.search.panel.placeholder"
|
||||
placeholder="Frist suchen…"
|
||||
aria-label="Frist suchen"
|
||||
/>
|
||||
<button type="button" className="fristen-row-search-panel-clear" id="fristen-row-search-panel-clear"
|
||||
data-i18n-title="deadlines.row.search.panel.clear" title="Eingabe leeren" hidden>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fristen-b1-cascade" id="fristen-b1-cascade"></div>
|
||||
|
||||
<div className="fristen-row-stack" id="fristen-row-stack" aria-live="polite"></div>
|
||||
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
@@ -428,8 +434,17 @@ export function renderFristenrechner(): string {
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DE_TYPES.map((p) => proceedingBtn(p))}
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -531,6 +546,10 @@ export function renderFristenrechner(): string {
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
|
||||
@@ -117,6 +117,8 @@ export type I18nKey =
|
||||
| "admin.card.feature_flags.title"
|
||||
| "admin.card.partner_units.desc"
|
||||
| "admin.card.partner_units.title"
|
||||
| "admin.card.rules.desc"
|
||||
| "admin.card.rules.title"
|
||||
| "admin.card.team.desc"
|
||||
| "admin.card.team.title"
|
||||
| "admin.coming_soon"
|
||||
@@ -266,6 +268,174 @@ export type I18nKey =
|
||||
| "admin.partner_units.new.heading"
|
||||
| "admin.partner_units.subtitle"
|
||||
| "admin.partner_units.title"
|
||||
| "admin.rules.col.legal_citation"
|
||||
| "admin.rules.col.lifecycle"
|
||||
| "admin.rules.col.modified"
|
||||
| "admin.rules.col.name"
|
||||
| "admin.rules.col.priority"
|
||||
| "admin.rules.col.proceeding"
|
||||
| "admin.rules.col.submission_code"
|
||||
| "admin.rules.edit.action.archive"
|
||||
| "admin.rules.edit.action.archive.error"
|
||||
| "admin.rules.edit.action.archive.ok"
|
||||
| "admin.rules.edit.action.clone"
|
||||
| "admin.rules.edit.action.clone.error"
|
||||
| "admin.rules.edit.action.ok"
|
||||
| "admin.rules.edit.action.publish"
|
||||
| "admin.rules.edit.action.publish.error"
|
||||
| "admin.rules.edit.action.publish.ok"
|
||||
| "admin.rules.edit.action.restore"
|
||||
| "admin.rules.edit.action.restore.error"
|
||||
| "admin.rules.edit.action.restore.ok"
|
||||
| "admin.rules.edit.action.save_draft"
|
||||
| "admin.rules.edit.action.save_draft.error"
|
||||
| "admin.rules.edit.action.save_draft.ok"
|
||||
| "admin.rules.edit.audit.action.archive"
|
||||
| "admin.rules.edit.audit.action.create"
|
||||
| "admin.rules.edit.audit.action.delete"
|
||||
| "admin.rules.edit.audit.action.publish"
|
||||
| "admin.rules.edit.audit.action.restore"
|
||||
| "admin.rules.edit.audit.action.update"
|
||||
| "admin.rules.edit.audit.actor.system"
|
||||
| "admin.rules.edit.audit.empty"
|
||||
| "admin.rules.edit.audit.exported"
|
||||
| "admin.rules.edit.audit.heading"
|
||||
| "admin.rules.edit.audit.loading"
|
||||
| "admin.rules.edit.audit.loadmore"
|
||||
| "admin.rules.edit.breadcrumb"
|
||||
| "admin.rules.edit.error.bad_id"
|
||||
| "admin.rules.edit.error.load"
|
||||
| "admin.rules.edit.error.not_found"
|
||||
| "admin.rules.edit.field.alt_duration_unit"
|
||||
| "admin.rules.edit.field.alt_duration_value"
|
||||
| "admin.rules.edit.field.alt_rule_code"
|
||||
| "admin.rules.edit.field.anchor_alt"
|
||||
| "admin.rules.edit.field.combine_op"
|
||||
| "admin.rules.edit.field.concept"
|
||||
| "admin.rules.edit.field.condition.valid"
|
||||
| "admin.rules.edit.field.condition_hint"
|
||||
| "admin.rules.edit.field.deadline_notes"
|
||||
| "admin.rules.edit.field.deadline_notes_en"
|
||||
| "admin.rules.edit.field.description"
|
||||
| "admin.rules.edit.field.duration_unit"
|
||||
| "admin.rules.edit.field.duration_value"
|
||||
| "admin.rules.edit.field.event_type"
|
||||
| "admin.rules.edit.field.is_court_set"
|
||||
| "admin.rules.edit.field.is_spawn"
|
||||
| "admin.rules.edit.field.legal_source"
|
||||
| "admin.rules.edit.field.name"
|
||||
| "admin.rules.edit.field.name_en"
|
||||
| "admin.rules.edit.field.parent"
|
||||
| "admin.rules.edit.field.primary_party"
|
||||
| "admin.rules.edit.field.priority"
|
||||
| "admin.rules.edit.field.proceeding"
|
||||
| "admin.rules.edit.field.proceeding.none"
|
||||
| "admin.rules.edit.field.rule_code"
|
||||
| "admin.rules.edit.field.sequence_order"
|
||||
| "admin.rules.edit.field.spawn_label"
|
||||
| "admin.rules.edit.field.spawn_proceeding"
|
||||
| "admin.rules.edit.field.spawn_proceeding.none"
|
||||
| "admin.rules.edit.field.submission_code"
|
||||
| "admin.rules.edit.field.timing"
|
||||
| "admin.rules.edit.field.trigger"
|
||||
| "admin.rules.edit.field.trigger.none"
|
||||
| "admin.rules.edit.heading.loading"
|
||||
| "admin.rules.edit.modal.archive.body"
|
||||
| "admin.rules.edit.modal.archive.title"
|
||||
| "admin.rules.edit.modal.clone.body"
|
||||
| "admin.rules.edit.modal.clone.title"
|
||||
| "admin.rules.edit.modal.publish.body"
|
||||
| "admin.rules.edit.modal.publish.title"
|
||||
| "admin.rules.edit.modal.restore.body"
|
||||
| "admin.rules.edit.modal.restore.title"
|
||||
| "admin.rules.edit.modal.save_draft.body"
|
||||
| "admin.rules.edit.modal.save_draft.title"
|
||||
| "admin.rules.edit.preview.empty"
|
||||
| "admin.rules.edit.preview.error"
|
||||
| "admin.rules.edit.preview.flags"
|
||||
| "admin.rules.edit.preview.heading"
|
||||
| "admin.rules.edit.preview.hint"
|
||||
| "admin.rules.edit.preview.only_drafts"
|
||||
| "admin.rules.edit.preview.run"
|
||||
| "admin.rules.edit.preview.running"
|
||||
| "admin.rules.edit.preview.trigger_date"
|
||||
| "admin.rules.edit.preview.trigger_required"
|
||||
| "admin.rules.edit.section.condition"
|
||||
| "admin.rules.edit.section.display"
|
||||
| "admin.rules.edit.section.identity"
|
||||
| "admin.rules.edit.section.lifecycle"
|
||||
| "admin.rules.edit.section.party"
|
||||
| "admin.rules.edit.section.proceeding"
|
||||
| "admin.rules.edit.section.timing"
|
||||
| "admin.rules.edit.title"
|
||||
| "admin.rules.empty"
|
||||
| "admin.rules.error.load"
|
||||
| "admin.rules.export.breadcrumb"
|
||||
| "admin.rules.export.copied"
|
||||
| "admin.rules.export.copy"
|
||||
| "admin.rules.export.copy_failed"
|
||||
| "admin.rules.export.count"
|
||||
| "admin.rules.export.download"
|
||||
| "admin.rules.export.error"
|
||||
| "admin.rules.export.field.since"
|
||||
| "admin.rules.export.heading"
|
||||
| "admin.rules.export.latest"
|
||||
| "admin.rules.export.no_pending"
|
||||
| "admin.rules.export.ok"
|
||||
| "admin.rules.export.run"
|
||||
| "admin.rules.export.running"
|
||||
| "admin.rules.export.subtitle"
|
||||
| "admin.rules.export.title"
|
||||
| "admin.rules.filter.lifecycle"
|
||||
| "admin.rules.filter.lifecycle.any"
|
||||
| "admin.rules.filter.proceeding"
|
||||
| "admin.rules.filter.proceeding.any"
|
||||
| "admin.rules.filter.search"
|
||||
| "admin.rules.filter.search.placeholder"
|
||||
| "admin.rules.filter.trigger"
|
||||
| "admin.rules.filter.trigger.any"
|
||||
| "admin.rules.lifecycle.archived"
|
||||
| "admin.rules.lifecycle.draft"
|
||||
| "admin.rules.lifecycle.published"
|
||||
| "admin.rules.list.export"
|
||||
| "admin.rules.list.heading"
|
||||
| "admin.rules.list.new"
|
||||
| "admin.rules.list.subtitle"
|
||||
| "admin.rules.list.title"
|
||||
| "admin.rules.loading"
|
||||
| "admin.rules.modal.confirm"
|
||||
| "admin.rules.modal.error.create"
|
||||
| "admin.rules.modal.error.name_required"
|
||||
| "admin.rules.modal.error.resolve"
|
||||
| "admin.rules.modal.field.duration"
|
||||
| "admin.rules.modal.field.name"
|
||||
| "admin.rules.modal.field.name_en"
|
||||
| "admin.rules.modal.new.body"
|
||||
| "admin.rules.modal.new.title"
|
||||
| "admin.rules.modal.reason"
|
||||
| "admin.rules.modal.reason.hint"
|
||||
| "admin.rules.modal.reason.placeholder"
|
||||
| "admin.rules.modal.reason.too_short"
|
||||
| "admin.rules.modal.resolve.body"
|
||||
| "admin.rules.modal.resolve.title"
|
||||
| "admin.rules.orphans.empty"
|
||||
| "admin.rules.orphans.field.proceeding"
|
||||
| "admin.rules.orphans.field.project"
|
||||
| "admin.rules.orphans.field.reason"
|
||||
| "admin.rules.orphans.loading"
|
||||
| "admin.rules.orphans.no_candidates"
|
||||
| "admin.rules.orphans.reason.ambiguous"
|
||||
| "admin.rules.orphans.reason.manual_unbound"
|
||||
| "admin.rules.orphans.reason.no_match"
|
||||
| "admin.rules.orphans.reason.no_project"
|
||||
| "admin.rules.orphans.resolved"
|
||||
| "admin.rules.orphans.subtitle"
|
||||
| "admin.rules.priority.informational"
|
||||
| "admin.rules.priority.mandatory"
|
||||
| "admin.rules.priority.optional"
|
||||
| "admin.rules.priority.recommended"
|
||||
| "admin.rules.tab.orphans"
|
||||
| "admin.rules.tab.rules"
|
||||
| "admin.section.available"
|
||||
| "admin.section.planned"
|
||||
| "admin.subtitle"
|
||||
@@ -413,6 +583,7 @@ export type I18nKey =
|
||||
| "approvals.action.approve"
|
||||
| "approvals.action.reject"
|
||||
| "approvals.action.revoke"
|
||||
| "approvals.action.suggest_changes"
|
||||
| "approvals.agent.byline"
|
||||
| "approvals.agent.label"
|
||||
| "approvals.agent.suggestion_pending"
|
||||
@@ -422,6 +593,10 @@ export type I18nKey =
|
||||
| "approvals.decision_kind.peer"
|
||||
| "approvals.diff.after"
|
||||
| "approvals.diff.before"
|
||||
| "approvals.disabled.not_authorized"
|
||||
| "approvals.disabled.revoke_not_requester"
|
||||
| "approvals.disabled.self_approval"
|
||||
| "approvals.disabled.suggest_lifecycle"
|
||||
| "approvals.empty.mine"
|
||||
| "approvals.empty.pending_mine"
|
||||
| "approvals.entity.appointment"
|
||||
@@ -432,6 +607,8 @@ export type I18nKey =
|
||||
| "approvals.error.not_authorized"
|
||||
| "approvals.error.request_not_pending"
|
||||
| "approvals.error.self_approval"
|
||||
| "approvals.error.suggestion_lifecycle_invalid"
|
||||
| "approvals.error.suggestion_requires_change"
|
||||
| "approvals.heading"
|
||||
| "approvals.lifecycle.complete"
|
||||
| "approvals.lifecycle.create"
|
||||
@@ -458,11 +635,32 @@ export type I18nKey =
|
||||
| "approvals.required_role.pa"
|
||||
| "approvals.required_role.senior_pa"
|
||||
| "approvals.status.approved"
|
||||
| "approvals.status.changes_requested"
|
||||
| "approvals.status.pending"
|
||||
| "approvals.status.rejected"
|
||||
| "approvals.status.revoked"
|
||||
| "approvals.status.superseded"
|
||||
| "approvals.subtitle"
|
||||
| "approvals.suggest.cancel"
|
||||
| "approvals.suggest.context.approval_status"
|
||||
| "approvals.suggest.context.project"
|
||||
| "approvals.suggest.context.requested_at"
|
||||
| "approvals.suggest.context.requester"
|
||||
| "approvals.suggest.event_type_picker_unavailable"
|
||||
| "approvals.suggest.field.description"
|
||||
| "approvals.suggest.field.original_due_date"
|
||||
| "approvals.suggest.field.rule_code"
|
||||
| "approvals.suggest.field.warning_date"
|
||||
| "approvals.suggest.intro"
|
||||
| "approvals.suggest.modal_title"
|
||||
| "approvals.suggest.next_request_link"
|
||||
| "approvals.suggest.note_label"
|
||||
| "approvals.suggest.note_placeholder"
|
||||
| "approvals.suggest.section.context"
|
||||
| "approvals.suggest.section.editable"
|
||||
| "approvals.suggest.submit"
|
||||
| "approvals.suggest.submit_disabled_hint"
|
||||
| "approvals.suggest.unsupported_lifecycle"
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
@@ -480,8 +678,13 @@ export type I18nKey =
|
||||
| "bottomnav.add.title"
|
||||
| "bottomnav.badge.deadlines"
|
||||
| "bottomnav.menu"
|
||||
| "cal.day.back_to_month"
|
||||
| "cal.day.fri"
|
||||
| "cal.day.mon"
|
||||
| "cal.day.next"
|
||||
| "cal.day.no_entries"
|
||||
| "cal.day.open_day"
|
||||
| "cal.day.prev"
|
||||
| "cal.day.sat"
|
||||
| "cal.day.sun"
|
||||
| "cal.day.thu"
|
||||
@@ -499,6 +702,50 @@ export type I18nKey =
|
||||
| "cal.month.7"
|
||||
| "cal.month.8"
|
||||
| "cal.month.9"
|
||||
| "cal.month.next"
|
||||
| "cal.month.prev"
|
||||
| "cal.view.day"
|
||||
| "cal.view.month"
|
||||
| "cal.view.week"
|
||||
| "cal.week.next"
|
||||
| "cal.week.prev"
|
||||
| "caldav.bindings.add"
|
||||
| "caldav.bindings.card.edit"
|
||||
| "caldav.bindings.card.enabled"
|
||||
| "caldav.bindings.card.remove"
|
||||
| "caldav.bindings.delete.confirm"
|
||||
| "caldav.bindings.delete.failed"
|
||||
| "caldav.bindings.empty"
|
||||
| "caldav.bindings.error.create_name_required"
|
||||
| "caldav.bindings.error.create_name_taken"
|
||||
| "caldav.bindings.error.create_unsupported"
|
||||
| "caldav.bindings.error.path"
|
||||
| "caldav.bindings.error.scope"
|
||||
| "caldav.bindings.error.scope_project"
|
||||
| "caldav.bindings.heading"
|
||||
| "caldav.bindings.hint"
|
||||
| "caldav.bindings.modal.add_title"
|
||||
| "caldav.bindings.modal.display_name"
|
||||
| "caldav.bindings.modal.display_name.placeholder"
|
||||
| "caldav.bindings.modal.edit_title"
|
||||
| "caldav.bindings.modal.scope"
|
||||
| "caldav.bindings.modal.scope.all_visible"
|
||||
| "caldav.bindings.modal.scope.personal_only"
|
||||
| "caldav.bindings.modal.scope.project"
|
||||
| "caldav.bindings.modal.scope.project.loading"
|
||||
| "caldav.bindings.modal.source"
|
||||
| "caldav.bindings.modal.source.create"
|
||||
| "caldav.bindings.modal.source.custom"
|
||||
| "caldav.bindings.modal.source.degrade"
|
||||
| "caldav.bindings.modal.source.discover_empty"
|
||||
| "caldav.bindings.modal.source.discover_failed"
|
||||
| "caldav.bindings.modal.source.existing"
|
||||
| "caldav.bindings.modal.source.loading"
|
||||
| "caldav.bindings.modal.submit_add"
|
||||
| "caldav.bindings.modal.submit_edit"
|
||||
| "caldav.bindings.scope.all_visible"
|
||||
| "caldav.bindings.scope.personal_only"
|
||||
| "caldav.bindings.scope.project"
|
||||
| "caldav.delete"
|
||||
| "caldav.delete.confirm"
|
||||
| "caldav.delete.done"
|
||||
@@ -680,6 +927,11 @@ export type I18nKey =
|
||||
| "dashboard.deadlines.empty"
|
||||
| "dashboard.deadlines.heading"
|
||||
| "dashboard.greeting.prefix"
|
||||
| "dashboard.inbox.empty"
|
||||
| "dashboard.inbox.entity.appointment"
|
||||
| "dashboard.inbox.entity.deadline"
|
||||
| "dashboard.inbox.full_link"
|
||||
| "dashboard.inbox.heading"
|
||||
| "dashboard.matters.active"
|
||||
| "dashboard.matters.archived"
|
||||
| "dashboard.matters.heading"
|
||||
@@ -740,16 +992,19 @@ export type I18nKey =
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
| "deadlines.complete.action"
|
||||
| "deadlines.complete.confirm"
|
||||
| "deadlines.court.indirect"
|
||||
| "deadlines.court.label"
|
||||
| "deadlines.court.set"
|
||||
| "deadlines.date.edit.hint"
|
||||
| "deadlines.de"
|
||||
| "deadlines.de_inf"
|
||||
| "deadlines.de_inf_bgh"
|
||||
| "deadlines.de_inf_olg"
|
||||
| "deadlines.de_null"
|
||||
| "deadlines.de_null_bgh"
|
||||
| "deadlines.de.group.inf"
|
||||
| "deadlines.de.group.null"
|
||||
| "deadlines.de.inf.bgh"
|
||||
| "deadlines.de.inf.lg"
|
||||
| "deadlines.de.inf.olg"
|
||||
| "deadlines.de.null.bgh"
|
||||
| "deadlines.de.null.bpatg"
|
||||
| "deadlines.detail.back"
|
||||
| "deadlines.detail.cancel"
|
||||
| "deadlines.detail.complete"
|
||||
@@ -772,16 +1027,16 @@ export type I18nKey =
|
||||
| "deadlines.detail.source"
|
||||
| "deadlines.detail.title"
|
||||
| "deadlines.dpma"
|
||||
| "deadlines.dpma_bgh_rb"
|
||||
| "deadlines.dpma_bpatg_beschwerde"
|
||||
| "deadlines.dpma_opp"
|
||||
| "deadlines.dpma.appeal.bgh"
|
||||
| "deadlines.dpma.appeal.bpatg"
|
||||
| "deadlines.dpma.opp.dpma"
|
||||
| "deadlines.empty.filtered"
|
||||
| "deadlines.empty.hint"
|
||||
| "deadlines.empty.title"
|
||||
| "deadlines.ep_grant"
|
||||
| "deadlines.epa"
|
||||
| "deadlines.epa_app"
|
||||
| "deadlines.epa_opp"
|
||||
| "deadlines.epa.grant.exa"
|
||||
| "deadlines.epa.opp.boa"
|
||||
| "deadlines.epa.opp.opd"
|
||||
| "deadlines.error.generic"
|
||||
| "deadlines.error.required"
|
||||
| "deadlines.event.adjusted"
|
||||
@@ -881,6 +1136,7 @@ export type I18nKey =
|
||||
| "deadlines.neu.submit"
|
||||
| "deadlines.neu.subtitle"
|
||||
| "deadlines.neu.title"
|
||||
| "deadlines.notes.show"
|
||||
| "deadlines.optional.badge"
|
||||
| "deadlines.party.both"
|
||||
| "deadlines.party.both.label"
|
||||
@@ -913,9 +1169,27 @@ export type I18nKey =
|
||||
| "deadlines.perspective.predefined_hint"
|
||||
| "deadlines.print"
|
||||
| "deadlines.priority.date"
|
||||
| "deadlines.priority.informational"
|
||||
| "deadlines.priority.informational.notice_label"
|
||||
| "deadlines.priority.mandatory"
|
||||
| "deadlines.priority.optional"
|
||||
| "deadlines.priority.recommended"
|
||||
| "deadlines.proceeding.reselect"
|
||||
| "deadlines.proceeding.selected"
|
||||
| "deadlines.reset"
|
||||
| "deadlines.row.autowalk.dismiss"
|
||||
| "deadlines.row.autowalk.tooltip"
|
||||
| "deadlines.row.edit"
|
||||
| "deadlines.row.mode.question"
|
||||
| "deadlines.row.prefilled.from_akte"
|
||||
| "deadlines.row.reset"
|
||||
| "deadlines.row.reset.title"
|
||||
| "deadlines.row.search.link"
|
||||
| "deadlines.row.search.link.title"
|
||||
| "deadlines.row.search.panel.back"
|
||||
| "deadlines.row.search.panel.back.title"
|
||||
| "deadlines.row.search.panel.clear"
|
||||
| "deadlines.row.search.panel.placeholder"
|
||||
| "deadlines.save.cta"
|
||||
| "deadlines.save.cta.adhoc.hint"
|
||||
| "deadlines.save.error"
|
||||
@@ -1000,14 +1274,15 @@ export type I18nKey =
|
||||
| "deadlines.trigger.label"
|
||||
| "deadlines.unavailable"
|
||||
| "deadlines.upc"
|
||||
| "deadlines.upc_app"
|
||||
| "deadlines.upc_app_orders"
|
||||
| "deadlines.upc_cost_appeal"
|
||||
| "deadlines.upc_damages"
|
||||
| "deadlines.upc_discovery"
|
||||
| "deadlines.upc_inf"
|
||||
| "deadlines.upc_pi"
|
||||
| "deadlines.upc_rev"
|
||||
| "deadlines.upc.apl.cost"
|
||||
| "deadlines.upc.apl.merits"
|
||||
| "deadlines.upc.apl.order"
|
||||
| "deadlines.upc.ccr.cfi"
|
||||
| "deadlines.upc.disc.cfi"
|
||||
| "deadlines.upc.dmgs.cfi"
|
||||
| "deadlines.upc.inf.cfi"
|
||||
| "deadlines.upc.pi.cfi"
|
||||
| "deadlines.upc.rev.cfi"
|
||||
| "deadlines.urgency.later"
|
||||
| "deadlines.urgency.overdue"
|
||||
| "deadlines.urgency.soon"
|
||||
@@ -1022,6 +1297,16 @@ export type I18nKey =
|
||||
| "downloads.subtitle"
|
||||
| "downloads.title"
|
||||
| "einstellungen.error.generic"
|
||||
| "einstellungen.export.audit"
|
||||
| "einstellungen.export.bullet.csv"
|
||||
| "einstellungen.export.bullet.json"
|
||||
| "einstellungen.export.bullet.xlsx"
|
||||
| "einstellungen.export.button"
|
||||
| "einstellungen.export.heading"
|
||||
| "einstellungen.export.scope"
|
||||
| "einstellungen.export.started"
|
||||
| "einstellungen.export.subtitle"
|
||||
| "einstellungen.export.what"
|
||||
| "einstellungen.heading"
|
||||
| "einstellungen.loading"
|
||||
| "einstellungen.optional"
|
||||
@@ -1065,9 +1350,11 @@ export type I18nKey =
|
||||
| "einstellungen.subtitle"
|
||||
| "einstellungen.tab.benachrichtigungen"
|
||||
| "einstellungen.tab.caldav"
|
||||
| "einstellungen.tab.export"
|
||||
| "einstellungen.tab.profil"
|
||||
| "einstellungen.title"
|
||||
| "event.description.appointment_approval_approved"
|
||||
| "event.description.appointment_approval_changes_suggested"
|
||||
| "event.description.appointment_approval_rejected"
|
||||
| "event.description.appointment_approval_requested"
|
||||
| "event.description.appointment_approval_revoked"
|
||||
@@ -1076,6 +1363,7 @@ export type I18nKey =
|
||||
| "event.description.appointment_project_changed"
|
||||
| "event.description.appointment_updated"
|
||||
| "event.description.deadline_approval_approved"
|
||||
| "event.description.deadline_approval_changes_suggested"
|
||||
| "event.description.deadline_approval_rejected"
|
||||
| "event.description.deadline_approval_requested"
|
||||
| "event.description.deadline_approval_revoked"
|
||||
@@ -1091,6 +1379,7 @@ export type I18nKey =
|
||||
| "event.note.parent.deadline"
|
||||
| "event.note.parent.project"
|
||||
| "event.title.appointment_approval_approved"
|
||||
| "event.title.appointment_approval_changes_suggested"
|
||||
| "event.title.appointment_approval_rejected"
|
||||
| "event.title.appointment_approval_requested"
|
||||
| "event.title.appointment_approval_revoked"
|
||||
@@ -1105,6 +1394,7 @@ export type I18nKey =
|
||||
| "event.title.checklist_reset"
|
||||
| "event.title.checklist_unlinked"
|
||||
| "event.title.deadline_approval_approved"
|
||||
| "event.title.deadline_approval_changes_suggested"
|
||||
| "event.title.deadline_approval_rejected"
|
||||
| "event.title.deadline_approval_requested"
|
||||
| "event.title.deadline_approval_revoked"
|
||||
@@ -1169,6 +1459,7 @@ export type I18nKey =
|
||||
| "events.empty.hint"
|
||||
| "events.empty.title"
|
||||
| "events.filter.status.all"
|
||||
| "events.filter.status.upcoming"
|
||||
| "events.row.type.appointment"
|
||||
| "events.row.type.deadline"
|
||||
| "events.summary.later"
|
||||
@@ -1432,11 +1723,14 @@ export type I18nKey =
|
||||
| "login.tab.login"
|
||||
| "login.tab.register"
|
||||
| "login.title"
|
||||
| "modal.close.label"
|
||||
| "nav.admin.audit"
|
||||
| "nav.admin.bereich"
|
||||
| "nav.admin.event_types"
|
||||
| "nav.admin.paliadin"
|
||||
| "nav.admin.partner_units"
|
||||
| "nav.admin.rules"
|
||||
| "nav.admin.rules_export"
|
||||
| "nav.admin.team"
|
||||
| "nav.agenda"
|
||||
| "nav.akten"
|
||||
@@ -1574,6 +1868,10 @@ export type I18nKey =
|
||||
| "partner_unit.members_label"
|
||||
| "partner_unit.none"
|
||||
| "partner_unit.subtitle"
|
||||
| "project.instance_level.appeal"
|
||||
| "project.instance_level.cassation"
|
||||
| "project.instance_level.first"
|
||||
| "project.instance_level.unset"
|
||||
| "projects.cancel"
|
||||
| "projects.cards.deadline_open"
|
||||
| "projects.cards.deadline_overdue"
|
||||
@@ -1710,6 +2008,8 @@ export type I18nKey =
|
||||
| "projects.detail.edit"
|
||||
| "projects.detail.edit.modal.title"
|
||||
| "projects.detail.edit.type_change_warning.title"
|
||||
| "projects.detail.export.button"
|
||||
| "projects.detail.export.tooltip"
|
||||
| "projects.detail.firmwide.off"
|
||||
| "projects.detail.firmwide.on"
|
||||
| "projects.detail.kinder.add"
|
||||
@@ -1805,11 +2105,21 @@ export type I18nKey =
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.submissions.action.generate"
|
||||
| "projects.detail.submissions.action.no_template"
|
||||
| "projects.detail.submissions.col.action"
|
||||
| "projects.detail.submissions.col.name"
|
||||
| "projects.detail.submissions.col.party"
|
||||
| "projects.detail.submissions.col.source"
|
||||
| "projects.detail.submissions.empty"
|
||||
| "projects.detail.submissions.empty.no_proceeding"
|
||||
| "projects.detail.submissions.hint"
|
||||
| "projects.detail.tab.checklisten"
|
||||
| "projects.detail.tab.fristen"
|
||||
| "projects.detail.tab.kinder"
|
||||
| "projects.detail.tab.notizen"
|
||||
| "projects.detail.tab.parteien"
|
||||
| "projects.detail.tab.submissions"
|
||||
| "projects.detail.tab.team"
|
||||
| "projects.detail.tab.termine"
|
||||
| "projects.detail.tab.verlauf"
|
||||
@@ -2059,6 +2369,8 @@ export type I18nKey =
|
||||
| "unit_role.pa"
|
||||
| "unit_role.paralegal"
|
||||
| "unit_role.senior_pa"
|
||||
| "verlauf.spawn.chip"
|
||||
| "verlauf.spawn.cycle_warning"
|
||||
| "views.action.edit"
|
||||
| "views.bar.action.reset"
|
||||
| "views.bar.action.save_as_view"
|
||||
@@ -2072,6 +2384,7 @@ export type I18nKey =
|
||||
| "views.bar.approval_role.approver_eligible"
|
||||
| "views.bar.approval_role.self_requested"
|
||||
| "views.bar.approval_status.approved"
|
||||
| "views.bar.approval_status.changes_requested"
|
||||
| "views.bar.approval_status.pending"
|
||||
| "views.bar.approval_status.rejected"
|
||||
| "views.bar.approval_status.revoked"
|
||||
@@ -2220,6 +2533,12 @@ export type I18nKey =
|
||||
| "views.source.project_event"
|
||||
| "views.subtitle"
|
||||
| "views.timeline.caveat.body"
|
||||
| "views.timeline.zoom.1y"
|
||||
| "views.timeline.zoom.2y"
|
||||
| "views.timeline.zoom.all"
|
||||
| "views.timeline.zoom.in"
|
||||
| "views.timeline.zoom.label"
|
||||
| "views.timeline.zoom.out"
|
||||
| "views.title"
|
||||
| "views.toast.inaccessible_n"
|
||||
| "views.toast.inaccessible_one";
|
||||
|
||||
@@ -9,7 +9,6 @@ const ICON_FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
|
||||
const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
||||
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const ICON_GLOSSAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
@@ -108,19 +107,6 @@ export function renderIndex(): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="sections">
|
||||
<div className="container">
|
||||
<h3 className="section-heading" data-i18n="index.downloads">Downloads</h3>
|
||||
<div className="grid grid-2">
|
||||
<a href="/files/hl-patents-style.dotm" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_DOWNLOAD }} />
|
||||
<h2 data-i18n="index.style.title">{`${FIRM} Patents Style`}</h2>
|
||||
<p data-i18n="index.style.desc">{`Word-Vorlage im ${FIRM} Patents Style. Formatierung, Schriftarten und Makros für standardisierte Schriftsätze.`}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="offices">
|
||||
<div className="container">
|
||||
<h3 data-i18n="index.offices">Standorte</h3>
|
||||
|
||||
@@ -80,6 +80,21 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="appointments" href="#" data-i18n="projects.detail.tab.termine">Termine</a>
|
||||
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
|
||||
{/* t-paliad-214 Slice 2 — project-subtree export button.
|
||||
Sits at the end of the tab nav. Hidden by default; the
|
||||
client unhides it after /api/me confirms the caller can
|
||||
extract (responsibility ∈ {lead, member} OR global_admin). */}
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="entity-tab entity-tab-action"
|
||||
style="display:none"
|
||||
title=""
|
||||
data-i18n-title="projects.detail.export.tooltip"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
@@ -188,7 +203,7 @@ export function renderProjectsDetail(): string {
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-counterclaim-procedure" data-i18n="projects.detail.smarttimeline.counterclaim.procedure">Verfahrenstyp</label>
|
||||
<select id="smart-timeline-counterclaim-procedure">
|
||||
{/* Options injected from client; defaults to UPC_REV */}
|
||||
{/* Options injected from client; defaults to upc.rev.cfi */}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
@@ -571,6 +586,38 @@ export function renderProjectsDetail(): string {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Submissions (Schriftsätze) — t-paliad-215 Slice 1.
|
||||
Lists the project's filing-type rules with a per-row
|
||||
[Generieren] button when a .docx template resolves
|
||||
in the registry's fallback chain (firm → base/code →
|
||||
base/family → skeleton). Empty for projects with no
|
||||
proceeding bound; otherwise enumerates every active
|
||||
filing rule for the proceeding. */}
|
||||
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
|
||||
<p id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty.no_proceeding">
|
||||
Bitte zuerst einen Verfahrenstyp setzen.
|
||||
</p>
|
||||
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
|
||||
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
|
||||
</p>
|
||||
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="projects.detail.submissions.col.name">Schriftsatz</th>
|
||||
<th data-i18n="projects.detail.submissions.col.party">Partei</th>
|
||||
<th data-i18n="projects.detail.submissions.col.source">Rechtsgrundlage</th>
|
||||
<th data-i18n="projects.detail.submissions.col.action" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="project-submissions-body" />
|
||||
</table>
|
||||
</div>
|
||||
<p className="tool-subtitle submissions-hint" data-i18n="projects.detail.submissions.hint">
|
||||
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="entity-detail-footer" id="project-delete-wrap" style="display:none">
|
||||
<button id="project-delete-btn" className="btn-secondary" type="button" data-i18n="projects.detail.delete">
|
||||
Projekt archivieren
|
||||
|
||||
@@ -40,6 +40,7 @@ export function renderSettings(): string {
|
||||
<a className="entity-tab" data-tab="profil" href="?tab=profil" data-i18n="einstellungen.tab.profil">Profil</a>
|
||||
<a className="entity-tab" data-tab="benachrichtigungen" href="?tab=benachrichtigungen" data-i18n="einstellungen.tab.benachrichtigungen">Benachrichtigungen</a>
|
||||
<a className="entity-tab" data-tab="caldav" href="?tab=caldav" data-i18n="einstellungen.tab.caldav">CalDAV</a>
|
||||
<a className="entity-tab" data-tab="export" href="?tab=export" data-i18n="einstellungen.tab.export">Datenexport</a>
|
||||
</nav>
|
||||
|
||||
{/* --- Profil tab ---------------------------------------- */}
|
||||
@@ -322,6 +323,25 @@ export function renderSettings(): string {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* t-paliad-212 Slice 2b — multi-calendar bindings.
|
||||
Each card is one (calendar, scope) binding layered on the
|
||||
single CalDAV server connection above. */}
|
||||
<div className="caldav-bindings-section" id="caldav-bindings-section" style="display:none">
|
||||
<div className="caldav-bindings-header">
|
||||
<h2 data-i18n="caldav.bindings.heading">Kalender</h2>
|
||||
<button type="button" id="caldav-bindings-add-btn" className="btn-secondary" data-i18n="caldav.bindings.add">
|
||||
+ Kalender hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-hint" data-i18n="caldav.bindings.hint">
|
||||
Verbinde mehrere Kalender mit Paliad — einen Master für alles oder eigene Kalender pro Projekt.
|
||||
</p>
|
||||
<div id="caldav-bindings-list" className="caldav-bindings-list" />
|
||||
<p className="entity-events-empty" id="caldav-bindings-empty" data-i18n="caldav.bindings.empty" style="display:none">
|
||||
Noch keine Kalender konfiguriert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="caldav-log-card">
|
||||
<h2 data-i18n="caldav.log.heading">Letzte Synchronisationen</h2>
|
||||
<table className="entity-table entity-table--readonly caldav-log-table">
|
||||
@@ -342,12 +362,138 @@ export function renderSettings(): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Datenexport tab (t-paliad-214 Slice 1) ----------- */}
|
||||
<section className="entity-tab-panel" id="tab-export" style="display:none">
|
||||
<p className="tool-subtitle" data-i18n="einstellungen.export.subtitle">
|
||||
Laden Sie Ihre persönlichen Paliad-Daten als Excel- + JSON- + CSV-Paket herunter.
|
||||
Enthalten ist alles, was Sie aktuell sehen können — Ihre Projekte, Fristen, Termine, Notizen, Genehmigungen und Einstellungen.
|
||||
</p>
|
||||
|
||||
<div className="caldav-info-card">
|
||||
<h2 data-i18n="einstellungen.export.heading">Persönlicher Datenexport</h2>
|
||||
<p data-i18n="einstellungen.export.what">
|
||||
Das Paket enthält Ihre sichtbaren Daten in drei Formaten in einem <code>.zip</code>:
|
||||
</p>
|
||||
<ul className="form-hint settings-export-list">
|
||||
<li data-i18n="einstellungen.export.bullet.xlsx">
|
||||
<strong>paliad-export.xlsx</strong> — eine Excel-Mappe pro Entität.
|
||||
</li>
|
||||
<li data-i18n="einstellungen.export.bullet.json">
|
||||
<strong>paliad-export.json</strong> — maschinenlesbare Kopie für Skripte und Tools.
|
||||
</li>
|
||||
<li data-i18n="einstellungen.export.bullet.csv">
|
||||
<strong>csv/<sheet>.csv</strong> — Tabellen einzeln als CSV (UTF-8 mit BOM).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="form-hint" data-i18n="einstellungen.export.scope">
|
||||
Umfang: alles, was Sie aktuell in Paliad sehen können (Sichtbarkeit zum Zeitpunkt des Exports).
|
||||
Passwörter, CalDAV-Zugangsdaten und andere Geheimnisse werden nie exportiert.
|
||||
</p>
|
||||
|
||||
<p className="form-hint" data-i18n="einstellungen.export.audit">
|
||||
Jeder Export wird im Audit-Log protokolliert.
|
||||
</p>
|
||||
|
||||
<p className="form-msg" id="export-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" id="export-btn" className="btn-primary btn-cta-lime" data-i18n="einstellungen.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
{/* t-paliad-212 Slice 2b — single-step Add/Edit modal for
|
||||
calendar bindings. Source picker (existing dropdown or
|
||||
custom URL) + scope radio + display name. Edit mode hides
|
||||
the source picker (path is fixed). */}
|
||||
<div id="caldav-binding-modal" className="modal-backdrop" style="display:none">
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-header">
|
||||
<h2 id="caldav-binding-modal-title" data-i18n="caldav.bindings.modal.add_title">Kalender hinzufügen</h2>
|
||||
<button type="button" className="modal-close" id="caldav-binding-modal-close" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<form id="caldav-binding-form" className="entity-form modal-body" autocomplete="off">
|
||||
<div className="form-field" id="caldav-binding-source-field">
|
||||
<label data-i18n="caldav.bindings.modal.source">Kalender</label>
|
||||
<div className="caldav-binding-source-modes" id="caldav-binding-source-modes">
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="existing" checked />
|
||||
<span data-i18n="caldav.bindings.modal.source.existing">Vorhandenen Kalender wählen</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label" id="caldav-binding-source-mode-create-row" style="display:none">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="create" />
|
||||
<span data-i18n="caldav.bindings.modal.source.create">Neuen Kalender erstellen</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="custom" />
|
||||
<span data-i18n="caldav.bindings.modal.source.custom">Eigene URL eingeben</span>
|
||||
</label>
|
||||
</div>
|
||||
<select id="caldav-binding-discover-select">
|
||||
<option value="" data-i18n="caldav.bindings.modal.source.loading">Lädt…</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="caldav-binding-custom-path"
|
||||
placeholder="https://..."
|
||||
style="display:none"
|
||||
/>
|
||||
{/* Slice 2c — Google-degrade notice. Shown when
|
||||
supports_mkcalendar=false; the create-new radio is
|
||||
hidden in that state, so users are nudged to the
|
||||
custom-URL path. */}
|
||||
<p className="form-hint caldav-binding-degrade-notice" id="caldav-binding-degrade-notice" style="display:none" data-i18n="caldav.bindings.modal.source.degrade">
|
||||
Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV.
|
||||
Erstelle den Kalender direkt in der Anbieter-Oberfläche und füge ihn hier per URL hinzu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="caldav-binding-display-name" data-i18n="caldav.bindings.modal.display_name">Anzeigename (optional)</label>
|
||||
<input type="text" id="caldav-binding-display-name" data-i18n-placeholder="caldav.bindings.modal.display_name.placeholder" placeholder="z.B. Projekt Acme v Bosch" />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label data-i18n="caldav.bindings.modal.scope">Inhalt</label>
|
||||
<div className="caldav-binding-scope-radios">
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="all_visible" checked />
|
||||
<span data-i18n="caldav.bindings.modal.scope.all_visible">Alles, was ich sehe</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="personal_only" />
|
||||
<span data-i18n="caldav.bindings.modal.scope.personal_only">Nur persönliche Termine</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="project" />
|
||||
<span data-i18n="caldav.bindings.modal.scope.project">Ein Projekt:</span>
|
||||
<select id="caldav-binding-project-select" disabled>
|
||||
<option value="" data-i18n="caldav.bindings.modal.scope.project.loading">Lädt…</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="form-msg" id="caldav-binding-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" id="caldav-binding-cancel-btn" data-i18n="common.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" id="caldav-binding-submit-btn" data-i18n="caldav.bindings.modal.submit_add">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,34 +29,44 @@ function proceedingBtn(p: ProceedingDef): string {
|
||||
}
|
||||
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "UPC_INF", i18nKey: "deadlines.upc_inf", name: "Verletzungsverfahren" },
|
||||
{ code: "UPC_REV", i18nKey: "deadlines.upc_rev", name: "Nichtigkeitsklage" },
|
||||
{ code: "UPC_PI", i18nKey: "deadlines.upc_pi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "UPC_APP", i18nKey: "deadlines.upc_app", name: "Berufung" },
|
||||
{ code: "UPC_DAMAGES", i18nKey: "deadlines.upc_damages", name: "Schadensbemessung" },
|
||||
{ code: "UPC_DISCOVERY", i18nKey: "deadlines.upc_discovery", name: "Bucheinsicht" },
|
||||
{ code: "UPC_COST_APPEAL", i18nKey: "deadlines.upc_cost_appeal", name: "Berufung Kosten" },
|
||||
{ code: "UPC_APP_ORDERS", i18nKey: "deadlines.upc_app_orders", name: "Berufung Anordnungen" },
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
|
||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
const DE_TYPES: ProceedingDef[] = [
|
||||
{ code: "DE_INF", i18nKey: "deadlines.de_inf", name: "Verletzungsklage (LG)" },
|
||||
{ code: "DE_INF_OLG", i18nKey: "deadlines.de_inf_olg", name: "Berufung OLG" },
|
||||
{ code: "DE_INF_BGH", i18nKey: "deadlines.de_inf_bgh", name: "Revision/NZB BGH" },
|
||||
{ code: "DE_NULL", i18nKey: "deadlines.de_null", name: "Nichtigkeitsverfahren" },
|
||||
{ code: "DE_NULL_BGH", i18nKey: "deadlines.de_null_bgh", name: "Berufung BGH (Nichtigk.)" },
|
||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
|
||||
// so a user scanning the picker sees the instance-and-role at a glance
|
||||
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
|
||||
// verfahren". Sub-group headers convey the type grouping. Combined-
|
||||
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
|
||||
const DE_INF_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
|
||||
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
|
||||
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
|
||||
];
|
||||
|
||||
const DE_NULL_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
|
||||
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "EPA_OPP", i18nKey: "deadlines.epa_opp", name: "Einspruchsverfahren" },
|
||||
{ code: "EPA_APP", i18nKey: "deadlines.epa_app", name: "Beschwerdeverfahren" },
|
||||
{ code: "EP_GRANT", i18nKey: "deadlines.ep_grant", name: "EP-Erteilungsverfahren" },
|
||||
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
|
||||
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
|
||||
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "DPMA_OPP", i18nKey: "deadlines.dpma_opp", name: "Einspruch DPMA" },
|
||||
{ code: "DPMA_BPATG_BESCHWERDE", i18nKey: "deadlines.dpma_bpatg_beschwerde", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "DPMA_BGH_RB", i18nKey: "deadlines.dpma_bgh_rb", name: "Rechtsbeschwerde BGH" },
|
||||
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
|
||||
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
export function renderVerfahrensablauf(): string {
|
||||
@@ -107,8 +117,17 @@ export function renderVerfahrensablauf(): string {
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DE_TYPES.map((p) => proceedingBtn(p))}
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,6 +174,35 @@ export function renderVerfahrensablauf(): string {
|
||||
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
|
||||
<select id="court-picker" className="date-input"></select>
|
||||
</div>
|
||||
{/* Proceeding-specific flag rows — mirror /tools/fristenrechner
|
||||
so an abstract-browse user can model the same variants
|
||||
(CCR, Patentänderung, Verletzungswiderklage,
|
||||
Vorab-Einrede). Show/hide driven by selectedType in
|
||||
the client. */}
|
||||
<div className="date-field-row" id="ccr-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="ccr-flag" />
|
||||
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="inf-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patentänderung (R.30)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patentänderung (R.49.2.a)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-cci-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
|
||||
Fristen berechnen
|
||||
</button>
|
||||
@@ -177,6 +225,10 @@ export function renderVerfahrensablauf(): string {
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
|
||||
@@ -60,6 +60,13 @@ export function renderViews(): string {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Filter bar host — t-paliad-211. mountFilterBar appends its
|
||||
own toolbar element here; the saved view's filter_spec
|
||||
becomes the bar's baseline, axes are chosen client-side
|
||||
per the view's data sources. */}
|
||||
<div className="views-filter-bar" id="views-filter-bar" hidden />
|
||||
|
||||
|
||||
{/* Empty / onboarding state — shown on bare /views with no saved views. */}
|
||||
<div className="views-onboarding" id="views-onboarding" hidden>
|
||||
<h2 data-i18n="views.onboarding.title">Eigene Ansichten — was ist das?</h2>
|
||||
|
||||
15
go.mod
15
go.mod
@@ -4,8 +4,21 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
)
|
||||
|
||||
76
go.sum
76
go.sum
@@ -1,39 +1,11 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
@@ -43,33 +15,29 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,46 +1,78 @@
|
||||
// Package db owns the Paliad Postgres connection and embedded schema migrations.
|
||||
//
|
||||
// Migrations are golang-migrate format (NNN_description.up.sql / .down.sql) and
|
||||
// live in the migrations/ subdirectory, embedded into the binary so a single
|
||||
// artifact ships with its schema. The server applies pending migrations at
|
||||
// startup before binding the HTTP listener.
|
||||
// Migrations are NNN_description.up.sql / .down.sql files in the migrations/
|
||||
// subdirectory, embedded into the binary so a single artifact ships with its
|
||||
// schema. The server applies pending migrations at startup before binding
|
||||
// the HTTP listener.
|
||||
//
|
||||
// The runner tracks applied state as a set, not a counter: every applied
|
||||
// migration gets its own row in paliad.applied_migrations(version PK, name,
|
||||
// applied_at, checksum). On every deploy, pending = on_disk \ applied, in
|
||||
// ascending version order. Gaps in the version space are first-class — a
|
||||
// version that's missing from applied_migrations runs on the next deploy,
|
||||
// regardless of which higher versions are already applied.
|
||||
//
|
||||
// This is what closes the parallel-merge skip-hole that the single-counter
|
||||
// tracker (golang-migrate) silently fell into on 2026-05-20 (m/paliad#44).
|
||||
// Background and design: docs/design-migration-runner-applied-set-2026-05-20.md.
|
||||
//
|
||||
// .down.sql files ship in the embedded FS as reference material but are not
|
||||
// auto-applied — there are no call sites for rolling back, and operator
|
||||
// recovery (psql .down.sql + DELETE FROM paliad.applied_migrations WHERE
|
||||
// version=N) is the documented path. If a real call site for auto-rollback
|
||||
// materializes later, add it as a focused follow-up.
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// migrationsTable is the name of the golang-migrate tracking table. We use a
|
||||
// uniquely-named table (not the default "schema_migrations") because the
|
||||
// production Supabase instance hosts multiple apps in the `public` schema,
|
||||
// and a differently-shaped `public.schema_migrations` already exists there.
|
||||
// Using "paliad_schema_migrations" prevents collision at startup.
|
||||
// advisoryLockID is the Postgres advisory-lock id the runner takes around
|
||||
// the apply loop. Derived once from the table name so the value is stable
|
||||
// across processes — two concurrent deploys (rolling Dokploy update, dev
|
||||
// laptop hitting the same scratch DB as CI) serialize on this id rather
|
||||
// than racing on the pending set.
|
||||
//
|
||||
// The table lives in the `public` schema (golang-migrate's default) rather
|
||||
// than `paliad`. Rationale: migration 001's down-step is
|
||||
// DROP SCHEMA IF EXISTS paliad CASCADE
|
||||
// which would take the tracking table with it — breaking any subsequent
|
||||
// migrate.Up() call. Keeping the tracker in `public` makes the down-path
|
||||
// safe and idempotent.
|
||||
const migrationsTable = "paliad_schema_migrations"
|
||||
// FNV-1a-64 is good enough: the id only has to be a stable int64, not
|
||||
// cryptographically uniform. Process-wide constant.
|
||||
var advisoryLockID = func() int64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte("paliad.applied_migrations"))
|
||||
return int64(h.Sum64())
|
||||
}()
|
||||
|
||||
// ApplyMigrations runs all pending up-migrations against the given database
|
||||
// URL. Returns nil if no migrations were pending. Safe to call repeatedly.
|
||||
// migration is one *.up.sql file from the embedded FS.
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
filename string
|
||||
}
|
||||
|
||||
// ApplyMigrations applies every pending up-migration to the given database.
|
||||
//
|
||||
// Pre-creates the `paliad` schema before invoking golang-migrate because the
|
||||
// first migration creates it and golang-migrate's tracking table would
|
||||
// otherwise be created in whatever `current_schema()` happens to be.
|
||||
// Safe to call repeatedly; a fully-applied tree is a no-op. Returns the
|
||||
// first error encountered (with the offending migration filename wrapped
|
||||
// in the message) and leaves the rest of pending unapplied — same fail-fast
|
||||
// posture as the previous golang-migrate runner.
|
||||
//
|
||||
// On first deploy of this code path against a database that still has the
|
||||
// legacy paliad.paliad_schema_migrations counter at version N, the runner
|
||||
// seeds paliad.applied_migrations with rows 1..N (checksum NULL) before
|
||||
// applying anything new. The first deploy is therefore effectively a
|
||||
// no-op against the schema — the bootstrap just relabels existing state.
|
||||
func ApplyMigrations(databaseURL string) error {
|
||||
if databaseURL == "" {
|
||||
return errors.New("database URL is empty")
|
||||
@@ -51,39 +83,250 @@ func ApplyMigrations(databaseURL string) error {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.Ping(); err != nil {
|
||||
return fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
// Bootstrap the paliad schema so later migrations can target it cleanly.
|
||||
// This duplicates migration 001, but is idempotent via IF NOT EXISTS and
|
||||
// ensures the schema exists before golang-migrate touches the DB.
|
||||
// Ensure the paliad schema exists. Mig 001 also creates it; the
|
||||
// applied_migrations table lives in paliad.* and gets created before
|
||||
// any migrations run, so the schema must exist first.
|
||||
if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil {
|
||||
return fmt.Errorf("ensure paliad schema: %w", err)
|
||||
}
|
||||
|
||||
source, err := iofs.New(migrationFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open migration source: %w", err)
|
||||
if _, err := conn.Exec(`SELECT pg_advisory_lock($1)`, advisoryLockID); err != nil {
|
||||
return fmt.Errorf("acquire advisory lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(`SELECT pg_advisory_unlock($1)`, advisoryLockID)
|
||||
}()
|
||||
|
||||
if _, err := conn.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS paliad.applied_migrations (
|
||||
version int NOT NULL PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||
checksum text NULL
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create applied_migrations: %w", err)
|
||||
}
|
||||
|
||||
driver, err := postgres.WithInstance(conn, &postgres.Config{
|
||||
// Unique tracking-table name avoids collision with pre-existing
|
||||
// public.schema_migrations owned by other apps on this Postgres.
|
||||
MigrationsTable: migrationsTable,
|
||||
})
|
||||
onDisk, err := scanEmbeddedMigrations()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create migration driver: %w", err)
|
||||
return fmt.Errorf("scan embedded migrations: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create migrator: %w", err)
|
||||
if err := bootstrapFromLegacyTracker(conn, onDisk); err != nil {
|
||||
return fmt.Errorf("bootstrap from legacy tracker: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("apply migrations: %w", err)
|
||||
applied, err := readAppliedMigrations(conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read applied_migrations: %w", err)
|
||||
}
|
||||
|
||||
if err := checkNameAgreement(onDisk, applied); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, m := range onDisk {
|
||||
if _, ok := applied[m.version]; ok {
|
||||
continue
|
||||
}
|
||||
if err := applyOne(conn, m); err != nil {
|
||||
return fmt.Errorf("apply %s: %w", m.filename, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanEmbeddedMigrations returns every NNN_*.up.sql in the embedded FS,
|
||||
// sorted by version ascending. Hard-fails on two files sharing the same
|
||||
// version prefix — that's the failure mode the parallel-merge incident
|
||||
// exposed, and the runner refuses to start rather than silently picking one.
|
||||
func scanEmbeddedMigrations() ([]migration, error) {
|
||||
entries, err := migrationFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
seen := map[int]string{}
|
||||
var out []migration
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".up.sql") {
|
||||
continue
|
||||
}
|
||||
v, n, ok := parseMigrationFilename(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unparseable migration filename %q "+
|
||||
"(expected NNN_description.up.sql)", name)
|
||||
}
|
||||
if prior, dup := seen[v]; dup {
|
||||
return nil, fmt.Errorf("two migrations at version %d: %q and %q — "+
|
||||
"rename one and redeploy", v, prior, name)
|
||||
}
|
||||
seen[v] = name
|
||||
out = append(out, migration{version: v, name: n, filename: name})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseMigrationFilename splits "NNN_description.up.sql" into (NNN, description).
|
||||
// Returns ok=false on any deviation from that shape.
|
||||
func parseMigrationFilename(filename string) (version int, name string, ok bool) {
|
||||
base := strings.TrimSuffix(filename, ".up.sql")
|
||||
if base == filename {
|
||||
return 0, "", false
|
||||
}
|
||||
underscore := strings.IndexByte(base, '_')
|
||||
if underscore <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
v, err := strconv.Atoi(base[:underscore])
|
||||
if err != nil {
|
||||
return 0, "", false
|
||||
}
|
||||
return v, base[underscore+1:], true
|
||||
}
|
||||
|
||||
// readAppliedMigrations returns a map version → name from
|
||||
// paliad.applied_migrations. Returns an empty map (no error) if the table
|
||||
// is missing — that's the fresh-DB path before the CREATE TABLE in
|
||||
// ApplyMigrations runs against it.
|
||||
func readAppliedMigrations(conn *sql.DB) (map[int]string, error) {
|
||||
rows, err := conn.Query(`SELECT version, name FROM paliad.applied_migrations`)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return map[int]string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int]string{}
|
||||
for rows.Next() {
|
||||
var v int
|
||||
var n string
|
||||
if err := rows.Scan(&v, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[v] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// bootstrapFromLegacyTracker seeds paliad.applied_migrations from
|
||||
// paliad.paliad_schema_migrations on the first deploy of the new runner
|
||||
// against a DB that previously ran golang-migrate.
|
||||
//
|
||||
// Behavior:
|
||||
// - applied_migrations already has rows → no-op (idempotent).
|
||||
// - applied_migrations empty AND legacy tracker missing → no-op
|
||||
// (virgin DB; the apply loop will run everything from scratch).
|
||||
// - applied_migrations empty AND legacy tracker present, clean, version N
|
||||
// → INSERT rows for every on-disk version ≤ N with checksum NULL.
|
||||
// - applied_migrations empty AND legacy tracker dirty → hard-fail.
|
||||
// The operator must recover the legacy tracker first (it being dirty
|
||||
// means a prior golang-migrate run crashed mid-flight); we will not
|
||||
// paper over an unknown state by guessing what landed.
|
||||
//
|
||||
// Backfilled rows have checksum NULL because the legacy runner didn't hash
|
||||
// anything — we can't fabricate a provenance hash today without falsely
|
||||
// claiming we know the byte-identity of what shipped historically.
|
||||
func bootstrapFromLegacyTracker(conn *sql.DB, onDisk []migration) error {
|
||||
var count int
|
||||
if err := conn.QueryRow(`SELECT count(*) FROM paliad.applied_migrations`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("count applied_migrations: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var legacyVer int
|
||||
var legacyDirty bool
|
||||
err := conn.QueryRow(`SELECT version, dirty FROM paliad.paliad_schema_migrations LIMIT 1`).
|
||||
Scan(&legacyVer, &legacyDirty)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read legacy tracker: %w", err)
|
||||
}
|
||||
if legacyDirty {
|
||||
return fmt.Errorf("legacy paliad.paliad_schema_migrations is dirty at version %d — "+
|
||||
"recover manually before deploying", legacyVer)
|
||||
}
|
||||
|
||||
for _, m := range onDisk {
|
||||
if m.version > legacyVer {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.Exec(`
|
||||
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
|
||||
VALUES ($1, $2, now(), NULL)
|
||||
ON CONFLICT (version) DO NOTHING
|
||||
`, m.version, m.name); err != nil {
|
||||
return fmt.Errorf("backfill version %d: %w", m.version, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNameAgreement hard-fails if a version that's already applied has a
|
||||
// different name on disk than in the DB. Catches the post-merge rename
|
||||
// accident where someone renames `098_foo.up.sql` to `098_bar.up.sql` —
|
||||
// the SQL has already run on prod with the old name, so the rename is a
|
||||
// lie about history. Operator recovery: revert the rename, or update the
|
||||
// DB row if the rename is intentional.
|
||||
//
|
||||
// Backfilled rows have a name pulled from the on-disk filename, so an
|
||||
// out-of-the-box backfill never trips this check.
|
||||
func checkNameAgreement(onDisk []migration, applied map[int]string) error {
|
||||
for _, m := range onDisk {
|
||||
dbName, ok := applied[m.version]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if dbName != m.name {
|
||||
return fmt.Errorf("migration %d: disk name %q != DB name %q "+
|
||||
"(renamed after apply? revert the rename, or UPDATE paliad.applied_migrations "+
|
||||
"SET name=%q WHERE version=%d if the rename is intentional)",
|
||||
m.version, m.name, dbName, m.name, m.version)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyOne runs one migration's .up.sql plus its INSERT row in a single
|
||||
// transaction. All-or-nothing per migration: if the SQL fails, the row
|
||||
// isn't inserted and the next deploy re-tries from the same point. If
|
||||
// the INSERT fails (e.g. PK violation because the lock wasn't held), the
|
||||
// SQL rolls back too.
|
||||
func applyOne(conn *sql.DB, m migration) error {
|
||||
body, err := migrationFS.ReadFile("migrations/" + m.filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", m.filename, err)
|
||||
}
|
||||
checksum := fmt.Sprintf("%x", sha256.Sum256(body))
|
||||
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(string(body)); err != nil {
|
||||
return fmt.Errorf("exec sql: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
|
||||
VALUES ($1, $2, now(), $3)
|
||||
`, m.version, m.name, checksum); err != nil {
|
||||
return fmt.Errorf("record applied: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
145
internal/db/migrate_test.go
Normal file
145
internal/db/migrate_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Package db tests — migration dry-run gate.
|
||||
//
|
||||
// This is the test that catches mig-N crash-loops before they reach prod.
|
||||
// The new runner tracks applied state as a set in paliad.applied_migrations
|
||||
// (one row per migration; see migrate.go). A migration that compiles cleanly
|
||||
// but fails on apply (typo, missing column, wrong CHECK shape) crashes the
|
||||
// Dokploy container loop before paliad.de finishes binding :8080, and the
|
||||
// only way to learn about it today is to watch the deploy log.
|
||||
//
|
||||
// TestMigrations_DryRun closes that gap: for every *.up.sql in this
|
||||
// directory whose version is NOT present in paliad.applied_migrations on
|
||||
// the scratch DB, it opens a transaction, runs the SQL, and ROLLBACKs.
|
||||
// Any error fails the test with the file name + Postgres error. Always
|
||||
// non-destructive — the ROLLBACK runs even on success, so the scratch DB
|
||||
// stays at its starting set.
|
||||
//
|
||||
// "Pending" means: a version that's on disk but not in applied_migrations.
|
||||
// In CI against a fresh scratch DB (where applied_migrations either
|
||||
// doesn't exist or is empty), every migration is pending and gets
|
||||
// verified. On a developer laptop whose scratch DB is already at HEAD,
|
||||
// no migrations are pending and the test logs and passes — the protection
|
||||
// only kicks in the moment a new *.up.sql lands in the tree before the
|
||||
// developer runs `db.ApplyMigrations` against the same scratch DB.
|
||||
//
|
||||
// Requires TEST_DATABASE_URL (same pattern as the rest of the live-DB
|
||||
// tests). Skipped without it.
|
||||
//
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
|
||||
// docs/design-migration-runner-applied-set-2026-05-20.md §6.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// TestMigrations_DryRun walks every pending *.up.sql in numeric order,
|
||||
// applies each inside its own BEGIN/ROLLBACK against the scratch DB, and
|
||||
// fails the test on the first SQL error. Reports per-file as a sub-test so
|
||||
// `go test -v` shows which migration failed.
|
||||
func TestMigrations_DryRun(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping migration dry-run")
|
||||
}
|
||||
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := conn.Ping(); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
|
||||
// The paliad schema must exist before migration 001 runs against it,
|
||||
// mirroring the bootstrap step in ApplyMigrations. Without this, a
|
||||
// fresh scratch DB would fail migration 001's CREATE TABLE paliad.*
|
||||
// statements inside the BEGIN/ROLLBACK probe with "schema paliad does
|
||||
// not exist" — a false negative that distracts from real errors.
|
||||
if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil {
|
||||
t.Fatalf("ensure paliad schema: %v", err)
|
||||
}
|
||||
|
||||
applied, err := readAppliedVersions(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read applied_migrations: %v", err)
|
||||
}
|
||||
|
||||
onDisk, err := scanEmbeddedMigrations()
|
||||
if err != nil {
|
||||
t.Fatalf("scan embedded migrations: %v", err)
|
||||
}
|
||||
|
||||
var pending []migration
|
||||
for _, m := range onDisk {
|
||||
if !applied[m.version] {
|
||||
pending = append(pending, m)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
t.Logf("no pending migrations — scratch DB applied set covers every on-disk version (%d total)",
|
||||
len(onDisk))
|
||||
return
|
||||
}
|
||||
t.Logf("scratch DB has %d/%d on-disk migrations applied; walking %d pending",
|
||||
len(applied), len(onDisk), len(pending))
|
||||
|
||||
for _, m := range pending {
|
||||
t.Run(fmt.Sprintf("%03d_%s", m.version, m.name), func(t *testing.T) {
|
||||
body, err := migrationFS.ReadFile("migrations/" + m.filename)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", m.filename, err)
|
||||
}
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
// Always rollback; the dry-run must not leave the scratch
|
||||
// DB at a different applied set than where it started.
|
||||
// Rollback is safe after a failed Exec — Postgres aborts
|
||||
// the transaction internally on the first error.
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(string(body)); err != nil {
|
||||
t.Fatalf("migration %s failed dry-run: %v", m.filename, 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).
|
||||
//
|
||||
// We don't pre-create the table here because the dry-run is supposed to be
|
||||
// a passive observer — it must not mutate the scratch DB outside of its
|
||||
// own per-mig BEGIN/ROLLBACK probes. A "table doesn't exist" outcome is
|
||||
// the right read against a virgin scratch DB.
|
||||
func readAppliedVersions(conn *sql.DB) (map[int]bool, error) {
|
||||
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations`)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return map[int]bool{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int]bool{}
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[v] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- t-paliad-190 down — reverses 089_deadline_rule_backfill_orphans.up.sql.
|
||||
-- Drops the staging table; mig 090's down-migration MUST run first
|
||||
-- (it depends on this table for its INSERT — running them in reverse
|
||||
-- order satisfies that).
|
||||
|
||||
DROP POLICY IF EXISTS deadline_rule_backfill_orphans_select ON paliad.deadline_rule_backfill_orphans;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_backfill_orphans_unresolved_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_backfill_orphans_deadline_id_idx;
|
||||
DROP TABLE IF EXISTS paliad.deadline_rule_backfill_orphans;
|
||||
@@ -0,0 +1,82 @@
|
||||
-- t-paliad-190 / Fristen Phase 3 Slice 10 — staging table for the
|
||||
-- fuzzy-match orphans produced by mig 090. Per design §3.I + m's Q10
|
||||
-- ruling: legacy paliad.deadlines rows whose title can't be uniquely
|
||||
-- bound to a deadline_rule via fuzzy matching are NOT silently left
|
||||
-- NULL — they're logged here so a legal-review pass can hand-link
|
||||
-- the ambiguous tail.
|
||||
--
|
||||
-- Mig 089 ships the table; mig 090 does the actual backfill +
|
||||
-- populates this table. Numbering reflects the dependency order
|
||||
-- (the backfill SELECTs into this table, so the table must exist
|
||||
-- first).
|
||||
--
|
||||
-- Schema notes:
|
||||
-- - deadline_id is the FK to paliad.deadlines.id with ON DELETE
|
||||
-- CASCADE so a hand-deletion of an orphan deadline cleans up
|
||||
-- its staging row too. (Deadlines are normally archived, not
|
||||
-- deleted; the cascade is defensive.)
|
||||
-- - project_id stays denormalised so the admin orphan-review UI
|
||||
-- can group orphans by project without re-joining deadlines.
|
||||
-- - reason is a free-text discriminator: 'no_match' | 'ambiguous'
|
||||
-- today; the editor in Slice 11 may add 'manual_unbound' or
|
||||
-- similar in the future.
|
||||
-- - resolved_at + resolved_rule_id are NULL on insert; the admin
|
||||
-- orphan-review UI sets them when an editor hand-links the row,
|
||||
-- so the table doubles as an audit trail of the legal-review
|
||||
-- pass. The matching paliad.deadlines.rule_id is updated at the
|
||||
-- same time (the UPDATE on deadlines fires its own audit row
|
||||
-- once an audit trigger lives on that table; today no trigger,
|
||||
-- so the staging row is the audit artefact).
|
||||
--
|
||||
-- RLS: admin-only read. The orphan list contains real deadline titles
|
||||
-- + project ids, so non-admins should not see it. The Slice 11 rule
|
||||
-- editor surface gates this further.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rule_backfill_orphans (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
deadline_id uuid NOT NULL
|
||||
REFERENCES paliad.deadlines(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
project_id uuid,
|
||||
proceeding_code text,
|
||||
reason text NOT NULL
|
||||
CHECK (reason IN ('no_match', 'ambiguous', 'no_project', 'manual_unbound')),
|
||||
candidate_count int NOT NULL DEFAULT 0,
|
||||
candidate_rule_ids uuid[] NOT NULL DEFAULT '{}',
|
||||
resolved_at timestamptz,
|
||||
resolved_rule_id uuid
|
||||
REFERENCES paliad.deadline_rules(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_backfill_orphans_deadline_id_idx
|
||||
ON paliad.deadline_rule_backfill_orphans (deadline_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_backfill_orphans_unresolved_idx
|
||||
ON paliad.deadline_rule_backfill_orphans (created_at DESC)
|
||||
WHERE resolved_at IS NULL;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rule_backfill_orphans IS
|
||||
'Slice 10 (mig 089/090, t-paliad-190): staging for legacy '
|
||||
'paliad.deadlines rows that the fuzzy-match backfill could not '
|
||||
'uniquely bind to a deadline_rule. Each row holds the deadline '
|
||||
'context + the candidate rule IDs the matcher found (0 → '
|
||||
'''no_match''; ≥2 → ''ambiguous'') so a legal-review pass can '
|
||||
'hand-link without rerunning the match. resolved_at + '
|
||||
'resolved_rule_id flip when the admin orphan-review UI binds the '
|
||||
'row.';
|
||||
|
||||
-- RLS: admin-only read.
|
||||
ALTER TABLE paliad.deadline_rule_backfill_orphans ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS deadline_rule_backfill_orphans_select ON paliad.deadline_rule_backfill_orphans;
|
||||
|
||||
CREATE POLICY deadline_rule_backfill_orphans_select
|
||||
ON paliad.deadline_rule_backfill_orphans FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
-- t-paliad-190 down — reverses 090_backfill_deadline_rule_id.up.sql.
|
||||
--
|
||||
-- Restores rule_id values from the pre-mig snapshot (every deadline
|
||||
-- that mig 090 touched had rule_id IS NULL originally, so restoring
|
||||
-- means setting rule_id back to NULL on every row that survived the
|
||||
-- backfill). Drops the orphan rows mig 090 wrote (resolved rows stay
|
||||
-- — those represent legal-review work that shouldn't disappear on
|
||||
-- a code rollback) and drops the backup table.
|
||||
--
|
||||
-- This is a defensive rollback path; the migration itself is one-time
|
||||
-- + idempotent, so re-running 090 after a down + up is safe.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 090: NULL rule_id on deadlines mig 090 touched + drop pre-089 backup',
|
||||
true);
|
||||
|
||||
-- Restore rule_id = NULL on every deadline mig 090 may have written.
|
||||
-- We use the backup table as the authoritative "before" snapshot.
|
||||
UPDATE paliad.deadlines d
|
||||
SET rule_id = b.rule_id
|
||||
FROM paliad.deadlines_pre_089 b
|
||||
WHERE d.id = b.id;
|
||||
|
||||
-- Drop the unresolved orphan rows mig 090 wrote. Resolved rows stay —
|
||||
-- a legal-review hand-link is real work that survives a code rollback.
|
||||
DELETE FROM paliad.deadline_rule_backfill_orphans
|
||||
WHERE resolved_at IS NULL;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.deadlines_pre_089;
|
||||
320
internal/db/migrations/090_backfill_deadline_rule_id.up.sql
Normal file
320
internal/db/migrations/090_backfill_deadline_rule_id.up.sql
Normal file
@@ -0,0 +1,320 @@
|
||||
-- t-paliad-190 / Fristen Phase 3 Slice 10 — one-time fuzzy-match
|
||||
-- backfill of paliad.deadlines.rule_id per design §3.I + m's Q10
|
||||
-- ruling. Restores SmartTimeline's "anchor real deadlines into
|
||||
-- projection" affordance on legacy data (1 of 26 deadlines currently
|
||||
-- has rule_id populated; the SmartTimeline anchor flow needs the FK
|
||||
-- to thread predicted dates off actuals).
|
||||
--
|
||||
-- Matching strategies (in priority order; first unique hit wins):
|
||||
--
|
||||
-- 1. rule_code-prefix extraction from title. Titles like
|
||||
-- "RoP.023 — Klageerwiderung" carry the rule citation in the
|
||||
-- prefix; we extract the leading citation token and JOIN on
|
||||
-- deadline_rules.rule_code = extracted. When the rule_code
|
||||
-- resolves to multiple rules (e.g. RoP.023 → 2 rules — DE
|
||||
-- Klageerwiderung + EN Statement of Defence), the remaining
|
||||
-- title fragment narrows by name ILIKE.
|
||||
--
|
||||
-- 2. exact title match against rule.name OR rule.name_en (LOWER).
|
||||
-- Mostly hits common Pipeline-A names ("Antrag auf
|
||||
-- Schadensbemessung" → 1 unique rule); ambiguous for shared
|
||||
-- names like "Klageerwiderung" (8 rules across proceedings).
|
||||
--
|
||||
-- 3. deadline_concepts.aliases match. Each concept carries a
|
||||
-- text[] of canonical aliases; if LOWER(d.title) is in the
|
||||
-- aliases array, we pick the rules with that concept_id. Today
|
||||
-- the alias coverage is thin (no aliases for "Schutzschrift"
|
||||
-- etc.), but the strategy is shaped so a future seed lights
|
||||
-- it up.
|
||||
--
|
||||
-- For each deadline, we collect all candidates across the three
|
||||
-- strategies, dedupe by rule.id, and:
|
||||
-- - exactly 1 candidate → UPDATE rule_id (matched).
|
||||
-- - 0 candidates → orphan with reason='no_match'.
|
||||
-- - ≥2 candidates → orphan with reason='ambiguous', candidate_rule_ids
|
||||
-- populated so a legal-review pass can hand-pick.
|
||||
--
|
||||
-- Per-project narrowing by proceeding_type_id is the design's primary
|
||||
-- discriminator. In the live corpus today all 11 projects have
|
||||
-- proceeding_type_id IS NULL (Slice 5 retired litigation codes from
|
||||
-- project-binding; the fristenrechner-side rebinding hasn't happened),
|
||||
-- so this slice can't use proceeding-narrowing on production data.
|
||||
-- The CTE still includes the predicate so the migration self-tunes
|
||||
-- the moment proceeding_type_id starts getting populated.
|
||||
--
|
||||
-- Defensive backup: paliad.deadlines is snapshotted to
|
||||
-- paliad.deadlines_pre_089 before the UPDATE so an operator can
|
||||
-- restore individual rule_id values if a hand-link goes wrong post
|
||||
-- mig. The table is dropped in the down-migration; Slice 11 (rule
|
||||
-- editor) can drop it once orphan resolution finishes in prod.
|
||||
--
|
||||
-- Idempotency: WHERE d.rule_id IS NULL on the UPDATE; the orphan
|
||||
-- INSERT uses ON CONFLICT DO NOTHING via a NOT EXISTS guard (no
|
||||
-- unique constraint on deadline_id alone — a deadline may legitimately
|
||||
-- get re-orphaned after a resolution rollback; but re-running 090 on
|
||||
-- the same corpus must not duplicate orphan rows for unresolved
|
||||
-- deadlines).
|
||||
--
|
||||
-- Hard assertion at end: SUM(matched) + SUM(orphans for current
|
||||
-- unresolved deadlines) ≥ COUNT(deadlines processed). Strict equality
|
||||
-- doesn't hold cleanly on a re-run (the orphan table may already
|
||||
-- carry prior rows from a partial run), so the assertion is "at
|
||||
-- least one row exists per unresolved deadline".
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 090: one-time fuzzy-match backfill of deadlines.rule_id per design §3.I / Q10',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Defensive backup before any UPDATE.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadlines_pre_089 AS
|
||||
SELECT id, project_id, title, rule_id, rule_code, status, due_date,
|
||||
completed_at, created_at, updated_at
|
||||
FROM paliad.deadlines
|
||||
WHERE rule_id IS NULL
|
||||
AND project_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE paliad.deadlines_pre_089 IS
|
||||
'Snapshot of paliad.deadlines (id, rule_id-relevant columns) taken '
|
||||
'before mig 090 ran the fuzzy-match backfill. Lets an operator '
|
||||
'restore individual rule_id values if a hand-link goes wrong. '
|
||||
'Slice 11 (rule editor) drops this once orphan resolution finishes.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Build the candidate set in a temp table so the per-deadline
|
||||
-- aggregation + UPDATE + orphan INSERT can share the work without
|
||||
-- re-evaluating the matchers.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TEMP TABLE _mig_090_candidates ON COMMIT DROP AS
|
||||
WITH targets AS (
|
||||
-- Every NULL-rule_id deadline still bound to a project. project_id
|
||||
-- is required because we want at least the SmartTimeline anchor
|
||||
-- flow to work; un-bound deadlines (rare) are out of scope.
|
||||
SELECT d.id AS deadline_id,
|
||||
d.title AS title,
|
||||
d.project_id,
|
||||
p.proceeding_type_id,
|
||||
-- Extract a leading citation token like "RoP.023" or
|
||||
-- "R.49" from the title. Captures the rule_code prefix
|
||||
-- on titles that carry one ("RoP.023 — Klageerwiderung");
|
||||
-- NULL on plain titles.
|
||||
NULLIF(regexp_replace(d.title, '^\s*((?:RoP|R|Art|§)\.?\s*[0-9]+(?:\.[a-z0-9]+)*)\s*(?:[—–-].*)?$', '\1'), d.title) AS code_token,
|
||||
-- Strip the leading citation + separator to surface the
|
||||
-- title's name fragment. "RoP.023 — Klageerwiderung" →
|
||||
-- "Klageerwiderung"; "RoP.029.a" (no suffix) → ""; plain
|
||||
-- "Klageerwiderung" → "Klageerwiderung" unchanged.
|
||||
NULLIF(trim(regexp_replace(d.title, '^\s*(?:RoP|R|Art|§)\.?\s*[0-9]+(?:\.[a-z0-9]+)*\s*[—–-]?\s*', '')), '') AS title_tail
|
||||
FROM paliad.deadlines d
|
||||
LEFT JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE d.rule_id IS NULL
|
||||
AND d.project_id IS NOT NULL
|
||||
),
|
||||
by_code_and_tail AS (
|
||||
-- Strategy 1a (narrowest): rule_code AND name (DE or EN) matches
|
||||
-- the title's tail fragment. Handles "RoP.023 — Klageerwiderung"
|
||||
-- where the bare code matches 2 rules (DE Klageerwiderung +
|
||||
-- EN Statement of Defence); the tail picks the DE one.
|
||||
SELECT t.deadline_id, dr.id AS rule_id, 'rule_code_and_tail' AS strategy
|
||||
FROM targets t
|
||||
JOIN paliad.deadline_rules dr
|
||||
ON dr.rule_code = trim(t.code_token)
|
||||
AND dr.is_active = true
|
||||
AND (LOWER(dr.name) = LOWER(t.title_tail)
|
||||
OR LOWER(dr.name_en) = LOWER(t.title_tail))
|
||||
WHERE t.code_token IS NOT NULL
|
||||
AND t.title_tail IS NOT NULL
|
||||
),
|
||||
by_code AS (
|
||||
-- Strategy 1b: rule_code prefix only. Handles bare-code titles
|
||||
-- ("RoP.029.a" maps to 1 unique rule regardless of suffix) and
|
||||
-- the fallback when by_code_and_tail returns 0 (suffix doesn't
|
||||
-- match — e.g. "RoP.029.a — Replik" where the suffix "Replik"
|
||||
-- doesn't appear in any RoP.029.a rule's name; pick the
|
||||
-- code-only match anyway).
|
||||
SELECT t.deadline_id, dr.id AS rule_id, 'rule_code' AS strategy
|
||||
FROM targets t
|
||||
JOIN paliad.deadline_rules dr
|
||||
ON dr.rule_code = trim(t.code_token)
|
||||
AND dr.is_active = true
|
||||
WHERE t.code_token IS NOT NULL
|
||||
),
|
||||
by_name AS (
|
||||
-- Strategy 2: exact title match against rule.name or rule.name_en.
|
||||
-- The widest matcher; for shared names like "Klageerwiderung"
|
||||
-- (8 rules across proceedings) this is ambiguous, but for
|
||||
-- unique titles like "Antrag auf Schadensbemessung" (1 rule) it
|
||||
-- nails the match.
|
||||
SELECT t.deadline_id, dr.id AS rule_id, 'name_exact' AS strategy
|
||||
FROM targets t
|
||||
JOIN paliad.deadline_rules dr
|
||||
ON (LOWER(dr.name) = LOWER(t.title)
|
||||
OR LOWER(dr.name_en) = LOWER(t.title))
|
||||
AND dr.is_active = true
|
||||
),
|
||||
by_alias AS (
|
||||
-- Strategy 3: concept aliases. deadline_concepts.aliases is a
|
||||
-- text[] of canonical synonyms; if the deadline title appears
|
||||
-- in that array, every active rule on the concept is a candidate.
|
||||
-- Today's alias coverage is thin (the seed for Slice 12 is the
|
||||
-- expected source of new aliases), but the strategy is in place
|
||||
-- so future seeds light it up without a migration.
|
||||
SELECT t.deadline_id, dr.id AS rule_id, 'concept_alias' AS strategy
|
||||
FROM targets t
|
||||
JOIN paliad.deadline_concepts dc
|
||||
ON LOWER(t.title) = ANY(SELECT LOWER(a) FROM unnest(dc.aliases) a)
|
||||
JOIN paliad.deadline_rules dr
|
||||
ON dr.concept_id = dc.id
|
||||
AND dr.is_active = true
|
||||
)
|
||||
SELECT deadline_id, rule_id, strategy
|
||||
FROM by_code_and_tail
|
||||
UNION
|
||||
SELECT deadline_id, rule_id, strategy
|
||||
FROM by_code
|
||||
UNION
|
||||
SELECT deadline_id, rule_id, strategy
|
||||
FROM by_name
|
||||
UNION
|
||||
SELECT deadline_id, rule_id, strategy
|
||||
FROM by_alias;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Aggregate per-deadline candidate counts by strategy + pick the
|
||||
-- narrowest-unique-match per deadline. Strategy priority (narrowest
|
||||
-- first): rule_code_and_tail > rule_code > name_exact > concept_alias.
|
||||
-- A deadline's "chosen" rule comes from the highest-priority strategy
|
||||
-- that yields exactly 1 distinct candidate.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TEMP TABLE _mig_090_strategy_counts ON COMMIT DROP AS
|
||||
SELECT deadline_id,
|
||||
strategy,
|
||||
count(DISTINCT rule_id) AS n,
|
||||
MIN(rule_id::text) AS first_rule_text
|
||||
FROM _mig_090_candidates
|
||||
GROUP BY deadline_id, strategy;
|
||||
|
||||
CREATE TEMP TABLE _mig_090_chosen ON COMMIT DROP AS
|
||||
SELECT DISTINCT ON (deadline_id)
|
||||
deadline_id,
|
||||
first_rule_text::uuid AS rule_id,
|
||||
strategy AS chosen_strategy
|
||||
FROM _mig_090_strategy_counts
|
||||
WHERE n = 1
|
||||
ORDER BY deadline_id,
|
||||
CASE strategy
|
||||
WHEN 'rule_code_and_tail' THEN 1
|
||||
WHEN 'rule_code' THEN 2
|
||||
WHEN 'name_exact' THEN 3
|
||||
WHEN 'concept_alias' THEN 4
|
||||
ELSE 5
|
||||
END;
|
||||
|
||||
-- "Aggregated" carries the widest candidate set for orphan logging
|
||||
-- (an editor reviewing an orphan wants to see EVERY plausible rule,
|
||||
-- not just the narrowest-strategy result).
|
||||
CREATE TEMP TABLE _mig_090_aggregated ON COMMIT DROP AS
|
||||
SELECT c.deadline_id,
|
||||
count(DISTINCT c.rule_id) AS n_candidates,
|
||||
array_agg(DISTINCT c.rule_id) AS all_rule_ids
|
||||
FROM _mig_090_candidates c
|
||||
GROUP BY c.deadline_id;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. UPDATE deadlines.rule_id for the chosen set (narrowest-unique match).
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET rule_id = c.rule_id
|
||||
FROM _mig_090_chosen c
|
||||
WHERE d.id = c.deadline_id
|
||||
AND d.rule_id IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. Log every deadline that didn't get a unique match as an orphan.
|
||||
-- Skip rows that already have a non-resolved orphan entry (re-run
|
||||
-- guard) — the existing entry is the source-of-truth until the
|
||||
-- admin UI flips resolved_at.
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO paliad.deadline_rule_backfill_orphans
|
||||
(deadline_id, title, project_id, proceeding_code, reason,
|
||||
candidate_count, candidate_rule_ids)
|
||||
SELECT t.deadline_id,
|
||||
t.title,
|
||||
t.project_id,
|
||||
pt.code AS proceeding_code,
|
||||
CASE
|
||||
WHEN a.n_candidates IS NULL OR a.n_candidates = 0 THEN 'no_match'
|
||||
WHEN a.n_candidates > 1 THEN 'ambiguous'
|
||||
END AS reason,
|
||||
COALESCE(a.n_candidates, 0),
|
||||
COALESCE(a.all_rule_ids, ARRAY[]::uuid[])
|
||||
FROM (
|
||||
SELECT d.id AS deadline_id, d.title, d.project_id, p.proceeding_type_id
|
||||
FROM paliad.deadlines d
|
||||
LEFT JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE d.rule_id IS NULL
|
||||
AND d.project_id IS NOT NULL
|
||||
) t
|
||||
LEFT JOIN _mig_090_aggregated a ON a.deadline_id = t.deadline_id
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = t.proceeding_type_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.deadline_rule_backfill_orphans o
|
||||
WHERE o.deadline_id = t.deadline_id
|
||||
AND o.resolved_at IS NULL
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. Hard assertion: every NULL-rule_id deadline (with project) is
|
||||
-- either resolved (rule_id IS NOT NULL post-mig) or carries an
|
||||
-- unresolved orphan row.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_processed int;
|
||||
n_matched int;
|
||||
n_orphaned int;
|
||||
n_unaccounted int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_processed
|
||||
FROM paliad.deadlines
|
||||
WHERE project_id IS NOT NULL
|
||||
AND (rule_id IS NOT NULL OR EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rule_backfill_orphans o
|
||||
WHERE o.deadline_id = paliad.deadlines.id
|
||||
));
|
||||
|
||||
SELECT count(*) INTO n_matched
|
||||
FROM paliad.deadlines d
|
||||
JOIN paliad.deadlines_pre_089 b ON b.id = d.id
|
||||
WHERE d.rule_id IS NOT NULL;
|
||||
|
||||
SELECT count(DISTINCT deadline_id) INTO n_orphaned
|
||||
FROM paliad.deadline_rule_backfill_orphans
|
||||
WHERE resolved_at IS NULL;
|
||||
|
||||
SELECT count(*) INTO n_unaccounted
|
||||
FROM paliad.deadlines d
|
||||
WHERE d.rule_id IS NULL
|
||||
AND d.project_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rule_backfill_orphans o
|
||||
WHERE o.deadline_id = d.id
|
||||
);
|
||||
|
||||
RAISE NOTICE 'mig 090: processed=% matched=% orphaned=% unaccounted=%',
|
||||
n_processed, n_matched, n_orphaned, n_unaccounted;
|
||||
|
||||
IF n_unaccounted > 0 THEN
|
||||
RAISE EXCEPTION 'mig 090: % deadlines have rule_id IS NULL and no orphan row — '
|
||||
'matcher missed them. Investigate the candidate query.',
|
||||
n_unaccounted;
|
||||
END IF;
|
||||
END $$;
|
||||
32
internal/db/migrations/091_drop_legacy_rule_columns.down.sql
Normal file
32
internal/db/migrations/091_drop_legacy_rule_columns.down.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- t-paliad-195 down — reverses 091_drop_legacy_rule_columns.up.sql.
|
||||
--
|
||||
-- Restores the four columns and re-populates them from the
|
||||
-- paliad.deadline_rules_pre_091 snapshot. Rules created AFTER the
|
||||
-- mig 091 cutover (via the rule editor's POST /admin/api/rules)
|
||||
-- won't have a snapshot entry — they get NULL on the restored
|
||||
-- columns, which matches their original "never had these legacy
|
||||
-- fields" state.
|
||||
--
|
||||
-- The snapshot table itself stays (it's a permanent audit artefact);
|
||||
-- a focused follow-up slice / Slice 12 cleanup drops it once the
|
||||
-- rule editor's migration-export flow has been used to roll any
|
||||
-- post-drop edits back into version control.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 091: restore legacy columns from pre-drop snapshot',
|
||||
true);
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS is_mandatory boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS is_optional boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS condition_flag text[],
|
||||
ADD COLUMN IF NOT EXISTS condition_rule_id uuid;
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET is_mandatory = b.is_mandatory,
|
||||
is_optional = b.is_optional,
|
||||
condition_flag = b.condition_flag,
|
||||
condition_rule_id = b.condition_rule_id
|
||||
FROM paliad.deadline_rules_pre_091 b
|
||||
WHERE dr.id = b.id;
|
||||
116
internal/db/migrations/091_drop_legacy_rule_columns.up.sql
Normal file
116
internal/db/migrations/091_drop_legacy_rule_columns.up.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- t-paliad-195 / Fristen Phase 3 Slice 9 Step E (design §3.E, §9.1).
|
||||
-- m approved the downtime window 2026-05-15 ("paliad ist nicht in use
|
||||
-- heute, downtime ist egal") so the destructive drops can land.
|
||||
--
|
||||
-- This migration drops the four legacy columns on
|
||||
-- paliad.deadline_rules that the unified Phase 3 calculator no longer
|
||||
-- reads. The replacements have been backfilled (Slice 2 mig 082/083/
|
||||
-- 084), wired into the calculator (Slice 4), and on the wire (Slice 8):
|
||||
--
|
||||
-- is_mandatory → priority='mandatory' | (recommended | optional | informational)
|
||||
-- is_optional → priority='optional' (the RoP.151 T/T case)
|
||||
-- condition_flag → condition_expr (jsonb long form)
|
||||
-- condition_rule_id → DEAD (no live rows, Q13 m's approved drop)
|
||||
--
|
||||
-- Sibling drops (event_deadlines/trigger_events tables, retire of
|
||||
-- litigation category) are deferred from this slice per the live-data
|
||||
-- audit (see head ping). This file is the legacy-column-drop only.
|
||||
--
|
||||
-- Backup: paliad.deadline_rules_pre_091 snapshot of the four columns +
|
||||
-- id BEFORE the drop, so the down-migration can restore individual
|
||||
-- values if a deploy needs to roll back. The backup uses CREATE TABLE
|
||||
-- IF NOT EXISTS so a re-applied migration is a no-op.
|
||||
--
|
||||
-- Audit-reason set at the top: the mig 079 trigger fires on every
|
||||
-- UPDATE/DELETE on paliad.deadline_rules; ALTER TABLE DROP COLUMN
|
||||
-- doesn't fire the row-level trigger but the wrapper is the standard
|
||||
-- Phase 3 pattern. The reason persists in the audit log only for
|
||||
-- write paths.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 091: drop legacy rule columns per design §3.E + m''s 2026-05-15 approval',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Snapshot of the four columns + id, so the down-migration can
|
||||
-- restore values to existing rows. Skipping the snapshot table
|
||||
-- would mean a rollback adds the columns back but with NULL data;
|
||||
-- the snapshot preserves the legacy values for any downstream
|
||||
-- consumer the audit might surface.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_091 AS
|
||||
SELECT id,
|
||||
is_mandatory,
|
||||
is_optional,
|
||||
condition_flag,
|
||||
condition_rule_id,
|
||||
now() AS snapshotted_at
|
||||
FROM paliad.deadline_rules;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rules_pre_091 IS
|
||||
'Snapshot of paliad.deadline_rules.(is_mandatory, is_optional, '
|
||||
'condition_flag, condition_rule_id) before mig 091''s drop. Lets '
|
||||
'a rollback restore the legacy values for the 172 rules that '
|
||||
'existed at drop time. Drop this table after Slice 9 is verified '
|
||||
'in prod (a focused follow-up slice or part of Slice 12 cleanup).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Drop the columns. Order doesn't matter — none of them reference
|
||||
-- each other or other tables (condition_rule_id was a dead self-FK
|
||||
-- that no live row uses, Q13).
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS is_mandatory,
|
||||
DROP COLUMN IF EXISTS is_optional,
|
||||
DROP COLUMN IF EXISTS condition_flag,
|
||||
DROP COLUMN IF EXISTS condition_rule_id;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Hard assertion: every remaining row carries a valid priority +
|
||||
-- has condition_expr populated when its legacy condition_flag was
|
||||
-- non-empty pre-mig. Belt-and-braces — Slice 2 backfilled both
|
||||
-- paths and Slice 4 unified the calculator, but a stale row would
|
||||
-- light up here BEFORE we hand the schema to the unified code.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_total int;
|
||||
n_null_prio int;
|
||||
n_lost int;
|
||||
BEGIN
|
||||
SELECT count(*), count(*) FILTER (WHERE priority IS NULL)
|
||||
INTO n_total, n_null_prio
|
||||
FROM paliad.deadline_rules;
|
||||
|
||||
-- Cross-check against the snapshot: every pre-mig row with a
|
||||
-- non-empty condition_flag must have a non-NULL condition_expr
|
||||
-- post-mig. If any row lost its gate, the calculator's gate
|
||||
-- behaviour would silently change — surface it loudly.
|
||||
SELECT count(*)
|
||||
INTO n_lost
|
||||
FROM paliad.deadline_rules_pre_091 b
|
||||
JOIN paliad.deadline_rules dr ON dr.id = b.id
|
||||
WHERE b.condition_flag IS NOT NULL
|
||||
AND array_length(b.condition_flag, 1) > 0
|
||||
AND dr.condition_expr IS NULL;
|
||||
|
||||
RAISE NOTICE 'mig 091: % rules, % with NULL priority, % lost condition_expr',
|
||||
n_total, n_null_prio, n_lost;
|
||||
|
||||
IF n_null_prio > 0 THEN
|
||||
RAISE EXCEPTION 'mig 091: % rules have priority IS NULL post-drop — '
|
||||
'the priority column must be backfilled (Slice 2 mig 083) '
|
||||
'before legacy columns are dropped',
|
||||
n_null_prio;
|
||||
END IF;
|
||||
|
||||
IF n_lost > 0 THEN
|
||||
RAISE EXCEPTION 'mig 091: % rules had a condition_flag pre-drop but no '
|
||||
'condition_expr post-drop — Slice 2 mig 084 missed them',
|
||||
n_lost;
|
||||
END IF;
|
||||
END $$;
|
||||
116
internal/db/migrations/092_drop_event_deadlines_tables.down.sql
Normal file
116
internal/db/migrations/092_drop_event_deadlines_tables.down.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- t-paliad-199 down — reverses 092_drop_event_deadlines_tables.up.sql.
|
||||
--
|
||||
-- Re-creates paliad.event_deadlines + paliad.event_deadline_rule_codes
|
||||
-- with the schema they had at end of mig 086 (the read-only state right
|
||||
-- before mig 092 dropped them), repopulates from the _pre_092
|
||||
-- snapshots, restores the mig 086 read-only trigger, and drops the
|
||||
-- rule_codes column the up migration added to paliad.deadline_rules.
|
||||
--
|
||||
-- The snapshot tables themselves stay — they're the source of this
|
||||
-- rollback's data and a permanent audit artefact. A focused
|
||||
-- follow-up slice / Slice 12 cleanup drops the snapshots once
|
||||
-- Slice 9 is verified in prod.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 092: restore paliad.event_deadlines + event_deadline_rule_codes from pre-drop snapshots and drop rule_codes column',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Recreate paliad.event_deadlines. Schema matches the live state at
|
||||
-- the start of mig 092 (post-mig-086, with the notes_en column from
|
||||
-- mig 036 and the legal_source column from mig 038).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.event_deadlines (
|
||||
id bigint PRIMARY KEY,
|
||||
trigger_event_id bigint NOT NULL REFERENCES paliad.trigger_events(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
title_de text NOT NULL DEFAULT '',
|
||||
duration_value integer NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'days'
|
||||
CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days')),
|
||||
timing text NOT NULL DEFAULT 'after'
|
||||
CHECK (timing IN ('before', 'after')),
|
||||
notes text NOT NULL DEFAULT '',
|
||||
alt_duration_value integer,
|
||||
alt_duration_unit text CHECK (alt_duration_unit IS NULL OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days')),
|
||||
combine_op text CHECK (combine_op IS NULL OR combine_op IN ('max', 'min')),
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
notes_en text,
|
||||
legal_source text
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS event_deadlines_trigger_event_idx
|
||||
ON paliad.event_deadlines (trigger_event_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS event_deadlines_active_idx
|
||||
ON paliad.event_deadlines (is_active) WHERE is_active = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS event_deadlines_legal_src_trgm
|
||||
ON paliad.event_deadlines USING gin (legal_source gin_trgm_ops);
|
||||
|
||||
INSERT INTO paliad.event_deadlines
|
||||
(id, trigger_event_id, title, title_de, duration_value, duration_unit,
|
||||
timing, notes, alt_duration_value, alt_duration_unit, combine_op,
|
||||
is_active, created_at, updated_at, notes_en, legal_source)
|
||||
SELECT id, trigger_event_id, title, title_de, duration_value, duration_unit,
|
||||
timing, notes, alt_duration_value, alt_duration_unit, combine_op,
|
||||
is_active, created_at, updated_at, notes_en, legal_source
|
||||
FROM paliad.event_deadlines_pre_092
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Recreate paliad.event_deadline_rule_codes.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.event_deadline_rule_codes (
|
||||
event_deadline_id bigint NOT NULL REFERENCES paliad.event_deadlines(id) ON DELETE CASCADE,
|
||||
rule_code text NOT NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (event_deadline_id, rule_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS event_deadline_rule_codes_code_idx
|
||||
ON paliad.event_deadline_rule_codes (rule_code);
|
||||
|
||||
INSERT INTO paliad.event_deadline_rule_codes
|
||||
(event_deadline_id, rule_code, sort_order)
|
||||
SELECT event_deadline_id, rule_code, sort_order
|
||||
FROM paliad.event_deadline_rule_codes_pre_092
|
||||
ON CONFLICT (event_deadline_id, rule_code) DO NOTHING;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Restore the mig 086 read-only trigger + function (the rolled-back
|
||||
-- state IS "Slice 3 + Slice 9 only", which had the trigger in place).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.event_deadlines_readonly_trigger()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'paliad.event_deadlines is read-only after Phase 3 Slice 3 — '
|
||||
'writes must go through paliad.deadline_rules (Pipeline C is '
|
||||
'unified; the source table is preserved as an audit anchor '
|
||||
'until Slice 9 drops it). Operation: %', TG_OP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
|
||||
CREATE TRIGGER event_deadlines_readonly
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON paliad.event_deadlines
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.event_deadlines_readonly_trigger();
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Drop the rule_codes column the up migration added. The data is
|
||||
-- preserved in paliad.event_deadline_rule_codes (just restored
|
||||
-- above), so dropping the column doesn't lose history.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS rule_codes;
|
||||
195
internal/db/migrations/092_drop_event_deadlines_tables.up.sql
Normal file
195
internal/db/migrations/092_drop_event_deadlines_tables.up.sql
Normal file
@@ -0,0 +1,195 @@
|
||||
-- t-paliad-199 / Fristen Phase 3 Slice 9 follow-up A — drop the legacy
|
||||
-- Pipeline-C source tables (paliad.event_deadlines +
|
||||
-- paliad.event_deadline_rule_codes) and the read-only trigger from
|
||||
-- mig 086, now that EventDeadlineService.Calculate has been rewritten
|
||||
-- to read from paliad.deadline_rules.
|
||||
--
|
||||
-- Lorenz's Slice 9 (t-paliad-195) deferred this drop because the
|
||||
-- legacy service still SELECTed event_deadlines.duration_value /
|
||||
-- duration_unit / timing / notes / alt_* / combine_op. Slice 9
|
||||
-- follow-up A refactors the service onto deadline_rules (the unified
|
||||
-- source-of-truth since Slice 3 / mig 085) and frees us to remove the
|
||||
-- old tables.
|
||||
--
|
||||
-- Sequencing — every step in this single migration is required for the
|
||||
-- drop to be safe:
|
||||
--
|
||||
-- 1. Snapshot both source tables into paliad.event_deadlines_pre_092
|
||||
-- + paliad.event_deadline_rule_codes_pre_092 (CREATE TABLE IF NOT
|
||||
-- EXISTS — idempotent re-run). The snapshots persist after the
|
||||
-- drop as audit anchors; the down migration restores from them.
|
||||
-- 2. ADD COLUMN rule_codes text[] to paliad.deadline_rules and
|
||||
-- backfill from paliad.event_deadline_rule_codes. Pipeline-C
|
||||
-- deadlines carry multi-code rules (e.g. R.198 / R.213 carry
|
||||
-- [RoP.029.a, RoP.030]) which don't fit deadline_rules.rule_code
|
||||
-- (singular text); mig 085 left rule_code NULL on the 77
|
||||
-- Pipeline-C rows. Without this backfill the drop would silently
|
||||
-- lose 72 RoP citations.
|
||||
-- 3. Hard assertion: every event_deadline_rule_codes row resolves to
|
||||
-- a deadline_rules row via the sequence_order = 1000 +
|
||||
-- event_deadlines.id convention from mig 085. If any row didn't
|
||||
-- land, fail loudly before dropping the source.
|
||||
-- 4. DROP TRIGGER + FUNCTION from mig 086 — orphan once the table is
|
||||
-- gone.
|
||||
-- 5. DROP TABLE paliad.event_deadline_rule_codes (FK side first).
|
||||
-- 6. DROP TABLE paliad.event_deadlines.
|
||||
-- 7. Final assertion: paliad.deadline_rules still carries >=77 active
|
||||
-- rows with trigger_event_id IS NOT NULL (the Slice 3 corpus must
|
||||
-- not have collapsed).
|
||||
--
|
||||
-- audit_reason wrapper at top — the mig 079 trigger on
|
||||
-- paliad.deadline_rules logs every row-level edit. The ALTER TABLE +
|
||||
-- UPDATE on rule_codes fires through that trigger, so the reason
|
||||
-- persists in paliad.deadline_rule_audit for forever-grade audit.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 092: drop paliad.event_deadlines + event_deadline_rule_codes after backfilling rule_codes into deadline_rules (t-paliad-199, Slice 9 follow-up A, design §3.E)',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Backup snapshots — full row copies so the down migration can
|
||||
-- rebuild both tables byte-identically. CREATE TABLE IF NOT EXISTS
|
||||
-- keeps the migration idempotent across reapplications; if the
|
||||
-- snapshot already exists from a prior aborted run, we re-use it.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.event_deadlines_pre_092 AS
|
||||
SELECT *, now() AS snapshotted_at
|
||||
FROM paliad.event_deadlines;
|
||||
|
||||
COMMENT ON TABLE paliad.event_deadlines_pre_092 IS
|
||||
'Snapshot of paliad.event_deadlines before mig 092 dropped it. '
|
||||
'Source-of-truth for the down migration; persists post-drop as the '
|
||||
'permanent audit record of the 77 Pipeline-C source rows that '
|
||||
'seeded paliad.deadline_rules via mig 085. Drop with a focused '
|
||||
'follow-up after Slice 9 is verified in prod (pair with '
|
||||
'paliad.deadline_rules_pre_091 cleanup).';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.event_deadline_rule_codes_pre_092 AS
|
||||
SELECT *, now() AS snapshotted_at
|
||||
FROM paliad.event_deadline_rule_codes;
|
||||
|
||||
COMMENT ON TABLE paliad.event_deadline_rule_codes_pre_092 IS
|
||||
'Snapshot of paliad.event_deadline_rule_codes before mig 092 dropped '
|
||||
'it. Restored by the down migration; persists post-drop as the '
|
||||
'permanent audit record of the legacy RoP citations attached to '
|
||||
'Pipeline-C deadlines (72 rows across 70 of 77 deadlines).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Add paliad.deadline_rules.rule_codes (text[]) and backfill it for
|
||||
-- the 77 Pipeline-C rules. Mig 085 set rule_code = NULL on every
|
||||
-- Pipeline-C row because deadline_rules.rule_code is singular and
|
||||
-- Pipeline-C deadlines can carry multiple citations. rule_codes
|
||||
-- holds the array form. Pipeline-A rules keep NULL here and continue
|
||||
-- using rule_code; this column is a Pipeline-C-only field today.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS rule_codes text[];
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.rule_codes IS
|
||||
'Array of legal-rule citations attached to this deadline, in '
|
||||
'render order. Pipeline-C rules (event-rooted, trigger_event_id IS '
|
||||
'NOT NULL) populate this column from the legacy '
|
||||
'paliad.event_deadline_rule_codes junction (mig 092 backfill); '
|
||||
'Pipeline-A rules use the singular rule_code column instead. NULL '
|
||||
'on Pipeline-A rules + on the 7 Pipeline-C deadlines that had no '
|
||||
'junction rows pre-mig.';
|
||||
|
||||
-- Aggregate junction rows into a text[] sorted by (sort_order,
|
||||
-- rule_code) — matches the legacy ORDER BY contract that
|
||||
-- EventDeadlineService.loadRuleCodes used.
|
||||
--
|
||||
-- Join key: the sequence_order = 1000 + event_deadlines.id convention
|
||||
-- mig 085 anchored. Every active event_deadlines.id has a corresponding
|
||||
-- deadline_rules row at sequence_order = 1000 + id; mig 085's hard
|
||||
-- assertion guarantees that.
|
||||
WITH agg AS (
|
||||
SELECT event_deadline_id,
|
||||
array_agg(rule_code ORDER BY sort_order, rule_code) AS codes
|
||||
FROM paliad.event_deadline_rule_codes
|
||||
GROUP BY event_deadline_id
|
||||
)
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET rule_codes = agg.codes
|
||||
FROM agg
|
||||
WHERE dr.trigger_event_id IS NOT NULL
|
||||
AND dr.sequence_order = 1000 + agg.event_deadline_id
|
||||
AND dr.rule_codes IS DISTINCT FROM agg.codes;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Hard assertion: every junction row landed on a deadline_rules row.
|
||||
-- Sums elements across all rule_codes arrays — if the count differs
|
||||
-- from the source junction count, some event_deadline_id failed to
|
||||
-- match any deadline_rules row (sequence_order convention broken).
|
||||
-- Fail loudly here BEFORE dropping the source.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_codes_src int;
|
||||
n_codes_target int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_codes_src
|
||||
FROM paliad.event_deadline_rule_codes;
|
||||
|
||||
SELECT COALESCE(SUM(array_length(rule_codes, 1)), 0) INTO n_codes_target
|
||||
FROM paliad.deadline_rules
|
||||
WHERE rule_codes IS NOT NULL;
|
||||
|
||||
RAISE NOTICE 'mig 092: junction rows=%, backfilled rule_codes elements=%',
|
||||
n_codes_src, n_codes_target;
|
||||
|
||||
IF n_codes_target < n_codes_src THEN
|
||||
RAISE EXCEPTION 'mig 092: rule_codes backfill missed % junction rows '
|
||||
'(source=%, target=%) — sequence_order = 1000 + ed.id '
|
||||
'convention broken? Aborting before drop.',
|
||||
n_codes_src - n_codes_target, n_codes_src, n_codes_target;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Drop the read-only trigger + function from mig 086. They're orphan
|
||||
-- once paliad.event_deadlines goes away — explicit drop documents
|
||||
-- that the wrapper's job is done, and keeps the symmetric reverse in
|
||||
-- the down migration cleanly readable.
|
||||
-- =============================================================================
|
||||
|
||||
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
|
||||
DROP FUNCTION IF EXISTS paliad.event_deadlines_readonly_trigger();
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. Drop the legacy tables. Order: junction first (it has a FK to
|
||||
-- event_deadlines), then the parent. Explicit ordering is clearer
|
||||
-- than relying on CASCADE and mirrors the down migration's CREATE
|
||||
-- sequence.
|
||||
-- =============================================================================
|
||||
|
||||
DROP TABLE IF EXISTS paliad.event_deadline_rule_codes;
|
||||
DROP TABLE IF EXISTS paliad.event_deadlines;
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. Final assertion: the unified Pipeline-C corpus is still intact.
|
||||
-- Mig 085 moved 77 active rows; future hand-edited Pipeline-C rules
|
||||
-- can only raise the count. A drop below 77 means the upstream
|
||||
-- deadline_rules data was clobbered while this migration ran and
|
||||
-- the deploy must abort.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_unified int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_unified
|
||||
FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id IS NOT NULL AND is_active = true;
|
||||
|
||||
RAISE NOTICE 'mig 092: post-drop Pipeline-C rule count = %', n_unified;
|
||||
|
||||
IF n_unified < 77 THEN
|
||||
RAISE EXCEPTION 'mig 092: Pipeline-C corpus collapsed — expected >=77 '
|
||||
'active deadline_rules with trigger_event_id IS NOT NULL, got %',
|
||||
n_unified;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,67 @@
|
||||
-- t-paliad-200 down — reverses 093_retire_litigation_category.up.sql.
|
||||
--
|
||||
-- Restores the 7 litigation-category paliad.proceeding_types rows from
|
||||
-- the _pre_093 snapshot, moves the 40 archived deadline_rules back onto
|
||||
-- their original proceeding_type_id values (and reverts
|
||||
-- lifecycle_state + is_active to their pre-093 values), then drops the
|
||||
-- _archived_litigation holding pt.
|
||||
--
|
||||
-- The snapshot tables themselves stay — they're the source of this
|
||||
-- rollback's data and a permanent audit artefact. A focused
|
||||
-- follow-up drops the snapshots once Slice 9 is verified in prod.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 093: restore litigation proceeding_types + un-archive the 40 Pipeline-A rules from pre-093 snapshots',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Restore the 7 litigation proceeding_types rows. ON CONFLICT (id)
|
||||
-- DO NOTHING — if a row somehow survived the up migration we don't
|
||||
-- clobber it.
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO paliad.proceeding_types
|
||||
(id, code, name, description, jurisdiction, category,
|
||||
default_color, sort_order, is_active, name_en, display_order)
|
||||
SELECT id, code, name, description, jurisdiction, category,
|
||||
default_color, sort_order, is_active, name_en, display_order
|
||||
FROM paliad.proceeding_types_pre_093
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Re-align the proceeding_types_id_seq if a SERIAL/IDENTITY column
|
||||
-- bumped past the restored ids. The pre-093 max was 7; the
|
||||
-- _archived_litigation INSERT in the up migration claimed a later id.
|
||||
-- Setting the seq to the max of the live table keeps future INSERTs
|
||||
-- safe regardless of order.
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('paliad.proceeding_types', 'id'),
|
||||
GREATEST(
|
||||
(SELECT COALESCE(MAX(id), 1) FROM paliad.proceeding_types),
|
||||
1
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Restore the 40 deadline_rules rows to their pre-093 state:
|
||||
-- proceeding_type_id, lifecycle_state, is_active, updated_at. The
|
||||
-- rule UUIDs are stable so we match on id. The mig 079 audit
|
||||
-- trigger captures these UPDATEs as the rollback record.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = snap.proceeding_type_id,
|
||||
lifecycle_state = snap.lifecycle_state,
|
||||
is_active = snap.is_active,
|
||||
updated_at = snap.updated_at
|
||||
FROM paliad.deadline_rules_pre_093 snap
|
||||
WHERE dr.id = snap.id;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Drop the _archived_litigation holding pt. Safe — step 2 moved all
|
||||
-- 40 rules off it. The CASCADE is a no-op (FK on rules has
|
||||
-- ON DELETE CASCADE, but there are zero rules to cascade).
|
||||
-- =============================================================================
|
||||
|
||||
DELETE FROM paliad.proceeding_types
|
||||
WHERE code = '_archived_litigation';
|
||||
247
internal/db/migrations/093_retire_litigation_category.up.sql
Normal file
247
internal/db/migrations/093_retire_litigation_category.up.sql
Normal file
@@ -0,0 +1,247 @@
|
||||
-- t-paliad-200 / Fristen Phase 3 Slice 9 follow-up B — retire the
|
||||
-- 'litigation' category from the rule corpus.
|
||||
--
|
||||
-- Lorenz's Slice 9 (t-paliad-195) deferred this drop because 40 active
|
||||
-- paliad.deadline_rules still pointed at the 7 litigation-category
|
||||
-- proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). Phase 3
|
||||
-- Slice 5 retired litigation codes from project-binding (mig 087/088);
|
||||
-- this migration retires them from the rule corpus.
|
||||
--
|
||||
-- Plan choice (audit-gated, paliadin-approved): archive-all-40 rather
|
||||
-- than the original re-parent plan. The audit found:
|
||||
--
|
||||
-- * 23 of 40 Pipeline-A litigation rules share their `code` with an
|
||||
-- existing fristenrechner rule on the proposed re-parent target
|
||||
-- (e.g. `inf.oral` exists on both INF and UPC_INF). Re-parenting
|
||||
-- would leave two rules with identical (proceeding_type_id, code),
|
||||
-- breaking the implicit per-proceeding rule_code identity contract
|
||||
-- keyed off by projection / search / rule_editor.
|
||||
-- * The fristenrechner-category rules are the production version:
|
||||
-- proper German names, legal_source pinned (UPC.RoP citations),
|
||||
-- full bilateral chains, intra-proceeding counterclaim handling
|
||||
-- via inf.def_to_ccr / rev.cc_inf / etc. The Pipeline-A rules are
|
||||
-- stubs: English-only, mostly NULL legal_source, duration_value=0
|
||||
-- for 28 of 40, no spawn_proceeding_type_id wiring.
|
||||
-- * 1 live deadline ("Lecker Frist", status=completed) points at
|
||||
-- Pipeline-A inf.rejoin/INF via paliad.deadlines.rule_id. Archive-
|
||||
-- not-delete preserves the FK.
|
||||
-- * 30 intra-litigation parent_id chains would be silently broken by
|
||||
-- piecemeal re-parenting. Archive-all preserves them.
|
||||
-- * FK on deadline_rules.proceeding_type_id is ON DELETE CASCADE →
|
||||
-- proceeding_types(id). A naive DELETE of the 7 litigation rows
|
||||
-- would cascade-delete all 40 rules AND break the live deadline's
|
||||
-- rule_id FK. Rules must be moved off the litigation pt ids before
|
||||
-- the litigation rows are dropped.
|
||||
--
|
||||
-- Surfaced for legal review at merge (commit body lists these so they
|
||||
-- don't get lost as the four open coverage questions Phase 3 leaves
|
||||
-- behind):
|
||||
--
|
||||
-- 1. inf.prelim (Preliminary Objection, RoP 19, 1 month) — not
|
||||
-- present on UPC_INF. Possible coverage gap for the fristenrechner
|
||||
-- ruleset; legal review to decide whether to add it.
|
||||
-- 2. inf.appeal / rev.appeal / ccr.appeal as cross-proceeding spawns
|
||||
-- into UPC_APP (2 months, UPC.RoP.220.1) — fristenrechner UPC_APP
|
||||
-- currently starts standalone with no spawn from UPC_INF/UPC_REV.
|
||||
-- Possible UX gap; the Pipeline-A versions had
|
||||
-- spawn_proceeding_type_id=NULL so they weren't functional
|
||||
-- spawns either.
|
||||
-- 3. ccr.amend / rev.amend (spawn rules) — superseded by
|
||||
-- inf.app_to_amend / rev.app_to_amend on UPC_INF / UPC_REV. Safe
|
||||
-- to drop.
|
||||
-- 4. zpo.klage / zpo.vertanz / zpo.klageerw / zpo.berufung — no UPC
|
||||
-- analogue; redundant with DE_INF / DE_INF_OLG / DE_INF_BGH and
|
||||
-- DE_NULL / DE_NULL_BGH. Safe to drop.
|
||||
--
|
||||
-- Sequencing — every step required for the drop to be safe:
|
||||
--
|
||||
-- 1. Snapshot paliad.proceeding_types and the 40 affected
|
||||
-- paliad.deadline_rules into _pre_093 audit tables.
|
||||
-- 2. Create a holding proceeding_type `_archived_litigation`
|
||||
-- (category='archived', is_active=false, jurisdiction='UPC') to
|
||||
-- home the archived rules and preserve their intra-set parent_id
|
||||
-- chains across the drop.
|
||||
-- 3. UPDATE all 40 rules: proceeding_type_id = archived_id,
|
||||
-- lifecycle_state='archived', is_active=false. The mig 079
|
||||
-- trigger captures every row in paliad.deadline_rule_audit.
|
||||
-- 4. DELETE the 7 litigation rows from paliad.proceeding_types
|
||||
-- (now safe — nothing references them).
|
||||
-- 5. Hard assertions: zero rules on litigation ids, zero litigation
|
||||
-- rows surviving, exactly 40 rules on the archive id.
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op (snapshots use CREATE TABLE IF
|
||||
-- NOT EXISTS; the archive pt INSERT uses ON CONFLICT DO NOTHING; the
|
||||
-- UPDATEs are guarded by lifecycle_state='archived' so they only fire
|
||||
-- once; the DELETE targets category='litigation' which becomes empty
|
||||
-- after first run).
|
||||
--
|
||||
-- audit_reason wrapper at top — the mig 079 trigger on
|
||||
-- paliad.deadline_rules logs every row-level edit. The UPDATE on all
|
||||
-- 40 rules fires through that trigger, so the reason persists in
|
||||
-- paliad.deadline_rule_audit for forever-grade audit.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 093: retire litigation category from rule corpus — archive 40 Pipeline-A rules under _archived_litigation pt, drop 7 litigation proceeding_types rows (t-paliad-200, Slice 9 follow-up B)',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Backup snapshots. CREATE TABLE IF NOT EXISTS keeps the migration
|
||||
-- idempotent across reapplications. Snapshots persist post-drop as
|
||||
-- the permanent audit anchor; the down migration restores from them.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.proceeding_types_pre_093 AS
|
||||
SELECT *, now() AS snapshotted_at
|
||||
FROM paliad.proceeding_types
|
||||
WHERE category = 'litigation';
|
||||
|
||||
COMMENT ON TABLE paliad.proceeding_types_pre_093 IS
|
||||
'Snapshot of the 7 litigation-category paliad.proceeding_types rows '
|
||||
'(INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL) before mig 093 dropped '
|
||||
'them. Source-of-truth for the down migration; persists post-drop '
|
||||
'as the permanent audit record of the Pipeline-A proceeding '
|
||||
'inventory. Drop with a focused follow-up after the Phase 3 cleanup '
|
||||
'is verified in prod.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_093 AS
|
||||
SELECT dr.*, now() AS snapshotted_at
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.category = 'litigation';
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rules_pre_093 IS
|
||||
'Snapshot of the 40 paliad.deadline_rules rows that pointed at '
|
||||
'litigation-category proceeding_types before mig 093 re-homed '
|
||||
'them under the _archived_litigation pt. Source-of-truth for the '
|
||||
'down migration; persists post-drop as the permanent audit record '
|
||||
'of the Pipeline-A rule corpus.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Create the holding proceeding_type `_archived_litigation`. Category
|
||||
-- is the new 'archived' bucket (non-fristenrechner, so it cannot be
|
||||
-- selected from any UI that filters category='fristenrechner', and
|
||||
-- the mig 088 trigger continues to reject project-binding to it).
|
||||
-- is_active=false so it doesn't appear in admin lists.
|
||||
--
|
||||
-- sort_order = 9999 to sit at the tail of any category sort. The
|
||||
-- INSERT is idempotent via ON CONFLICT (code) DO NOTHING.
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO paliad.proceeding_types
|
||||
(code, name, name_en, description, jurisdiction, category,
|
||||
default_color, sort_order, display_order, is_active)
|
||||
VALUES
|
||||
('_archived_litigation',
|
||||
'Archivierte Litigation-Regeln (Pipeline A)',
|
||||
'Archived litigation rules (Pipeline A)',
|
||||
'Holding proceeding_type for the 40 Pipeline-A litigation-category '
|
||||
'rules retired by mig 093 (t-paliad-200, Slice 9 follow-up B). Not '
|
||||
'selectable from any UI; preserves the rules + their 30 intra-set '
|
||||
'parent_id chains for audit, and keeps the FK valid for the one '
|
||||
'live deadline that still references inf.rejoin/INF.',
|
||||
'UPC',
|
||||
'archived',
|
||||
'#94a3b8',
|
||||
9999,
|
||||
9999,
|
||||
false)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Re-home all 40 rules to the archive pt and mark them archived.
|
||||
-- The mig 079 trigger requires a non-empty audit_reason for UPDATE;
|
||||
-- set_config above provides it. lifecycle_state='archived' +
|
||||
-- is_active=false means projection_service / fristenrechner /
|
||||
-- rule_editor filter them out by default. The intra-set parent_id
|
||||
-- chains (30 of them) are preserved verbatim — parent_id values
|
||||
-- point at the rule UUIDs which don't change.
|
||||
--
|
||||
-- Guard the UPDATE on lifecycle_state <> 'archived' so a second
|
||||
-- application of the migration is a no-op (the rules are already
|
||||
-- archived on the second run).
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (SELECT id FROM paliad.proceeding_types
|
||||
WHERE code = '_archived_litigation'),
|
||||
lifecycle_state = 'archived',
|
||||
is_active = false,
|
||||
updated_at = now()
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.category = 'litigation'
|
||||
AND dr.lifecycle_state <> 'archived';
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Drop the 7 litigation rows from paliad.proceeding_types. Nothing
|
||||
-- references them now: step 3 moved all 40 rules off; mig 087 moved
|
||||
-- every project off; the audit confirmed zero cross-category spawn /
|
||||
-- parent references. The FK is ON DELETE CASCADE but cascades zero
|
||||
-- rows at this point.
|
||||
-- =============================================================================
|
||||
|
||||
DELETE FROM paliad.proceeding_types
|
||||
WHERE category = 'litigation';
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. Hard assertions. Raise loudly if anything didn't land — this
|
||||
-- migration is not safe to leave half-applied because the litigation
|
||||
-- pt rows are gone and the rule corpus needs to be coherent.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_orphan_rules integer;
|
||||
v_lit_rows integer;
|
||||
v_archived integer;
|
||||
v_archive_id integer;
|
||||
BEGIN
|
||||
SELECT id INTO v_archive_id
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = '_archived_litigation';
|
||||
|
||||
IF v_archive_id IS NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 093: _archived_litigation proceeding_type missing after step 2';
|
||||
END IF;
|
||||
|
||||
-- No deadline_rules row still points at a litigation pt id (the
|
||||
-- pt rows themselves are gone, so the proper check is "no rule
|
||||
-- points at a row outside the surviving proceeding_types set").
|
||||
-- This collapses to: no rule has a NULL proceeding_type from the
|
||||
-- DELETE (the FK on rules → pt(id) is ON DELETE CASCADE; if we
|
||||
-- missed a rule it would have been cascade-deleted in step 4).
|
||||
-- Cross-check by counting rules that used to be on litigation pts:
|
||||
SELECT count(*) INTO v_lit_rows
|
||||
FROM paliad.proceeding_types
|
||||
WHERE category = 'litigation';
|
||||
IF v_lit_rows <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 093: % litigation proceeding_types rows survived the DELETE',
|
||||
v_lit_rows;
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_archived
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = v_archive_id;
|
||||
IF v_archived <> 40 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 093: expected 40 rules on _archived_litigation, got %',
|
||||
v_archived;
|
||||
END IF;
|
||||
|
||||
-- Belt-and-braces: every snapshot row matches a surviving rule on
|
||||
-- the archive pt by id. If any rule was cascade-deleted by a
|
||||
-- missed step, this raises.
|
||||
SELECT count(*) INTO v_orphan_rules
|
||||
FROM paliad.deadline_rules_pre_093 snap
|
||||
LEFT JOIN paliad.deadline_rules dr ON dr.id = snap.id
|
||||
WHERE dr.id IS NULL;
|
||||
IF v_orphan_rules <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 093: % rules from the pre-snapshot are missing from '
|
||||
'paliad.deadline_rules — cascade-delete leak',
|
||||
v_orphan_rules;
|
||||
END IF;
|
||||
END $$;
|
||||
32
internal/db/migrations/094_clientmatter_six_digit.down.sql
Normal file
32
internal/db/migrations/094_clientmatter_six_digit.down.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- mig 094 DOWN — restore the 7-digit CHECK and the snapshotted
|
||||
-- pre-clear client_number / matter_number values from
|
||||
-- paliad.projects_pre_094. Symmetric to the up migration.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 094 DOWN: restore 7-digit CHECK and pre-094 client_number/matter_number values from snapshot',
|
||||
true);
|
||||
|
||||
-- 1. Drop the 6-digit CHECKs.
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT projekte_client_number_check,
|
||||
DROP CONSTRAINT projekte_matter_number_check;
|
||||
|
||||
-- 2. Restore the original values from the snapshot. Only rows that
|
||||
-- existed at snapshot time are touched; rows added since stay as
|
||||
-- they were.
|
||||
UPDATE paliad.projects p
|
||||
SET client_number = s.client_number,
|
||||
matter_number = s.matter_number
|
||||
FROM paliad.projects_pre_094 s
|
||||
WHERE p.id = s.id;
|
||||
|
||||
-- 3. Re-add the legacy 7-digit CHECKs.
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projekte_client_number_check
|
||||
CHECK (client_number IS NULL OR client_number ~ '^[0-9]{7}$'),
|
||||
ADD CONSTRAINT projekte_matter_number_check
|
||||
CHECK (matter_number IS NULL OR matter_number ~ '^[0-9]{7}$');
|
||||
|
||||
-- 4. Drop the snapshot. The down migration is the only consumer.
|
||||
DROP TABLE IF EXISTS paliad.projects_pre_094;
|
||||
97
internal/db/migrations/094_clientmatter_six_digit.up.sql
Normal file
97
internal/db/migrations/094_clientmatter_six_digit.up.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- mig 094 — tighten paliad.projects.client_number + matter_number CHECK
|
||||
-- from 7-digit to 6-digit. The "7-Ziffern" rule in mig 018 was wrong;
|
||||
-- HLC's real Client/Matter format is 6 digits each (m's correction,
|
||||
-- 2026-05-17). The constraints carry the legacy 'projekte_*_check'
|
||||
-- name from before the table was renamed (mig 021), so the ALTER
|
||||
-- TABLE DROP / ADD has to use those names verbatim.
|
||||
--
|
||||
-- Existing rows: only test data (2 client_numbers, 1 matter_number),
|
||||
-- all 7-digit. They violate the new pattern, so we NULL them out
|
||||
-- before tightening — preserving the project rows themselves, just
|
||||
-- clearing the wrong-shaped billing identifiers. The rows are
|
||||
-- snapshotted in projects_pre_094 first so the down migration can
|
||||
-- restore them byte-identically.
|
||||
--
|
||||
-- audit_reason wrapper at top: the trigger on paliad.projects logs
|
||||
-- every row-level UPDATE; the message persists in the audit table as
|
||||
-- the permanent record of why those test values were cleared.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 094: clear test 7-digit client_number/matter_number values before tightening CHECK to 6-digit (HLC real format correction, 2026-05-17)',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Backup snapshot. Full row copy of every paliad.projects row that
|
||||
-- has either field populated. Idempotent via CREATE TABLE IF NOT
|
||||
-- EXISTS — re-running the migration after an aborted run re-uses
|
||||
-- the existing snapshot.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.projects_pre_094 AS
|
||||
SELECT *, now() AS snapshotted_at
|
||||
FROM paliad.projects
|
||||
WHERE client_number IS NOT NULL OR matter_number IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE paliad.projects_pre_094 IS
|
||||
'Snapshot of paliad.projects rows that had a client_number or '
|
||||
'matter_number set before mig 094 tightened the CHECK from '
|
||||
'7-digit to 6-digit. The 094 UPDATE NULL-ed those values out '
|
||||
'because they were leftover 7-digit test data. Persists as the '
|
||||
'permanent audit anchor; the down migration restores from it.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Clear the 7-digit test values. Only rows that already violate
|
||||
-- the new pattern are touched — anything that happens to already
|
||||
-- be 6 digits (none today, but the WHERE keeps the migration
|
||||
-- re-runnable after future inserts) is left alone.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.projects
|
||||
SET client_number = NULL
|
||||
WHERE client_number IS NOT NULL
|
||||
AND client_number !~ '^[0-9]{6}$';
|
||||
|
||||
UPDATE paliad.projects
|
||||
SET matter_number = NULL
|
||||
WHERE matter_number IS NOT NULL
|
||||
AND matter_number !~ '^[0-9]{6}$';
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Replace the legacy 7-digit CHECKs with 6-digit ones. The
|
||||
-- constraint names carry the pre-rename `projekte_*` prefix from
|
||||
-- mig 018; keep them stable so external audit tools that scan
|
||||
-- pg_constraint by name don't drift.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT projekte_client_number_check,
|
||||
DROP CONSTRAINT projekte_matter_number_check;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projekte_client_number_check
|
||||
CHECK (client_number IS NULL OR client_number ~ '^[0-9]{6}$'),
|
||||
ADD CONSTRAINT projekte_matter_number_check
|
||||
CHECK (matter_number IS NULL OR matter_number ~ '^[0-9]{6}$');
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Hard assertions. Any row that survived the UPDATE+ALTER must
|
||||
-- satisfy the new pattern; the count of cleared test rows must
|
||||
-- match the snapshot.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_violations int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_violations
|
||||
FROM paliad.projects
|
||||
WHERE (client_number IS NOT NULL AND client_number !~ '^[0-9]{6}$')
|
||||
OR (matter_number IS NOT NULL AND matter_number !~ '^[0-9]{6}$');
|
||||
|
||||
IF n_violations > 0 THEN
|
||||
RAISE EXCEPTION 'mig 094: % rows still violate the 6-digit pattern after UPDATE — should be 0', n_violations;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'mig 094: 6-digit CHECKs in place, all rows compliant';
|
||||
END $$;
|
||||
61
internal/db/migrations/095_fristen_gap_fill.down.sql
Normal file
61
internal/db/migrations/095_fristen_gap_fill.down.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- Reverses mig 095. Restores the 4 patched de_inf.* rows from
|
||||
-- paliad.deadline_rules_pre_095 and removes the 4 new rules
|
||||
-- (inf.prelim, rev.prelim, inf.appeal_spawn, rev.appeal_spawn).
|
||||
--
|
||||
-- The audit_reason is required by the mig 079 trigger for UPDATE +
|
||||
-- DELETE; set_config at top supplies it.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 095 (down): revert t-paliad-205 fristen gap-fill — restore de_inf.* patches from deadline_rules_pre_095, delete 4 new rules',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Delete the 4 new rules. Idempotent — if a rule is already missing
|
||||
-- the DELETE matches zero rows.
|
||||
-- =============================================================================
|
||||
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE code IN ('inf.prelim', 'rev.prelim',
|
||||
'inf.appeal_spawn', 'rev.appeal_spawn')
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Restore the 4 patched rows from the pre_095 snapshot. The snapshot
|
||||
-- captured the rows at first up-migration run; the restore copies
|
||||
-- each tracked field back. If the snapshot table doesn't exist (down
|
||||
-- run before up), the restore is a no-op.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_snap_exists boolean;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'deadline_rules_pre_095'
|
||||
) INTO v_snap_exists;
|
||||
|
||||
IF NOT v_snap_exists THEN
|
||||
RAISE NOTICE
|
||||
'mig 095 (down): snapshot table paliad.deadline_rules_pre_095 missing — nothing to restore';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET legal_source = snap.legal_source,
|
||||
is_court_set = snap.is_court_set,
|
||||
description = snap.description,
|
||||
updated_at = now()
|
||||
FROM paliad.deadline_rules_pre_095 snap
|
||||
WHERE dr.id = snap.id;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Drop the snapshot table so a re-applied up migration captures a
|
||||
-- fresh snapshot of the current state.
|
||||
-- =============================================================================
|
||||
|
||||
DROP TABLE IF EXISTS paliad.deadline_rules_pre_095;
|
||||
403
internal/db/migrations/095_fristen_gap_fill.up.sql
Normal file
403
internal/db/migrations/095_fristen_gap_fill.up.sql
Normal file
@@ -0,0 +1,403 @@
|
||||
-- t-paliad-205 / Fristen gap-fill — ingest curie's t-paliad-203 deltas
|
||||
-- as code. Source of truth for the deltas is
|
||||
-- docs/proposals/fristen-gap-fill-2026-05-18.md § 0.3 (m's decisions
|
||||
-- captured 2026-05-18, commit 0123d11).
|
||||
--
|
||||
-- Mig 093 (commit 40e49e8) retired the Pipeline-A litigation rule
|
||||
-- corpus and surfaced four open coverage questions for legal review.
|
||||
-- curie's proposal verified those questions and m signed off on:
|
||||
--
|
||||
-- * 4 new rules — preliminary-objection (RoP 19.1) on UPC_INF and
|
||||
-- UPC_REV, and merits-appeal spawn (RoP 220.1(a)) on the same two
|
||||
-- proceedings.
|
||||
-- * 4 polish PATCHes on the German civil-procedure rules — backfill
|
||||
-- legal_source on de_inf.klage, flip de_inf.erwidg to court-set
|
||||
-- with a §276 Abs.1 S.2 note, plus a defensive verify on
|
||||
-- de_inf.berufung.legal_source.
|
||||
--
|
||||
-- Final shape per the proposal § 0.3:
|
||||
--
|
||||
-- NEW
|
||||
-- inf.prelim UPC_INF parent=inf.soc 1mo RoP.019.1 flag=with_po optional
|
||||
-- rev.prelim UPC_REV parent=rev.app 1mo RoP.019.1 flag=with_po optional
|
||||
-- inf.appeal_spawn UPC_INF parent=inf.decision 2mo RoP.220.1.a (no flag, always) optional spawn → UPC_APP (id=11)
|
||||
-- rev.appeal_spawn UPC_REV parent=rev.decision 2mo RoP.220.1.a (no flag, always) optional spawn → UPC_APP (id=11)
|
||||
--
|
||||
-- PATCH
|
||||
-- de_inf.klage legal_source NULL → 'DE.ZPO.253'
|
||||
-- de_inf.anzeige no change (already 'DE.ZPO.276.1')
|
||||
-- de_inf.erwidg is_court_set false → true; set description with §276 Abs.1 S.2 note
|
||||
-- duration_value=6 weeks stays as the default-display value when no
|
||||
-- court order is yet attached.
|
||||
-- de_inf.berufung legal_source set to 'DE.ZPO.517' if still NULL (defensive verify)
|
||||
--
|
||||
-- The merits-appeal spawn rules unconditionally produce the 2-month
|
||||
-- appeal-window row once inf.decision / rev.decision is anchored
|
||||
-- (m's F2.3 decision: "appeal is always a possibility"). Visibility
|
||||
-- filtering for non-appealing projects is a frontend concern, not a
|
||||
-- rule-level flag (see proposal § 0.3 follow-up note).
|
||||
--
|
||||
-- The spawn_proceeding_type_id FK points at UPC_APP (id=11). t-paliad-204
|
||||
-- may rename the `code` string for that row but the integer id is stable;
|
||||
-- if id=11 ever moves, this migration's spawn rules still chain to the
|
||||
-- correct row.
|
||||
--
|
||||
-- Idempotency:
|
||||
-- * Backup snapshot `deadline_rules_pre_095` is CREATE TABLE IF NOT
|
||||
-- EXISTS, capturing the 4 patched rows at first run.
|
||||
-- * INSERTs use `WHERE NOT EXISTS` keyed on (proceeding_type_id, code,
|
||||
-- lifecycle_state='published') — there is no unique index on
|
||||
-- (proceeding_type_id, code) in paliad.deadline_rules (mig 093 left
|
||||
-- archived and published rows co-existing with identical codes), so
|
||||
-- ON CONFLICT is not available; WHERE NOT EXISTS is the equivalent
|
||||
-- idempotency guard.
|
||||
-- * UPDATEs are guarded by clauses that only fire when the row still
|
||||
-- has the old value (legal_source IS NULL, is_court_set = false).
|
||||
--
|
||||
-- audit_reason wrapper required by the mig 079 trigger for both UPDATE
|
||||
-- and INSERT (INSERT defaults to 'create' but we surface the t-paliad-205
|
||||
-- context anyway so deadline_rule_audit reads cleanly).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 095: t-paliad-205 fristen gap-fill — 4 new rules (inf.prelim, rev.prelim, inf.appeal_spawn, rev.appeal_spawn) + 4 patches on de_inf.* rules per docs/proposals/fristen-gap-fill-2026-05-18.md § 0.3',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Backup snapshot of the 4 rows the PATCHes touch. CREATE TABLE IF
|
||||
-- NOT EXISTS keeps this idempotent across reapplications. Snapshot
|
||||
-- persists post-patch as the audit anchor; the down migration
|
||||
-- restores from it.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_095 AS
|
||||
SELECT *, now() AS snapshotted_at
|
||||
FROM paliad.deadline_rules
|
||||
WHERE code IN ('de_inf.klage', 'de_inf.anzeige',
|
||||
'de_inf.erwidg', 'de_inf.berufung')
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rules_pre_095 IS
|
||||
'Snapshot of the 4 de_inf.* deadline_rules rows that mig 095 '
|
||||
'PATCHed (t-paliad-205). Source-of-truth for the down migration; '
|
||||
'persists post-patch as the permanent audit record. Drop with a '
|
||||
'focused follow-up after the gap-fill is verified in prod.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. New rules — preliminary objection on UPC_INF and UPC_REV
|
||||
-- (RoP 19.1, flag-gated `with_po`, 1 month from service of the
|
||||
-- Statement of Claim / Application for Revocation).
|
||||
--
|
||||
-- Anchor: parent_id on the existing root rule (inf.soc / rev.app),
|
||||
-- matching the chaining pattern used by inf.sod, inf.def_to_ccr,
|
||||
-- rev.defence, rev.app_to_amend. Idempotent via WHERE NOT EXISTS.
|
||||
--
|
||||
-- sequence_order=5 places the PO row before the SoD (sequence_order=10)
|
||||
-- in the per-proceeding timeline ordering, reflecting the 1-month
|
||||
-- statutory window beating the 3-month defence in calendar terms.
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state)
|
||||
SELECT
|
||||
8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE code = 'inf.soc'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'inf.prelim',
|
||||
'Vorab-Einrede (R. 19 VerfO)',
|
||||
'Preliminary Objection (RoP 19)',
|
||||
'Vorab-Einrede des Beklagten gegen Zuständigkeit, Verfahrenssprache (R.14) oder Spruchkörper-Zusammensetzung. Statutarische Frist von 1 Monat ab Zustellung der Klage; der UPC entscheidet typischerweise durch Beschluss vor der Zwischenverhandlung (R.19.7).',
|
||||
'defendant',
|
||||
'filing',
|
||||
1,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.019.1',
|
||||
'Innerhalb von 1 Monat ab Zustellung der Klage. Drei mögliche Gründe: (a) Zuständigkeit/Kompetenz, (b) Verfahrenssprache (R.14), (c) Spruchkörper.',
|
||||
'Within 1 month of service of the Statement of claim. Three available grounds: (a) jurisdiction/competence, (b) language (R.14), (c) panel composition.',
|
||||
5,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
'UPC.RoP.19.1',
|
||||
false,
|
||||
'{"flag":"with_po"}'::jsonb,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE code = 'inf.prelim'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state)
|
||||
SELECT
|
||||
9,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE code = 'rev.app'
|
||||
AND proceeding_type_id = 9
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'rev.prelim',
|
||||
'Vorab-Einrede (R. 19 i.V.m. R. 46 VerfO)',
|
||||
'Preliminary Objection (RoP 19 in conjunction with RoP 46)',
|
||||
'Vorab-Einrede des Beklagten (Patentinhaber) im Nichtigkeitsverfahren. R.46 erklärt R.19 für Nichtigkeitsverfahren mutatis mutandis anwendbar; statutarische Frist von 1 Monat ab Zustellung der Nichtigkeitsklage.',
|
||||
'defendant',
|
||||
'filing',
|
||||
1,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.019.1',
|
||||
'Innerhalb von 1 Monat ab Zustellung der Nichtigkeitsklage. R.46 macht R.19 mutatis mutandis für Nichtigkeitsverfahren anwendbar; in der Praxis vor allem Verfahrenssprache und Spruchkörper-Zusammensetzung als Gründe.',
|
||||
'Within 1 month of service of the Application for Revocation. R.46 makes R.19 apply mutatis mutandis to revocation actions; in practice the main grounds are language and panel composition.',
|
||||
5,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
'UPC.RoP.19.1',
|
||||
false,
|
||||
'{"flag":"with_po"}'::jsonb,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE code = 'rev.prelim'
|
||||
AND proceeding_type_id = 9
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. New rules — merits-appeal spawn on UPC_INF and UPC_REV
|
||||
-- (RoP 220.1(a), 2 months from service of the final decision, always
|
||||
-- fires once the decision is anchored). spawn_proceeding_type_id=11
|
||||
-- is UPC_APP; the spawn renders as an entry point into the appeal
|
||||
-- proceeding which already has app.notice / app.grounds as root
|
||||
-- rules.
|
||||
--
|
||||
-- No condition_expr — m's F2.3 decision: "the appeal deadline should
|
||||
-- always be triggered by a decision … appeal is always a possibility".
|
||||
-- Visibility filtering on the frontend is the right place to hide
|
||||
-- appeals on projects where no appeal is contemplated.
|
||||
--
|
||||
-- sequence_order=80 places the spawn row after inf.cost_app (70)
|
||||
-- and rev.decision's tail in the per-proceeding ordering.
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state)
|
||||
SELECT
|
||||
8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE code = 'inf.decision'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'inf.appeal_spawn',
|
||||
'Berufung gegen Endentscheidung',
|
||||
'Appeal against final decision',
|
||||
'Berufung gegen die Endentscheidung nach R.118. Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren).',
|
||||
'both',
|
||||
'filing',
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.220.1.a',
|
||||
'Innerhalb von 2 Monaten ab Zustellung der Endentscheidung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
|
||||
'Within 2 months of service of the final decision lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
|
||||
80,
|
||||
true,
|
||||
11,
|
||||
'Berufungsverfahren öffnen',
|
||||
true,
|
||||
'UPC.RoP.220.1',
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE code = 'inf.appeal_spawn'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state)
|
||||
SELECT
|
||||
9,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE code = 'rev.decision'
|
||||
AND proceeding_type_id = 9
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'rev.appeal_spawn',
|
||||
'Berufung gegen Endentscheidung (Nichtigkeit)',
|
||||
'Appeal against final decision (revocation)',
|
||||
'Berufung gegen die Endentscheidung im Nichtigkeitsverfahren nach R.118. Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)). Bei with_cci-Konstellationen (Verletzungswiderklage) deckt eine R.118-Entscheidung beide Streitgegenstände ab und erzeugt ein gemeinsames Berufungsfenster.',
|
||||
'both',
|
||||
'filing',
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.220.1.a',
|
||||
'Innerhalb von 2 Monaten ab Zustellung der Endentscheidung Berufungsschrift einreichen (R.224.1(a)). Bei Verletzungswiderklage (with_cci) ein gemeinsames Fenster.',
|
||||
'Within 2 months of service of the final decision lodge the Statement of appeal (R.224.1(a)). Where a counterclaim for infringement was raised (with_cci) the appeal window covers both parts.',
|
||||
80,
|
||||
true,
|
||||
11,
|
||||
'Berufungsverfahren öffnen',
|
||||
true,
|
||||
'UPC.RoP.220.1',
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE code = 'rev.appeal_spawn'
|
||||
AND proceeding_type_id = 9
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. PATCHes on existing rows. Each UPDATE is guarded by a WHERE clause
|
||||
-- that only fires when the row still has the old value — re-running
|
||||
-- the migration is a no-op once the first run has applied.
|
||||
-- =============================================================================
|
||||
|
||||
-- 4.1 de_inf.klage: legal_source NULL → 'DE.ZPO.253'
|
||||
UPDATE paliad.deadline_rules
|
||||
SET legal_source = 'DE.ZPO.253',
|
||||
updated_at = now()
|
||||
WHERE code = 'de_inf.klage'
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
AND legal_source IS NULL;
|
||||
|
||||
-- 4.2 de_inf.anzeige: no change — verified DE.ZPO.276.1 already correct
|
||||
-- (proposal § 4.2; intentional no-op to make the audit log complete).
|
||||
|
||||
-- 4.3 de_inf.erwidg: flip is_court_set true; set description with §276
|
||||
-- Abs.1 S.2 note. Keep duration_value=6, duration_unit='weeks' as
|
||||
-- the default-display value when no court order is yet attached
|
||||
-- (per § 0.3 — fristenrechner renders the 6-week heuristic until
|
||||
-- the user enters the actual court-set date).
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
description = 'Gericht setzt eine Frist von mindestens zwei Wochen ab Verteidigungsanzeige (§276 Abs. 1 S. 2 ZPO).',
|
||||
updated_at = now()
|
||||
WHERE code = 'de_inf.erwidg'
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
AND is_court_set = false;
|
||||
|
||||
-- 4.4 de_inf.berufung: defensive verify — set legal_source to
|
||||
-- 'DE.ZPO.517' only if currently NULL. Production value is already
|
||||
-- 'DE.ZPO.517' per the proposal § 4.4 verification, so this is a
|
||||
-- no-op in prod; preserved here as a belt-and-braces guard against
|
||||
-- a staging snapshot where the field was never backfilled.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET legal_source = 'DE.ZPO.517',
|
||||
updated_at = now()
|
||||
WHERE code = 'de_inf.berufung'
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
AND legal_source IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. Hard assertions. The migration is not safe to leave half-applied —
|
||||
-- if any of the new rules failed to insert, or the de_inf.erwidg
|
||||
-- flip didn't land, the fristenrechner corpus is inconsistent.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_new_rules integer;
|
||||
v_court_set integer;
|
||||
v_appeal_ids integer;
|
||||
v_klage_src text;
|
||||
BEGIN
|
||||
-- 5.1 All four new rules exist and are active+published
|
||||
SELECT count(*) INTO v_new_rules
|
||||
FROM paliad.deadline_rules
|
||||
WHERE code IN ('inf.prelim', 'rev.prelim',
|
||||
'inf.appeal_spawn', 'rev.appeal_spawn')
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
IF v_new_rules <> 4 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 095: expected 4 new active+published rules, got %',
|
||||
v_new_rules;
|
||||
END IF;
|
||||
|
||||
-- 5.2 de_inf.erwidg is now court-set
|
||||
SELECT count(*) INTO v_court_set
|
||||
FROM paliad.deadline_rules
|
||||
WHERE code = 'de_inf.erwidg'
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
AND is_court_set = true;
|
||||
IF v_court_set <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 095: expected de_inf.erwidg to be court-set after patch, got % matching rows',
|
||||
v_court_set;
|
||||
END IF;
|
||||
|
||||
-- 5.3 Both spawn rules reference an existing proceeding_type id=11
|
||||
SELECT count(*) INTO v_appeal_ids
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.spawn_proceeding_type_id
|
||||
WHERE dr.code IN ('inf.appeal_spawn', 'rev.appeal_spawn')
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.is_active = true
|
||||
AND pt.id = 11;
|
||||
IF v_appeal_ids <> 2 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 095: expected both appeal_spawn rules to chain to proceeding_type id=11, got % matching rows',
|
||||
v_appeal_ids;
|
||||
END IF;
|
||||
|
||||
-- 5.4 de_inf.klage now has a legal_source (we just set it, or it was
|
||||
-- already set — either way it must not be NULL after this mig)
|
||||
SELECT legal_source INTO v_klage_src
|
||||
FROM paliad.deadline_rules
|
||||
WHERE code = 'de_inf.klage'
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true;
|
||||
IF v_klage_src IS NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 095: de_inf.klage.legal_source is still NULL after patch';
|
||||
END IF;
|
||||
END $$;
|
||||
99
internal/db/migrations/096_proceeding_code_rename.down.sql
Normal file
99
internal/db/migrations/096_proceeding_code_rename.down.sql
Normal file
@@ -0,0 +1,99 @@
|
||||
-- Reverses mig 096. Restores the original UPPER_SNAKE codes on
|
||||
-- paliad.proceeding_types + paliad.event_category_concepts, drops the
|
||||
-- new upc.ccr.cfi row, removes the shape CHECK, refreshes the
|
||||
-- deadline_search materialized view, then drops the snapshot table.
|
||||
--
|
||||
-- audit_reason wrapper required by the mig 079 audit trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 096 (down): revert t-paliad-206 proceeding-code rename — restore UPPER_SNAKE codes from proceeding_types_pre_096, delete upc.ccr.cfi peer, drop shape CHECK',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Drop the shape CHECK first so the UPPER_SNAKE restores don't trip it.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP CONSTRAINT IF EXISTS paliad_proceeding_code_shape;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Delete the upc.ccr.cfi peer. The down restores the pre-096 state, which
|
||||
-- didn't have this row. If the row is already missing, the DELETE
|
||||
-- matches zero — idempotent.
|
||||
-- =============================================================================
|
||||
|
||||
DELETE FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.ccr.cfi';
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Restore proceeding_types.code from the pre_096 snapshot. The snapshot
|
||||
-- captured the rows at first up-migration run; if the table is missing
|
||||
-- (down run before up), the restore is a no-op.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_snap_exists boolean;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'proceeding_types_pre_096'
|
||||
) INTO v_snap_exists;
|
||||
|
||||
IF NOT v_snap_exists THEN
|
||||
RAISE NOTICE
|
||||
'mig 096 (down): snapshot table paliad.proceeding_types_pre_096 missing — nothing to restore';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
UPDATE paliad.proceeding_types pt
|
||||
SET code = snap.code
|
||||
FROM paliad.proceeding_types_pre_096 snap
|
||||
WHERE pt.id = snap.id
|
||||
AND pt.code <> snap.code;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Revert soft references on event_category_concepts.proceeding_type_code
|
||||
-- by running the inverse mapping. Symmetric with §4 of the up migration.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_INF' WHERE proceeding_type_code = 'upc.inf.cfi';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_REV' WHERE proceeding_type_code = 'upc.rev.cfi';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_PI' WHERE proceeding_type_code = 'upc.pi.cfi';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_APP' WHERE proceeding_type_code = 'upc.apl.merits';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_DAMAGES' WHERE proceeding_type_code = 'upc.dmgs.cfi';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_DISCOVERY' WHERE proceeding_type_code = 'upc.disc.cfi';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_COST_APPEAL' WHERE proceeding_type_code = 'upc.apl.cost';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_APP_ORDERS' WHERE proceeding_type_code = 'upc.apl.order';
|
||||
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_INF' WHERE proceeding_type_code = 'de.inf.lg';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_INF_OLG' WHERE proceeding_type_code = 'de.inf.olg';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_INF_BGH' WHERE proceeding_type_code = 'de.inf.bgh';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_NULL' WHERE proceeding_type_code = 'de.null.bpatg';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_NULL_BGH' WHERE proceeding_type_code = 'de.null.bgh';
|
||||
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'EP_GRANT' WHERE proceeding_type_code = 'epa.grant.exa';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'EPA_OPP' WHERE proceeding_type_code = 'epa.opp.opd';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'EPA_APP' WHERE proceeding_type_code = 'epa.opp.boa';
|
||||
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DPMA_OPP' WHERE proceeding_type_code = 'dpma.opp.dpma';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DPMA_BPATG_BESCHWERDE' WHERE proceeding_type_code = 'dpma.appeal.bpatg';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DPMA_BGH_RB' WHERE proceeding_type_code = 'dpma.appeal.bgh';
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. Refresh deadline_search so the reverted proceeding_code strings
|
||||
-- repopulate the materialized view.
|
||||
-- =============================================================================
|
||||
|
||||
REFRESH MATERIALIZED VIEW paliad.deadline_search;
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. Drop the snapshot table so a re-applied up migration captures a
|
||||
-- fresh snapshot of the current state.
|
||||
-- =============================================================================
|
||||
|
||||
DROP TABLE IF EXISTS paliad.proceeding_types_pre_096;
|
||||
226
internal/db/migrations/096_proceeding_code_rename.up.sql
Normal file
226
internal/db/migrations/096_proceeding_code_rename.up.sql
Normal file
@@ -0,0 +1,226 @@
|
||||
-- t-paliad-206 / proceeding-code rename — replace the historical
|
||||
-- UPPER_SNAKE proceeding codes with the lowercase dot-separated
|
||||
-- taxonomy ratified by m on 2026-05-18 (see
|
||||
-- docs/design-proceeding-code-taxonomy-2026-05-18.md).
|
||||
--
|
||||
-- IDs are stable. Only the `code` STRING changes. FKs
|
||||
-- (deadline_rules.proceeding_type_id, projects.proceeding_type_id,
|
||||
-- deadline_rules.spawn_proceeding_type_id) reference IDs, so the
|
||||
-- existing rule corpus and spawn wiring continue to work unchanged
|
||||
-- (incl. mig 095's spawn_proceeding_type_id=11 which becomes
|
||||
-- 'upc.apl.merits' after this migration).
|
||||
--
|
||||
-- Soft references on `code` (text column on event_category_concepts) are
|
||||
-- updated row-for-row to keep the soft join through proceeding_types.code
|
||||
-- resolving.
|
||||
--
|
||||
-- The materialized view paliad.deadline_search projects pt.code as
|
||||
-- proceeding_code; mig 096 REFRESHes it at the bottom so the new codes
|
||||
-- show up in search results immediately.
|
||||
--
|
||||
-- Idempotent:
|
||||
-- * UPDATEs are guarded by `WHERE code = '<OLD>'`. Re-running after a
|
||||
-- successful first apply is a no-op.
|
||||
-- * INSERT of upc.ccr.cfi uses `WHERE NOT EXISTS` keyed on the new
|
||||
-- code (bohr noted in t-paliad-205 that a UNIQUE constraint on the
|
||||
-- code column is not present, hence WHERE NOT EXISTS rather than
|
||||
-- ON CONFLICT).
|
||||
-- * CHECK constraint is dropped-then-recreated under the same name
|
||||
-- (paliad_proceeding_code_shape) so reapplication doesn't error.
|
||||
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
|
||||
--
|
||||
-- audit_reason wrapper required by the mig 079 audit trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 096: t-paliad-206 proceeding-code rename — lowercase dot-separated taxonomy + new upc.ccr.cfi illustrative peer; see docs/design-proceeding-code-taxonomy-2026-05-18.md',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Backup snapshot of paliad.proceeding_types BEFORE the rename. The
|
||||
-- rename is forward-only in code (the Go + frontend sweeps reference
|
||||
-- the new strings) but the DB snapshot is the audit anchor and the
|
||||
-- source for the down migration.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.proceeding_types_pre_096 AS
|
||||
SELECT *, now() AS snapshotted_at
|
||||
FROM paliad.proceeding_types;
|
||||
|
||||
COMMENT ON TABLE paliad.proceeding_types_pre_096 IS
|
||||
'Snapshot of paliad.proceeding_types taken before mig 096 renamed '
|
||||
'the `code` strings to the lowercase dot-separated taxonomy '
|
||||
'(t-paliad-206, 2026-05-18). Source-of-truth for the down '
|
||||
'migration; persists post-rename as the permanent audit record.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Drop any prior shape CHECK so we can recreate it post-rename. The
|
||||
-- constraint name is stable so reapplication idempotently drops it.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP CONSTRAINT IF EXISTS paliad_proceeding_code_shape;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. The 19 renames. Order-independent — every UPDATE is guarded by
|
||||
-- `WHERE code = '<OLD>'` so re-application is a no-op. id values in
|
||||
-- the trailing comment for cross-reference with the design doc.
|
||||
-- =============================================================================
|
||||
|
||||
-- UPC
|
||||
UPDATE paliad.proceeding_types SET code = 'upc.inf.cfi' WHERE code = 'UPC_INF'; -- id=8
|
||||
UPDATE paliad.proceeding_types SET code = 'upc.rev.cfi' WHERE code = 'UPC_REV'; -- id=9
|
||||
UPDATE paliad.proceeding_types SET code = 'upc.pi.cfi' WHERE code = 'UPC_PI'; -- id=10
|
||||
UPDATE paliad.proceeding_types SET code = 'upc.apl.merits' WHERE code = 'UPC_APP'; -- id=11
|
||||
UPDATE paliad.proceeding_types SET code = 'upc.dmgs.cfi' WHERE code = 'UPC_DAMAGES'; -- id=17
|
||||
UPDATE paliad.proceeding_types SET code = 'upc.disc.cfi' WHERE code = 'UPC_DISCOVERY'; -- id=18
|
||||
UPDATE paliad.proceeding_types SET code = 'upc.apl.cost' WHERE code = 'UPC_COST_APPEAL';-- id=19
|
||||
UPDATE paliad.proceeding_types SET code = 'upc.apl.order' WHERE code = 'UPC_APP_ORDERS'; -- id=20
|
||||
|
||||
-- DE
|
||||
UPDATE paliad.proceeding_types SET code = 'de.inf.lg' WHERE code = 'DE_INF'; -- id=12
|
||||
UPDATE paliad.proceeding_types SET code = 'de.inf.olg' WHERE code = 'DE_INF_OLG'; -- id=25
|
||||
UPDATE paliad.proceeding_types SET code = 'de.inf.bgh' WHERE code = 'DE_INF_BGH'; -- id=26
|
||||
UPDATE paliad.proceeding_types SET code = 'de.null.bpatg' WHERE code = 'DE_NULL'; -- id=13
|
||||
UPDATE paliad.proceeding_types SET code = 'de.null.bgh' WHERE code = 'DE_NULL_BGH'; -- id=27
|
||||
|
||||
-- EPA
|
||||
UPDATE paliad.proceeding_types SET code = 'epa.grant.exa' WHERE code = 'EP_GRANT'; -- id=16
|
||||
UPDATE paliad.proceeding_types SET code = 'epa.opp.opd' WHERE code = 'EPA_OPP'; -- id=14
|
||||
UPDATE paliad.proceeding_types SET code = 'epa.opp.boa' WHERE code = 'EPA_APP'; -- id=15
|
||||
|
||||
-- DPMA
|
||||
UPDATE paliad.proceeding_types SET code = 'dpma.opp.dpma' WHERE code = 'DPMA_OPP'; -- id=28
|
||||
UPDATE paliad.proceeding_types SET code = 'dpma.appeal.bpatg' WHERE code = 'DPMA_BPATG_BESCHWERDE';-- id=29
|
||||
UPDATE paliad.proceeding_types SET code = 'dpma.appeal.bgh' WHERE code = 'DPMA_BGH_RB'; -- id=30
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Update soft references on event_category_concepts.proceeding_type_code.
|
||||
-- Same OLD→NEW table as above; the column has a UNIQUE NULLS NOT
|
||||
-- DISTINCT constraint on (event_category_id, concept_id, proceeding_type_code)
|
||||
-- but no row has the NEW string yet so the UPDATEs cannot collide.
|
||||
-- =============================================================================
|
||||
|
||||
-- UPC
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.inf.cfi' WHERE proceeding_type_code = 'UPC_INF';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.rev.cfi' WHERE proceeding_type_code = 'UPC_REV';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.pi.cfi' WHERE proceeding_type_code = 'UPC_PI';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.apl.merits' WHERE proceeding_type_code = 'UPC_APP';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.dmgs.cfi' WHERE proceeding_type_code = 'UPC_DAMAGES';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.disc.cfi' WHERE proceeding_type_code = 'UPC_DISCOVERY';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.apl.cost' WHERE proceeding_type_code = 'UPC_COST_APPEAL';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.apl.order' WHERE proceeding_type_code = 'UPC_APP_ORDERS';
|
||||
|
||||
-- DE
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.inf.lg' WHERE proceeding_type_code = 'DE_INF';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.inf.olg' WHERE proceeding_type_code = 'DE_INF_OLG';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.inf.bgh' WHERE proceeding_type_code = 'DE_INF_BGH';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.null.bpatg' WHERE proceeding_type_code = 'DE_NULL';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.null.bgh' WHERE proceeding_type_code = 'DE_NULL_BGH';
|
||||
|
||||
-- EPA
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'epa.grant.exa' WHERE proceeding_type_code = 'EP_GRANT';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'epa.opp.opd' WHERE proceeding_type_code = 'EPA_OPP';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'epa.opp.boa' WHERE proceeding_type_code = 'EPA_APP';
|
||||
|
||||
-- DPMA
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'dpma.opp.dpma' WHERE proceeding_type_code = 'DPMA_OPP';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'dpma.appeal.bpatg' WHERE proceeding_type_code = 'DPMA_BPATG_BESCHWERDE';
|
||||
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'dpma.appeal.bgh' WHERE proceeding_type_code = 'DPMA_BGH_RB';
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. Insert the new illustrative peer `upc.ccr.cfi`. is_active=true so it
|
||||
-- surfaces in the determinator + dropdowns; no rules attached.
|
||||
-- proceeding_mapping.go routes cascade hits on this code back to
|
||||
-- upc.inf.cfi (id=8) with the with_ccr default flag — see design doc S1.
|
||||
--
|
||||
-- WHERE NOT EXISTS gates the insert on the new code so re-application
|
||||
-- is a no-op even though there's no UNIQUE constraint on (code).
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO paliad.proceeding_types
|
||||
(code, category, jurisdiction, is_active, name, name_en, description)
|
||||
SELECT
|
||||
'upc.ccr.cfi',
|
||||
'fristenrechner',
|
||||
'UPC',
|
||||
true,
|
||||
'Widerklage auf Nichtigkeit',
|
||||
'Counterclaim for Revocation',
|
||||
'Illustrativer Peer von upc.inf.cfi für Widerklagen auf Nichtigkeit. Regeln liegen auf upc.inf.cfi (with_ccr=true); der Fristenrechner leitet bei Auswahl dorthin weiter. Keine eigenen Fristregeln.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.ccr.cfi');
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. CHECK constraint on the code shape. Active rows must conform to the
|
||||
-- new lowercase dot-separated form; the carve-out for
|
||||
-- `_archived_litigation` keeps the Pipeline-A bucket addressable.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD CONSTRAINT paliad_proceeding_code_shape
|
||||
CHECK (
|
||||
code ~ '^[a-z]+\.[a-z]+\.[a-z]+$'
|
||||
OR code ~ '^_archived_'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. Refresh the deadline_search materialized view so search hits return
|
||||
-- the new proceeding_code strings immediately.
|
||||
-- =============================================================================
|
||||
|
||||
REFRESH MATERIALIZED VIEW paliad.deadline_search;
|
||||
|
||||
-- =============================================================================
|
||||
-- 8. Hard assertions. Half-applied migrations would leave the rule corpus
|
||||
-- inconsistent with the new shape; assert every active fristenrechner
|
||||
-- code conforms and that no old codes leak.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_new_shape integer;
|
||||
v_old_codes integer;
|
||||
v_ccr_row integer;
|
||||
BEGIN
|
||||
-- 8.1 Every active fristenrechner row matches the new shape regex.
|
||||
-- 20 = 19 renamed rows + 1 newly inserted upc.ccr.cfi. The check
|
||||
-- uses >= so an additional row added in a follow-up migration
|
||||
-- doesn't trip the assertion.
|
||||
SELECT count(*) INTO v_new_shape
|
||||
FROM paliad.proceeding_types
|
||||
WHERE category = 'fristenrechner'
|
||||
AND is_active = true
|
||||
AND code ~ '^[a-z]+\.[a-z]+\.[a-z]+$';
|
||||
IF v_new_shape < 20 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 096: expected >= 20 active fristenrechner rows on the new shape, got %',
|
||||
v_new_shape;
|
||||
END IF;
|
||||
|
||||
-- 8.2 No old UPPER_SNAKE codes remain on any row.
|
||||
SELECT count(*) INTO v_old_codes
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code LIKE 'UPC\_%' ESCAPE '\'
|
||||
OR code LIKE 'DE\_%' ESCAPE '\'
|
||||
OR code LIKE 'EPA\_%' ESCAPE '\'
|
||||
OR code LIKE 'EP\_%' ESCAPE '\'
|
||||
OR code LIKE 'DPMA\_%' ESCAPE '\';
|
||||
IF v_old_codes <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 096: expected 0 old UPPER_SNAKE codes after rename, got %',
|
||||
v_old_codes;
|
||||
END IF;
|
||||
|
||||
-- 8.3 The new ccr peer exists and is active.
|
||||
SELECT count(*) INTO v_ccr_row
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.ccr.cfi'
|
||||
AND is_active = true;
|
||||
IF v_ccr_row <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 096: expected 1 active upc.ccr.cfi row, got %',
|
||||
v_ccr_row;
|
||||
END IF;
|
||||
END $$;
|
||||
59
internal/db/migrations/097_legal_citation_backfill.down.sql
Normal file
59
internal/db/migrations/097_legal_citation_backfill.down.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- Reverses mig 097. Restores rule_code + legal_source on every row
|
||||
-- touched by the backfill (and the rev.defence normalization) from the
|
||||
-- paliad.deadline_rules_pre_097 snapshot, refreshes the deadline_search
|
||||
-- materialized view, then drops the snapshot.
|
||||
--
|
||||
-- audit_reason wrapper required by the mig 079 audit trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 097 (down): revert t-paliad-210 legal-citation backfill — restore rule_code/legal_source from deadline_rules_pre_097 snapshot',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Restore rule_code + legal_source from the pre_097 snapshot for every
|
||||
-- row whose current values diverge from the snapshot. Symmetric across
|
||||
-- the § 1 / § 2 / § 3 backfills and the § 5 rev.defence normalization
|
||||
-- in one pass. If the snapshot table is missing (down run before up),
|
||||
-- the restore is a no-op.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_snap_exists boolean;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'deadline_rules_pre_097'
|
||||
) INTO v_snap_exists;
|
||||
|
||||
IF NOT v_snap_exists THEN
|
||||
RAISE NOTICE
|
||||
'mig 097 (down): snapshot table paliad.deadline_rules_pre_097 missing — nothing to restore';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET rule_code = snap.rule_code,
|
||||
legal_source = snap.legal_source
|
||||
FROM paliad.deadline_rules_pre_097 snap
|
||||
WHERE dr.id = snap.id
|
||||
AND (dr.rule_code IS DISTINCT FROM snap.rule_code
|
||||
OR dr.legal_source IS DISTINCT FROM snap.legal_source);
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Refresh deadline_search so the reverted rule_code / legal_source
|
||||
-- values repopulate the materialized view.
|
||||
-- =============================================================================
|
||||
|
||||
REFRESH MATERIALIZED VIEW paliad.deadline_search;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Drop the snapshot so a re-applied up migration captures a fresh
|
||||
-- snapshot of the current state.
|
||||
-- =============================================================================
|
||||
|
||||
DROP TABLE IF EXISTS paliad.deadline_rules_pre_097;
|
||||
684
internal/db/migrations/097_legal_citation_backfill.up.sql
Normal file
684
internal/db/migrations/097_legal_citation_backfill.up.sql
Normal file
@@ -0,0 +1,684 @@
|
||||
-- t-paliad-210 / legal-citation backfill — apply huygens's HIGH/MED
|
||||
-- proposals from docs/proposals/legal-citation-backfill-2026-05-18.md
|
||||
-- (commit 391be09) PLUS m's 2026-05-18 FLAG walk-through (paliadin/head
|
||||
-- instruction-msg 2002). Scope grew from the original brief: m approved
|
||||
-- filling almost every category, with only 3 FLAG-J rows left NULL.
|
||||
--
|
||||
-- Touches (in 8 buckets, ~135 rows):
|
||||
--
|
||||
-- § 1 Easy wins — 6 rows. rule_code only. The 2
|
||||
-- § 123 PatG twins (Wiedereinsetzung)
|
||||
-- move into the FLAG-A dedup bucket
|
||||
-- below; not filled here.
|
||||
--
|
||||
-- § 2 HIGH/MED proceeding-typed — 15 rows. rule_code + legal_source.
|
||||
--
|
||||
-- § 3 HIGH/MED orphans — 47 rows. rule_code + legal_source.
|
||||
-- For UPC rows also rule_codes[]
|
||||
-- normalized to ARRAY[rule_code].
|
||||
-- Excludes 3 archive-dest dup rows
|
||||
-- that are filled via the canonical
|
||||
-- in § 4 instead (5c0508f4 /
|
||||
-- 791fd0f7 / d886f46f).
|
||||
--
|
||||
-- § 4 FLAG-A dedup (clean only) — 3 canonical fills + 3 archive
|
||||
-- flips. Only sets where the
|
||||
-- duplicate rows share an existing
|
||||
-- rule_codes[] value (or both are
|
||||
-- NULL) are deduped:
|
||||
-- * 2× "Wiedereinsetzungsantrag
|
||||
-- § 123 PatG" — canonical
|
||||
-- b588fa64 (lowest UUID),
|
||||
-- archive c24d494c.
|
||||
-- * 2× "Berufungsschrift R.220.1
|
||||
-- (a)/(b)" — canonical 1dfba5b1
|
||||
-- (filled in § 3.3), archive
|
||||
-- 5c0508f4.
|
||||
-- * 2× "Berufungsbegründung R.220.1
|
||||
-- (a)/(b)" — canonical 573df3d1
|
||||
-- (filled in § 3.3), archive
|
||||
-- 791fd0f7.
|
||||
--
|
||||
-- DEFERRED (paliadin/head msg 2006,
|
||||
-- pending m's call): 6× "Mängel-
|
||||
-- beseitigung / Zahlung" and 2×
|
||||
-- "Beginn des Hauptsacheverfahrens".
|
||||
-- Each row in those sets carries a
|
||||
-- DIFFERENT existing rule_codes[]
|
||||
-- value (Mängelbeseitigung: RoP.207
|
||||
-- .6.a, RoP.253.2, RoP.016.3.a,
|
||||
-- RoP.027.2, RoP.089.2, RoP.229.2;
|
||||
-- Beginn-Hauptsache: RoP.198 vs
|
||||
-- RoP.213). These may be distinct
|
||||
-- procedural-context rules masquer-
|
||||
-- ading as duplicates; m owns the
|
||||
-- collapse-or-preserve decision.
|
||||
-- Mig 097 leaves all 8 rows
|
||||
-- untouched (rule_code stays NULL,
|
||||
-- rule_codes[] stays as-is, neither
|
||||
-- archived nor filled).
|
||||
--
|
||||
-- § 5 FLAG-B court-scheduled — 26 rows. Per m: "try to find the
|
||||
-- rules — they often exist." Cites
|
||||
-- the framing norm authorising the
|
||||
-- court to schedule the event (RoP.111
|
||||
-- for UPC oral hearings, RoP.118 for
|
||||
-- UPC decisions, § 285 ZPO / § 300
|
||||
-- ZPO for DE Verhandlung / Urteil,
|
||||
-- § 47 / 78 / 79 / 107 PatG for
|
||||
-- DPMA/BPatG/BGH variants, etc.).
|
||||
--
|
||||
-- § 6 FLAG-C/D rubber-stamp — 5 rows. rev.reply/rev.rejoin/
|
||||
-- app.response use canonical RoP.5x
|
||||
-- regardless of duration-vs-norm
|
||||
-- mismatch (m: "just go ahead").
|
||||
-- de_inf.replik/de_inf.duplik cite
|
||||
-- § 273 ZPO (court-set framing).
|
||||
--
|
||||
-- § 7 FLAG-E service triggers — 6 rows (DE/EPA). Service-trigger
|
||||
-- citations on Zustellung events.
|
||||
-- UPC initial-submission rows carry
|
||||
-- the RoP.271.b 10-day deferral as a
|
||||
-- secondary cite in rule_codes[]
|
||||
-- (handled in § 9 below).
|
||||
--
|
||||
-- § 8 FLAG-F combined-pleading — 5 rows. Use rule_codes[] multi-cite
|
||||
-- array (column already exists from
|
||||
-- mig 095). Primary cite in
|
||||
-- rule_code, full set in rule_codes[].
|
||||
--
|
||||
-- § 9 FLAG-G/H/I + RoP.271.b — 13 rows. G: 2 Patentänderung
|
||||
-- orphans split by INF/REV context.
|
||||
-- H: 8 sub-paragraph spot-checks
|
||||
-- applied as-is per the doc. I: 3
|
||||
-- negative-declaration rows cite
|
||||
-- RoP.069 by analogy.
|
||||
-- Plus: 5 UPC initial-submission rows
|
||||
-- append RoP.271.b to rule_codes[]
|
||||
-- as the 10-day service deferral.
|
||||
-- m flagged this distinct from the
|
||||
-- primary substantive cite.
|
||||
--
|
||||
-- § 10 R.19 label rename — 2 rows max. inf.prelim / rev.prelim:
|
||||
-- set name to "Einspruch (R. 19 VerfO)"
|
||||
-- / "Einspruch (R. 19 i.V.m. R. 46
|
||||
-- VerfO)" + rule_code 'RoP.019.1'.
|
||||
-- Originally drafted in fermi's
|
||||
-- t-paliad-207 session; m applied the
|
||||
-- rename live on prod and asked us to
|
||||
-- consolidate the mig here per Path-A.
|
||||
-- Guard `name LIKE 'Vorab-Einrede%'`
|
||||
-- makes this a defensive no-op on the
|
||||
-- prod DB (fermi already wrote there)
|
||||
-- but applies cleanly on any future
|
||||
-- deploy that hasn't seen the live
|
||||
-- write.
|
||||
--
|
||||
-- § 11 Side-fix RoP.49.1 → .049.1 — 1 row. rev.defence carries an
|
||||
-- un-padded rule_code; all other UPC
|
||||
-- RoP rules under 100 use 3-digit
|
||||
-- padding. legal_source stays
|
||||
-- 'UPC.RoP.49.1' (structured locator
|
||||
-- never pads).
|
||||
--
|
||||
-- FLAG-J kept NULL (3 rows: d124c95b — Aufhebung Entscheidung des
|
||||
-- Amtes, 002c2ba7 — Folgemaßnahmen Validitätsentscheidung, 902cc5d5 —
|
||||
-- Klärung Übersetzungsfragen). m will pick them up later via
|
||||
-- /admin/rules. Existing rule_codes[] on these is left untouched.
|
||||
--
|
||||
-- Idempotent:
|
||||
-- * Backfill UPDATEs guarded on `rule_code IS NULL` (the de-novo fill
|
||||
-- bucket) — re-running is a no-op.
|
||||
-- * Archive UPDATEs guarded on `is_active = true AND lifecycle_state
|
||||
-- = 'published'` — re-running is a no-op.
|
||||
-- * Normalization UPDATE guarded on `rule_code = 'RoP.49.1'` — no-op
|
||||
-- after first apply.
|
||||
-- * Prelim rename UPDATEs guarded on `name LIKE 'Vorab-Einrede%'` —
|
||||
-- no-op after first apply or on prod (fermi already wrote).
|
||||
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
|
||||
-- * Materialized-view refresh is safe to repeat.
|
||||
--
|
||||
-- audit_reason is set at the top via set_config(..., true) so the
|
||||
-- mig-079 audit trigger on paliad.deadline_rules accepts the UPDATEs.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 097: t-paliad-210 legal-citation backfill — m''s FLAG walk-through 2026-05-18 (paliadin/head msg 2002). HIGH/MED proposals from docs/proposals/legal-citation-backfill-2026-05-18.md (commit 391be09) plus FLAG-A dedup + FLAG-B court-scheduled cites + FLAG-F rule_codes[] multi-cite + RoP.271.b on UPC initial submissions + RoP.49.1 padding normalization + R.19 prelim rename (fermi/t-paliad-207 consolidated)',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 0. Backup snapshot of paliad.deadline_rules BEFORE the backfill. Full
|
||||
-- table snapshot for the complete pre-097 baseline. Matches the
|
||||
-- mig 096 pattern (proceeding_types_pre_096).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_097 AS
|
||||
SELECT *, now() AS snapshotted_at
|
||||
FROM paliad.deadline_rules;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rules_pre_097 IS
|
||||
'Snapshot of paliad.deadline_rules taken before mig 097 backfilled '
|
||||
'rule_code + legal_source + rule_codes[] across huygens''s HIGH/MED '
|
||||
'proposals (t-paliad-208) and m''s expanded FLAG walk-through '
|
||||
'(2026-05-18). Source-of-truth for the down migration; persists '
|
||||
'post-backfill as the permanent audit anchor — also retains the '
|
||||
'pre-dedup per-row rule_codes[] for the Mängelbeseitigung × 6 + '
|
||||
'Beginn-Hauptsache × 2 sets in case m later wants to recover the '
|
||||
'procedural-context citations.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. § 1 Easy wins (6 rows). legal_source already populated; only
|
||||
-- rule_code missing. The 2 § 123 PatG Wiedereinsetzung twins
|
||||
-- (c24d494c…, b588fa64…) are handled in § 4 below as part of the
|
||||
-- FLAG-A dedup.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 253 ZPO'
|
||||
WHERE id = '1f532c82-9e6d-4f48-bd16-fa2fc71d5880' AND rule_code IS NULL; -- de_inf.klage / Klageerhebung
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 339 ZPO'
|
||||
WHERE id = '20254f4e-d213-4cf6-8f5f-1d9d36eeb6ac' AND rule_code IS NULL; -- Einspruch gegen Versäumnisurteil
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 296a ZPO'
|
||||
WHERE id = '3c36f149-3a81-456e-aac1-d4d18bfcb16b' AND rule_code IS NULL; -- Schriftsatznachreichung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'R. 135 EPÜ'
|
||||
WHERE id = 'f1099cf6-4c87-430e-b1c5-488bd44cb143' AND rule_code IS NULL; -- Weiterbehandlungsantrag (Art. 121 EPÜ)
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 234 ZPO'
|
||||
WHERE id = 'd40d9be7-e1b6-451c-bee2-6eaee2307ec5' AND rule_code IS NULL; -- Wiedereinsetzungsantrag (§ 233 ZPO)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'R. 136 EPÜ'
|
||||
WHERE id = '23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6' AND rule_code IS NULL; -- Wiedereinsetzungsantrag (Art. 122 EPÜ)
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. § 2 Proceeding-typed HIGH/MED (15 rows). rule_code + legal_source.
|
||||
-- Note: rule_codes[] is set in § 9 for the 5 UPC initial-submission
|
||||
-- rows (inf.soc / rev.app / pi.app / damages.app / disc.app) to
|
||||
-- include the RoP.271.b secondary cite. For DE/EPA rows here,
|
||||
-- rule_codes[] is left untouched (currently NULL and not used for
|
||||
-- DE/EPA citations in this corpus).
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.013.1', legal_source = 'UPC.RoP.13.1'
|
||||
WHERE id = '42be6c9b-8e84-4804-962f-94c3315aca1b' AND rule_code IS NULL; -- upc.inf.cfi / inf.soc
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.042', legal_source = 'UPC.RoP.42'
|
||||
WHERE id = '995c108e-e73a-4f9c-b79f-47abe7c94108' AND rule_code IS NULL; -- upc.rev.cfi / rev.app
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.206', legal_source = 'UPC.RoP.206'
|
||||
WHERE id = 'ed0194b7-74ab-4402-8971-7211f6036ff9' AND rule_code IS NULL; -- upc.pi.cfi / pi.app
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.243', legal_source = 'UPC.RoP.243', rule_codes = ARRAY['RoP.243']::text[]
|
||||
WHERE id = '85f92b72-c654-4429-8e91-03402f9438c6' AND rule_code IS NULL; -- upc.apl.merits / app.oral
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.131', legal_source = 'UPC.RoP.131'
|
||||
WHERE id = '3e1719e8-f6f6-4260-8f02-754bd214937f' AND rule_code IS NULL; -- upc.dmgs.cfi / damages.app
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.141', legal_source = 'UPC.RoP.141'
|
||||
WHERE id = 'eb1fa1d1-b345-42ba-ab14-79f5284166b0' AND rule_code IS NULL; -- upc.disc.cfi / disc.app
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 81 PatG', legal_source = 'DE.PatG.81.1'
|
||||
WHERE id = 'ba33e704-18f6-4486-8107-abdb1e9cbfad' AND rule_code IS NULL; -- de.null.bpatg / de_null.klage
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 58 PatG', legal_source = 'DE.PatG.58.1'
|
||||
WHERE id = '972f8fe4-8f4c-4497-9736-d60399ae5989' AND rule_code IS NULL; -- dpma.opp.dpma / dpma_opp.publish
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 75 EPÜ', legal_source = 'EU.EPÜ.75'
|
||||
WHERE id = 'a1766364-1478-4b13-ae02-0a94367c585e' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.filing
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 92 EPÜ', legal_source = 'EU.EPÜ.92'
|
||||
WHERE id = '63069ae5-e380-4db5-b020-d1856f31300c' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.search
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 97 EPÜ', legal_source = 'EU.EPÜ.97.1'
|
||||
WHERE id = '86b3a295-d76b-4566-955d-55f7a394524e' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.grant
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 97 EPÜ', legal_source = 'EU.EPÜ.97.3'
|
||||
WHERE id = '520dd205-7b4a-45f4-b87f-e2be5d1e183e' AND rule_code IS NULL; -- epa.opp.opd / epa_opp.grant
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 101 EPÜ', legal_source = 'EU.EPÜ.101'
|
||||
WHERE id = '8961a54b-2645-4af4-b0f5-114128150839' AND rule_code IS NULL; -- epa.opp.opd / epa_opp.entsch
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 116 EPÜ', legal_source = 'EU.EPÜ.116'
|
||||
WHERE id = '926f333d-55d2-4a12-890e-0508a4ea1bd4' AND rule_code IS NULL; -- epa.opp.boa / epa_app.oral
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 111 EPÜ', legal_source = 'EU.EPÜ.111'
|
||||
WHERE id = 'd0949eaf-da69-4972-90c2-7e6c1bebcd79' AND rule_code IS NULL; -- epa.opp.boa / epa_app.entsch2
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. § 3 Orphan HIGH/MED (47 rows). rule_code + legal_source. For UPC
|
||||
-- rows also normalize rule_codes[] to ARRAY[rule_code] so the
|
||||
-- structured tooling field matches the display field. The orphan
|
||||
-- archive destinations (5c0508f4 / 791fd0f7 / d886f46f) are NOT
|
||||
-- filled here — they're flipped to archived in § 4.
|
||||
-- =============================================================================
|
||||
|
||||
-- § 3.1 main-pleadings track (10 rows)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.023', legal_source = 'UPC.RoP.23.1', rule_codes = ARRAY['RoP.023']::text[]
|
||||
WHERE id = 'e34097d6-670d-447a-bdfe-b42df20ba459' AND rule_code IS NULL; -- Klageerwiderung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.025.1', legal_source = 'UPC.RoP.25.1', rule_codes = ARRAY['RoP.025.1']::text[]
|
||||
WHERE id = '7d8a4804-0ebc-42c4-8552-624350cd81f3' AND rule_code IS NULL; -- Nichtigkeitswiderklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.2.b', legal_source = 'UPC.RoP.49.2.b', rule_codes = ARRAY['RoP.049.2.b']::text[]
|
||||
WHERE id = 'c7523e6b-579d-4d80-afb3-e1cf11238d40' AND rule_code IS NULL; -- Verletzungswiderklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.019.1', legal_source = 'UPC.RoP.19.1', rule_codes = ARRAY['RoP.019.1']::text[]
|
||||
WHERE id = 'c57f62f8-bb52-4232-be85-9125fa93f58c' AND rule_code IS NULL; -- Vorgängige Einrede
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.b', legal_source = 'UPC.RoP.29.b', rule_codes = ARRAY['RoP.029.b']::text[]
|
||||
WHERE id = '84b390e0-1ca4-461a-942c-4ad94c643750' AND rule_code IS NULL; -- Replik auf Klageerwiderung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.c', legal_source = 'UPC.RoP.29.c', rule_codes = ARRAY['RoP.029.c']::text[]
|
||||
WHERE id = '176cc1ca-2b25-49ee-9c3e-8afed1673b7d' AND rule_code IS NULL; -- Duplik Replik Klageerwiderung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.1', legal_source = 'UPC.RoP.49.1', rule_codes = ARRAY['RoP.049.1']::text[]
|
||||
WHERE id = 'a32dcec1-6aaa-4a3c-936c-9a761d9362f0' AND rule_code IS NULL; -- Erwiderung auf Nichtigkeitsklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
|
||||
WHERE id = '1b5c6dee-0032-4be8-864c-f2ab945aacc5' AND rule_code IS NULL; -- Duplik Replik Erwiderung Nichtigkeitsklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.056.1', legal_source = 'UPC.RoP.56.1', rule_codes = ARRAY['RoP.056.1']::text[]
|
||||
WHERE id = 'bea86f9b-37d5-4f6e-b6bd-f0c01f053b66' AND rule_code IS NULL; -- Erwiderung auf Verletzungswiderklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.056.3', legal_source = 'UPC.RoP.56.3', rule_codes = ARRAY['RoP.056.3']::text[]
|
||||
WHERE id = '4834c957-2518-40e9-ad62-447f3f220d33' AND rule_code IS NULL; -- Replik Erwiderung Verletzungswiderklage
|
||||
|
||||
-- § 3.2 Patentänderungs-Track (1 row; FLAG-G twin rows are handled in § 9)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.1', legal_source = 'UPC.RoP.32.1', rule_codes = ARRAY['RoP.032.1']::text[]
|
||||
WHERE id = '7e65a434-f5c6-4391-a65c-d02de735f551' AND rule_code IS NULL; -- Erwiderung auf Patentänderungsantrag
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.3', legal_source = 'UPC.RoP.32.3', rule_codes = ARRAY['RoP.032.3']::text[]
|
||||
WHERE id = 'dfd52792-840f-42c4-8b71-0f77d07cbb53' AND rule_code IS NULL; -- Replik Erwiderung Patentänderung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.3', legal_source = 'UPC.RoP.32.3', rule_codes = ARRAY['RoP.032.3']::text[]
|
||||
WHERE id = '8cdf54eb-5189-47fd-a390-6a0ee98e5243' AND rule_code IS NULL; -- Duplik Replik Erwiderung Patentänderung
|
||||
|
||||
-- § 3.3 appeal track (8 fills; 2 archive-destinations handled in § 4)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.a', legal_source = 'UPC.RoP.224.1.a', rule_codes = ARRAY['RoP.224.1.a']::text[]
|
||||
WHERE id = '1dfba5b1-4ed1-40c1-9cf6-4ed8ff7a0818' AND rule_code IS NULL; -- Berufungsschrift canonical
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.b', legal_source = 'UPC.RoP.224.1.b', rule_codes = ARRAY['RoP.224.1.b']::text[]
|
||||
WHERE id = 'd560b3b6-9437-4b22-b62c-957d4a37d21a' AND rule_code IS NULL; -- Berufungsschrift Orders
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.225.1', legal_source = 'UPC.RoP.225.1', rule_codes = ARRAY['RoP.225.1']::text[]
|
||||
WHERE id = '573df3d1-8ea2-4a6e-b0d4-fc3cd10506da' AND rule_code IS NULL; -- Berufungsbegründung canonical
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.b', legal_source = 'UPC.RoP.224.1.b', rule_codes = ARRAY['RoP.224.1.b']::text[]
|
||||
WHERE id = '91e367dd-ffe6-4012-ac6a-b61c32e2b3b7' AND rule_code IS NULL; -- Berufung (Anordnungen & mit Zulassung)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.221.1', legal_source = 'UPC.RoP.221.1', rule_codes = ARRAY['RoP.221.1']::text[]
|
||||
WHERE id = 'ccb916df-4ee3-4dde-bcb0-6a5b557c0cba' AND rule_code IS NULL; -- Berufungszulassung Kosten
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.220.3', legal_source = 'UPC.RoP.220.3', rule_codes = ARRAY['RoP.220.3']::text[]
|
||||
WHERE id = '342e749d-c2bc-4148-974b-ac0331b76229' AND rule_code IS NULL; -- Ermessensüberprüfung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.1', legal_source = 'UPC.RoP.235.1', rule_codes = ARRAY['RoP.235.1']::text[]
|
||||
WHERE id = '10374392-b8db-4738-8a61-f8ce0fabcc3e' AND rule_code IS NULL; -- Berufungserwiderung (224.2(a))
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.237.1', legal_source = 'UPC.RoP.237.1', rule_codes = ARRAY['RoP.237.1']::text[]
|
||||
WHERE id = '6e39b653-1328-40e1-95f1-071fdf46eed6' AND rule_code IS NULL; -- Anschlussberufung (224.2(a))
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.238.1', legal_source = 'UPC.RoP.238.1', rule_codes = ARRAY['RoP.238.1']::text[]
|
||||
WHERE id = '6b989e85-e739-4e3b-bfd1-52b0e0c35f61' AND rule_code IS NULL; -- Erwiderung Anschlussberufung (224.2(a))
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.238.2', legal_source = 'UPC.RoP.238.2', rule_codes = ARRAY['RoP.238.2']::text[]
|
||||
WHERE id = 'e78f4652-acf9-4ecd-ac48-888ce475173f' AND rule_code IS NULL; -- Erwiderung Anschlussberufung (224.2(b))
|
||||
|
||||
-- § 3.4 Schadensbemessung / Rechnungslegung (7 rows)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.137.2', legal_source = 'UPC.RoP.137.2', rule_codes = ARRAY['RoP.137.2']::text[]
|
||||
WHERE id = 'd414f603-14c1-49f2-91be-e305eba696e3' AND rule_code IS NULL; -- Erwiderung Schadensbemessung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.139', legal_source = 'UPC.RoP.139', rule_codes = ARRAY['RoP.139']::text[]
|
||||
WHERE id = '9f39e263-e9ec-4805-a82e-c7551a22c78d' AND rule_code IS NULL; -- Replik Schadensbemessung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.139', legal_source = 'UPC.RoP.139', rule_codes = ARRAY['RoP.139']::text[]
|
||||
WHERE id = '067ffdf0-180b-488f-a369-249f6bcb9faa' AND rule_code IS NULL; -- Duplik Schadensbemessung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.2', legal_source = 'UPC.RoP.142.2', rule_codes = ARRAY['RoP.142.2']::text[]
|
||||
WHERE id = '429b8ec0-227a-4945-8b20-6ad79330a490' AND rule_code IS NULL; -- Erwiderung Rechnungslegung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.3', legal_source = 'UPC.RoP.142.3', rule_codes = ARRAY['RoP.142.3']::text[]
|
||||
WHERE id = '8d36fc76-61b9-4e99-b113-eed4c9c4b2c7' AND rule_code IS NULL; -- Replik Rechnungslegung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.3', legal_source = 'UPC.RoP.142.3', rule_codes = ARRAY['RoP.142.3']::text[]
|
||||
WHERE id = 'ed82fec9-2346-494f-a0ff-f41e64c26942' AND rule_code IS NULL; -- Duplik Rechnungslegung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.151', legal_source = 'UPC.RoP.151', rule_codes = ARRAY['RoP.151']::text[]
|
||||
WHERE id = 'eed69e8b-0dc8-4d97-83f0-5694d539b46a' AND rule_code IS NULL; -- Kostenentscheidung
|
||||
|
||||
-- § 3.5 provisional / PI (2 rows; canonical ba335c99 + the d886f46f archive handled in § 4)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.197.3', legal_source = 'UPC.RoP.197.3', rule_codes = ARRAY['RoP.197.3']::text[]
|
||||
WHERE id = '1f1f72ef-5a67-4d6a-9a80-82e53375177a' AND rule_code IS NULL; -- Beweissicherungsanordnung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.207.9', legal_source = 'UPC.RoP.207.9', rule_codes = ARRAY['RoP.207.9']::text[]
|
||||
WHERE id = '3e2f5697-3012-4bae-bd4d-44998dd3b75b' AND rule_code IS NULL; -- Schutzschrift
|
||||
|
||||
-- § 3.7 formalities / Registry (4 fills; 5 Mängelbeseitigung dups + FLAG-J 2 rows handled separately)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.016.5', legal_source = 'UPC.RoP.16.5', rule_codes = ARRAY['RoP.016.5']::text[]
|
||||
WHERE id = '3bc40027-9ebf-4f3d-880d-bf9de6da3ec0' AND rule_code IS NULL; -- Mängelbeseitigung / Stellungnahme
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.262.2', legal_source = 'UPC.RoP.262.2', rule_codes = ARRAY['RoP.262.2']::text[]
|
||||
WHERE id = '69e356b7-79b3-42d7-972b-44d4e35ebdbc' AND rule_code IS NULL; -- Vertraulichkeit
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.353', legal_source = 'UPC.RoP.353', rule_codes = ARRAY['RoP.353']::text[]
|
||||
WHERE id = '57e6eeca-8695-4af3-96cc-16ebd8bc3f2c' AND rule_code IS NULL; -- Berichtigung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.333.1', legal_source = 'UPC.RoP.333.1', rule_codes = ARRAY['RoP.333.1']::text[]
|
||||
WHERE id = '8ec233b9-3bc4-4015-a158-86af233e52b3' AND rule_code IS NULL; -- Verfahrensleitende Anordnung
|
||||
|
||||
-- § 3.8 translation / interpretation (1 row; FLAG-H/J handled in § 9 / left NULL)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.109.1', legal_source = 'UPC.RoP.109.1', rule_codes = ARRAY['RoP.109.1']::text[]
|
||||
WHERE id = 'bb7bafcb-9d91-4bf7-ae2c-6634652d9906' AND rule_code IS NULL; -- Simultanübersetzung
|
||||
|
||||
-- § 3.9 review / rehearing (2 rows)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.247.2', legal_source = 'UPC.RoP.247.2', rule_codes = ARRAY['RoP.247.2']::text[]
|
||||
WHERE id = '372e86e3-c8ff-4cb5-9389-66acdbc96e57' AND rule_code IS NULL; -- Wiederaufnahme (schwerwiegend)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.247.2', legal_source = 'UPC.RoP.247.2', rule_codes = ARRAY['RoP.247.2']::text[]
|
||||
WHERE id = '58de9573-07db-4d8d-9b00-8fab0d71d88c' AND rule_code IS NULL; -- Wiederaufnahme (Straftat)
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. § 4 FLAG-A dedup (clean only). 1 canonical fill (the other 2
|
||||
-- canonicals are filled in § 3.3) + 3 archive flips. Canonical
|
||||
-- selection per m's spec: lowest UUID. None of the archive
|
||||
-- candidates have FK references in mgmt.deadline_rules / paliad.
|
||||
-- appointments / paliad.deadlines / paliad.deadline_rules (parent_id
|
||||
-- or draft_of) — verified pre-mig. Archive over DELETE per m
|
||||
-- (audit trail).
|
||||
--
|
||||
-- Mängelbeseitigung 6× and Beginn-Hauptsache 2× are intentionally
|
||||
-- NOT deduped in this mig — see header for the deferred-decision
|
||||
-- rationale. Their rows stay active+published+rule_code IS NULL
|
||||
-- until m's call lands.
|
||||
-- =============================================================================
|
||||
|
||||
-- Canonical fill for the § 123 PatG twin (legal_source already
|
||||
-- DE.PatG.123.2). The other 2 canonicals (Berufungsschrift 1dfba5b1
|
||||
-- and Berufungsbegründung 573df3d1) are filled in § 3.3 above.
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 123 PatG'
|
||||
WHERE id = 'b588fa64-a727-4cfb-a45d-69a835a3b05a' AND rule_code IS NULL;
|
||||
|
||||
-- Archive flips (3 rows: the non-canonical sides of the 3 clean dedup
|
||||
-- sets). After this each set has exactly 1 active+published row.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_active = false, lifecycle_state = 'archived'
|
||||
WHERE id IN (
|
||||
'c24d494c-0da1-4f01-aa74-0f37f99fe1ae', -- Wiedereinsetzung § 123 PatG dup
|
||||
'5c0508f4-020a-4ef5-bcc7-1ee85eafe0b3', -- Berufungsschrift dup
|
||||
'791fd0f7-a448-4711-b1aa-63e6df1e7c57' -- Berufungsbegründung dup
|
||||
)
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. § 5 FLAG-B court-scheduled events (26 rows). Cite the framing norm
|
||||
-- that authorises the court to schedule the event. UPC RoP.111 /
|
||||
-- RoP.118 / RoP.101 / RoP.209 / RoP.211 / RoP.350 / RoP.220.1.c /
|
||||
-- RoP.157. DE § 285 ZPO / § 300 ZPO / § 89 PatG / § 84 PatG / § 113
|
||||
-- PatG / § 119 PatG. DPMA § 47 / 78 / 79 / 107 PatG.
|
||||
-- =============================================================================
|
||||
|
||||
-- UPC court-scheduled events
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.118', legal_source = 'UPC.RoP.118', rule_codes = ARRAY['RoP.118']::text[]
|
||||
WHERE id = '60d71f1e-a0e8-42cd-85e9-89f3c808868f' AND rule_code IS NULL; -- inf.decision
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.101', legal_source = 'UPC.RoP.101', rule_codes = ARRAY['RoP.101']::text[]
|
||||
WHERE id = '7b118633-92b2-4c91-8512-6cb929288f10' AND rule_code IS NULL; -- inf.interim
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.111', legal_source = 'UPC.RoP.111', rule_codes = ARRAY['RoP.111']::text[]
|
||||
WHERE id = 'd4c01a6f-d147-4505-bf1c-9aaf88b15287' AND rule_code IS NULL; -- inf.oral
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.118', legal_source = 'UPC.RoP.118', rule_codes = ARRAY['RoP.118']::text[]
|
||||
WHERE id = 'f382cfe4-6703-40f8-a43d-0fe02d62d0fa' AND rule_code IS NULL; -- rev.decision
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.101', legal_source = 'UPC.RoP.101', rule_codes = ARRAY['RoP.101']::text[]
|
||||
WHERE id = 'ccad91ef-da04-4b81-a979-658578fb97c4' AND rule_code IS NULL; -- rev.interim
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.111', legal_source = 'UPC.RoP.111', rule_codes = ARRAY['RoP.111']::text[]
|
||||
WHERE id = '38e8982b-5cc9-41b3-b477-37ce4bd4e7c4' AND rule_code IS NULL; -- rev.oral
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.209', legal_source = 'UPC.RoP.209', rule_codes = ARRAY['RoP.209']::text[]
|
||||
WHERE id = 'e4a61ebf-c49b-450f-9d94-bb06098536b4' AND rule_code IS NULL; -- pi.oral
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.211', legal_source = 'UPC.RoP.211', rule_codes = ARRAY['RoP.211']::text[]
|
||||
WHERE id = '7b93a8b7-115d-42b4-9d1d-34684ddf5206' AND rule_code IS NULL; -- pi.order
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.209.1', legal_source = 'UPC.RoP.209.1', rule_codes = ARRAY['RoP.209.1']::text[]
|
||||
WHERE id = '30ffe572-aa77-4dcb-9292-a4750289f75c' AND rule_code IS NULL; -- pi.response (court-set)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.350', legal_source = 'UPC.RoP.350', rule_codes = ARRAY['RoP.350']::text[]
|
||||
WHERE id = '685bad4f-3c3e-425d-8839-2f765d0fc96e' AND rule_code IS NULL; -- app.decision
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.220.1.c', legal_source = 'UPC.RoP.220.1.c', rule_codes = ARRAY['RoP.220.1.c']::text[]
|
||||
WHERE id = 'c2865575-d7d6-436d-b61c-0a266217f76c' AND rule_code IS NULL; -- app_ord.order
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.157', legal_source = 'UPC.RoP.157', rule_codes = ARRAY['RoP.157']::text[]
|
||||
WHERE id = '01db67c9-5621-48ca-9dbd-d652b6237b24' AND rule_code IS NULL; -- cost.decision
|
||||
|
||||
-- DE court-scheduled events
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
|
||||
WHERE id = 'a95af317-2fdb-43c9-ab66-c8b2099aaa5a' AND rule_code IS NULL; -- de_inf.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
|
||||
WHERE id = 'e46d2ae7-74bf-4c06-9e55-921242d36f2a' AND rule_code IS NULL; -- de_inf.urteil
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
|
||||
WHERE id = '2a16f77f-408f-48c4-9d71-8ea5926d4dca' AND rule_code IS NULL; -- de_inf_olg.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
|
||||
WHERE id = '7d7d88c5-895e-4855-8f4d-2e160ff74998' AND rule_code IS NULL; -- de_inf_olg.urteil_olg
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
|
||||
WHERE id = 'b1460f90-419e-47ae-978a-8e32ffafad73' AND rule_code IS NULL; -- de_inf_bgh.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
|
||||
WHERE id = '803460ac-f6bd-4194-b5ab-140175644648' AND rule_code IS NULL; -- de_inf_bgh.urteil_bgh
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 89 PatG', legal_source = 'DE.PatG.89'
|
||||
WHERE id = 'ab60e712-bc56-4326-8df0-413881996bf3' AND rule_code IS NULL; -- de_null.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 84 PatG', legal_source = 'DE.PatG.84'
|
||||
WHERE id = '1476829a-cc92-4221-b182-846fc99ad941' AND rule_code IS NULL; -- de_null.urteil
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 113 PatG', legal_source = 'DE.PatG.113'
|
||||
WHERE id = 'd077816d-bce4-4cb7-bd67-7b52edbf7fb9' AND rule_code IS NULL; -- de_null_bgh.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 119 PatG', legal_source = 'DE.PatG.119'
|
||||
WHERE id = '816e9756-efff-4e40-b650-f0b31bdc21e5' AND rule_code IS NULL; -- de_null_bgh.urteil_bgh
|
||||
|
||||
-- DPMA / BPatG / BGH-PatG court-scheduled events
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 47 PatG', legal_source = 'DE.PatG.47'
|
||||
WHERE id = '193a85e2-5794-463a-8c45-73174a54cea9' AND rule_code IS NULL; -- dpma_opp.entscheidung
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 79 PatG', legal_source = 'DE.PatG.79'
|
||||
WHERE id = 'baaff831-6a3f-43ed-96bb-eae6ad73f6fc' AND rule_code IS NULL; -- dpma_bpatg.entsch_bpatg
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 78 PatG', legal_source = 'DE.PatG.78'
|
||||
WHERE id = '446694c2-5b34-4ecd-9bf7-7eee055b0d1b' AND rule_code IS NULL; -- dpma_bpatg.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 107 PatG', legal_source = 'DE.PatG.107'
|
||||
WHERE id = '99c02992-1a77-4694-b773-941ac9876bb5' AND rule_code IS NULL; -- dpma_bgh.entsch_bgh
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. § 6 FLAG-C/D rubber-stamp (5 rows). UPC RoP duration-vs-norm
|
||||
-- mismatches get the canonical citation per m ("just go ahead"). DE
|
||||
-- LG patent-practice 4-week replik/duplik cite § 273 ZPO (court-set
|
||||
-- framing).
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
|
||||
WHERE id = '7e0ea937-d81b-4dee-897e-0d8bc0543f34' AND rule_code IS NULL; -- rev.reply (FLAG-C)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
|
||||
WHERE id = 'b7890351-c6d6-46e4-b064-0513a1808e6d' AND rule_code IS NULL; -- rev.rejoin (FLAG-C)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.1', legal_source = 'UPC.RoP.235.1', rule_codes = ARRAY['RoP.235.1']::text[]
|
||||
WHERE id = 'd6600ceb-d1d5-408a-a7c9-1026f304ac7f' AND rule_code IS NULL; -- app.response (FLAG-C)
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 273 ZPO', legal_source = 'DE.ZPO.273'
|
||||
WHERE id = 'd46d915e-fd46-4167-88b5-6d22bcbb8882' AND rule_code IS NULL; -- de_inf.replik (FLAG-D)
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 273 ZPO', legal_source = 'DE.ZPO.273'
|
||||
WHERE id = 'ca9b52cb-e986-4c3a-9e89-e799e6a6ac33' AND rule_code IS NULL; -- de_inf.duplik (FLAG-D)
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. § 7 FLAG-E service triggers (6 rows, DE/EPA). § 317 ZPO for LG/OLG
|
||||
-- judgment-service, § 99 / § 47 / § 79 PatG for the PatG variants,
|
||||
-- R. 111 EPÜ for EPA notification.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 317 ZPO', legal_source = 'DE.ZPO.317'
|
||||
WHERE id = '106d8a0b-514b-4021-8b65-7debff71f1d3' AND rule_code IS NULL; -- de_inf_olg.urteil_lg
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 317 ZPO', legal_source = 'DE.ZPO.317'
|
||||
WHERE id = 'd071b5c6-f33e-44e8-8656-4e9cccf55701' AND rule_code IS NULL; -- de_inf_bgh.urteil_olg
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 99 PatG', legal_source = 'DE.PatG.99.1'
|
||||
WHERE id = 'bdae7319-7435-40e9-be19-6ce21fdb9946' AND rule_code IS NULL; -- de_null_bgh.urteil_bpatg
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 47 PatG', legal_source = 'DE.PatG.47.1'
|
||||
WHERE id = '327390f9-3c1b-496f-8e63-2bf19c380dfe' AND rule_code IS NULL; -- dpma_bpatg.entscheidung
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 79 PatG', legal_source = 'DE.PatG.79.1'
|
||||
WHERE id = 'd3ea5e50-f7e2-40f1-bb16-30664acc2e2b' AND rule_code IS NULL; -- dpma_bgh.entsch_bpatg
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'R. 111 EPÜ', legal_source = 'EU.EPC-R.111'
|
||||
WHERE id = '79c27f9b-5195-4272-90d6-ea6a43cd0938' AND rule_code IS NULL; -- epa_app.entsch
|
||||
|
||||
-- =============================================================================
|
||||
-- 8. § 8 FLAG-F combined-pleading rows (5 rows). Primary cite in
|
||||
-- rule_code + legal_source; full set of citations in rule_codes[]
|
||||
-- so downstream tooling can resolve any of the combined norms.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.029.a', legal_source = 'UPC.RoP.29.a',
|
||||
rule_codes = ARRAY['RoP.029.a', 'RoP.029.b']::text[]
|
||||
WHERE id = 'cec1a865-30a4-46c9-8abf-630d4478b91a' AND rule_code IS NULL; -- Erwid CCR + Replik SoD
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.029.c', legal_source = 'UPC.RoP.29.c',
|
||||
rule_codes = ARRAY['RoP.029.c', 'RoP.032.3']::text[]
|
||||
WHERE id = '02ae9c1f-2aa0-4e0e-acf1-ae235588a64f' AND rule_code IS NULL; -- Duplik Replik + Replik Erwid Patentänderung
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.029.d', legal_source = 'UPC.RoP.29.d',
|
||||
rule_codes = ARRAY['RoP.029.d', 'RoP.029.c', 'RoP.032.1']::text[]
|
||||
WHERE id = 'ec2a1274-ffd8-42e7-9e27-582365d04d6e' AND rule_code IS NULL; -- Replik Erwid Widerklage + Duplik Replik Klageerwid + Erwid Patentänderung
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.051', legal_source = 'UPC.RoP.51',
|
||||
rule_codes = ARRAY['RoP.051', 'RoP.049.2.a', 'RoP.056.1']::text[]
|
||||
WHERE id = '37bd034b-79e3-4c3c-a21d-b078aaf2ea04' AND rule_code IS NULL; -- Replik Erwid Nichtigkeit + Erwid Patent + Erwid Widerklage
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.056.4', legal_source = 'UPC.RoP.56.4',
|
||||
rule_codes = ARRAY['RoP.056.4', 'RoP.032.3']::text[]
|
||||
WHERE id = '7b548c48-6fef-4387-8123-e1f1e4ee6da2' AND rule_code IS NULL; -- Duplik (Verletzungswiderklage + Patentänderung)
|
||||
|
||||
-- =============================================================================
|
||||
-- 9. § 9 FLAG-G/H/I + RoP.271.b. Patentänderung INF/REV split (G),
|
||||
-- sub-paragraph spot-checks (H, applied as-is per doc), negative-
|
||||
-- declaration RoP.069 by analogy (I), and the RoP.271.b 10-day
|
||||
-- service-deferral secondary cite on UPC initial submissions.
|
||||
-- =============================================================================
|
||||
|
||||
-- FLAG-G: Patentänderungs-Twin (INF vs REV context)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.030.1', legal_source = 'UPC.RoP.30.1', rule_codes = ARRAY['RoP.030.1']::text[]
|
||||
WHERE id = 'fb7050c6-a18b-47e4-8811-46ca3677d549' AND rule_code IS NULL; -- Patentänderung INF
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.2.a', legal_source = 'UPC.RoP.49.2.a', rule_codes = ARRAY['RoP.049.2.a']::text[]
|
||||
WHERE id = '21e67ac1-fe40-44d1-ae2e-ea90e0b97598' AND rule_code IS NULL; -- Patentänderung REV
|
||||
|
||||
-- FLAG-H: sub-paragraph spot-checks (8 rows, applied per doc proposal)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.225.2', legal_source = 'UPC.RoP.225.2', rule_codes = ARRAY['RoP.225.2']::text[]
|
||||
WHERE id = 'c3a369f9-4f56-4c88-b11c-f98d05d3b376' AND rule_code IS NULL; -- Berufungsbegründung Orders
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.234.1', legal_source = 'UPC.RoP.234.1', rule_codes = ARRAY['RoP.234.1']::text[]
|
||||
WHERE id = 'd4f739cd-444d-48c0-98c4-70f0521b4916' AND rule_code IS NULL; -- Anfechtung Verwerfung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.4', legal_source = 'UPC.RoP.235.4', rule_codes = ARRAY['RoP.235.4']::text[]
|
||||
WHERE id = '4c585c6d-fb5c-4a99-a798-86a05c757bf7' AND rule_code IS NULL; -- Berufungserwiderung Orders
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.237.2', legal_source = 'UPC.RoP.237.2', rule_codes = ARRAY['RoP.237.2']::text[]
|
||||
WHERE id = 'a00e51bb-bcb6-48d0-9aa5-2216e9480c5c' AND rule_code IS NULL; -- Anschlussberufung Orders
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.097.1', legal_source = 'UPC.RoP.97.1', rule_codes = ARRAY['RoP.097.1']::text[]
|
||||
WHERE id = '0531b6ba-98cc-48f4-adb8-da8b7a7c3535' AND rule_code IS NULL; -- Aufhebung EPA Einheitswirkung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.037.4', legal_source = 'UPC.RoP.37.4', rule_codes = ARRAY['RoP.037.4']::text[]
|
||||
WHERE id = '6b6b967c-65fd-4172-9640-1ffff8a46704' AND rule_code IS NULL; -- Verweisung Zentralkammer
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.109.5', legal_source = 'UPC.RoP.109.5', rule_codes = ARRAY['RoP.109.5']::text[]
|
||||
WHERE id = '8c682cff-3423-41d8-81ca-b5b461461682' AND rule_code IS NULL; -- Dolmetscher own-cost
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.007.2', legal_source = 'UPC.RoP.7.2', rule_codes = ARRAY['RoP.007.2']::text[]
|
||||
WHERE id = '9ed513c1-68df-455e-810e-a5d8d7b85729' AND rule_code IS NULL; -- Übersetzungen Schriftstücke
|
||||
|
||||
-- FLAG-I: negative-declaration track (3 rows, RoP.069 by analogy per m)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
|
||||
WHERE id = '521bf607-1c69-4dc5-a09e-70339bbe4684' AND rule_code IS NULL; -- Erwid neg. Feststellungsklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
|
||||
WHERE id = 'e887b1fb-83ff-4073-b81b-c10dde6dc2c6' AND rule_code IS NULL; -- Replik neg. Feststellung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
|
||||
WHERE id = '0cf1d755-3ba5-44ce-87ca-f98bb076c995' AND rule_code IS NULL; -- Duplik neg. Feststellung
|
||||
|
||||
-- RoP.271.b — 10-day service deferral on UPC initial submissions.
|
||||
-- Set rule_codes[] to [primary substantive cite, 'RoP.271.b'] for the
|
||||
-- 5 UPC initial-submission rows whose § 2 UPDATEs above only set
|
||||
-- rule_code + legal_source. Idempotent via the IS DISTINCT FROM guard
|
||||
-- — re-running matches no rows.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_codes = target.rule_codes
|
||||
FROM (VALUES
|
||||
('42be6c9b-8e84-4804-962f-94c3315aca1b'::uuid, ARRAY['RoP.013.1', 'RoP.271.b']::text[]), -- inf.soc
|
||||
('995c108e-e73a-4f9c-b79f-47abe7c94108'::uuid, ARRAY['RoP.042', 'RoP.271.b']::text[]), -- rev.app
|
||||
('ed0194b7-74ab-4402-8971-7211f6036ff9'::uuid, ARRAY['RoP.206', 'RoP.271.b']::text[]), -- pi.app
|
||||
('3e1719e8-f6f6-4260-8f02-754bd214937f'::uuid, ARRAY['RoP.131', 'RoP.271.b']::text[]), -- damages.app
|
||||
('eb1fa1d1-b345-42ba-ab14-79f5284166b0'::uuid, ARRAY['RoP.141', 'RoP.271.b']::text[]) -- disc.app
|
||||
) AS target(id, rule_codes)
|
||||
WHERE paliad.deadline_rules.id = target.id
|
||||
AND paliad.deadline_rules.rule_codes IS DISTINCT FROM target.rule_codes;
|
||||
|
||||
-- =============================================================================
|
||||
-- 10. § 10 R.19 label rename (inf.prelim / rev.prelim). Defensive
|
||||
-- idempotent backstop for fermi's live prod write. Matches no rows
|
||||
-- on the current prod DB (fermi already renamed) and on the first
|
||||
-- post-mig fresh-deploy too. Catches any future prod that hasn't
|
||||
-- seen the live write.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET name = 'Einspruch (R. 19 VerfO)', rule_code = 'RoP.019.1'
|
||||
WHERE code = 'inf.prelim' AND name LIKE 'Vorab-Einrede%';
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)', rule_code = 'RoP.019.1'
|
||||
WHERE code = 'rev.prelim' AND name LIKE 'Vorab-Einrede%';
|
||||
|
||||
-- =============================================================================
|
||||
-- 11. § 11 Side-fix: normalize the one un-padded UPC RoP <100 rule_code
|
||||
-- outlier. legal_source stays 'UPC.RoP.49.1' (structured locator
|
||||
-- never pads — convention § 0.2 of the proposal doc).
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.049.1'
|
||||
WHERE rule_code = 'RoP.49.1'
|
||||
AND code = 'rev.defence';
|
||||
|
||||
-- =============================================================================
|
||||
-- 12. Refresh the deadline_search materialized view so search hits
|
||||
-- return the newly populated rule_code + legal_source values.
|
||||
-- =============================================================================
|
||||
|
||||
REFRESH MATERIALIZED VIEW paliad.deadline_search;
|
||||
|
||||
-- =============================================================================
|
||||
-- 13. Hard assertions. Verifies the post-state matches the plan.
|
||||
--
|
||||
-- a) 11 active+published rows remain rule_code IS NULL: the 3
|
||||
-- FLAG-J rows (m picks them up via /admin/rules) plus the 8
|
||||
-- rows whose dedup decision is deferred (Mängelbeseitigung 6×
|
||||
-- + Beginn-Hauptsache 2×).
|
||||
-- b) No un-padded RoP.49.1 outlier remains.
|
||||
-- c) Padded RoP.049.1 present at least twice (rev.defence
|
||||
-- normalized + a32dcec1 orphan filled).
|
||||
-- d) Each of the 3 clean-dedup sets has exactly 1 active+published
|
||||
-- row after the archive flips.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_null_after integer;
|
||||
v_old_outlier integer;
|
||||
v_new_padded integer;
|
||||
v_dup_count integer;
|
||||
BEGIN
|
||||
-- (a) 3 FLAG-J + 8 deferred-dedup rows stay NULL.
|
||||
SELECT count(*) INTO v_null_after
|
||||
FROM paliad.deadline_rules
|
||||
WHERE rule_code IS NULL
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
IF v_null_after <> 11 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097: expected 11 rule_code IS NULL active+published rows after backfill (3 FLAG-J + 8 deferred dedup), got %',
|
||||
v_null_after;
|
||||
END IF;
|
||||
|
||||
-- (b) RoP.49.1 outlier normalized.
|
||||
SELECT count(*) INTO v_old_outlier
|
||||
FROM paliad.deadline_rules
|
||||
WHERE rule_code = 'RoP.49.1';
|
||||
IF v_old_outlier <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097: expected 0 RoP.49.1 rows after normalization, got %',
|
||||
v_old_outlier;
|
||||
END IF;
|
||||
|
||||
-- (c) RoP.049.1 present at least twice.
|
||||
SELECT count(*) INTO v_new_padded
|
||||
FROM paliad.deadline_rules
|
||||
WHERE rule_code = 'RoP.049.1';
|
||||
IF v_new_padded < 2 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097: expected >= 2 RoP.049.1 rows after normalization + orphan fill, got %',
|
||||
v_new_padded;
|
||||
END IF;
|
||||
|
||||
-- (d) Each clean-dedup set has exactly 1 active+published row.
|
||||
|
||||
SELECT count(*) INTO v_dup_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND id IN (
|
||||
'b588fa64-a727-4cfb-a45d-69a835a3b05a',
|
||||
'c24d494c-0da1-4f01-aa74-0f37f99fe1ae'
|
||||
);
|
||||
IF v_dup_count <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097 dedup: Wiedereinsetzung-§123-PatG set must have 1 active+published row, got %',
|
||||
v_dup_count;
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_dup_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND id IN (
|
||||
'1dfba5b1-4ed1-40c1-9cf6-4ed8ff7a0818',
|
||||
'5c0508f4-020a-4ef5-bcc7-1ee85eafe0b3'
|
||||
);
|
||||
IF v_dup_count <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097 dedup: Berufungsschrift set must have 1 active+published row, got %',
|
||||
v_dup_count;
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_dup_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND id IN (
|
||||
'573df3d1-8ea2-4a6e-b0d4-fc3cd10506da',
|
||||
'791fd0f7-a448-4711-b1aa-63e6df1e7c57'
|
||||
);
|
||||
IF v_dup_count <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097 dedup: Berufungsbegründung set must have 1 active+published row, got %',
|
||||
v_dup_count;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,162 @@
|
||||
-- Reverses mig 098. Restores the pre-098 submission codes on
|
||||
-- paliad.deadline_rules, renames the column back to `code`, recreates
|
||||
-- the deadline_search matview against the restored column, then drops
|
||||
-- the snapshot table.
|
||||
--
|
||||
-- audit_reason wrapper required by the mig 079 audit trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 098 (down): revert t-paliad-209 workstream B — restore paliad.deadline_rules.code values from deadline_rules_pre_098 snapshot and rename submission_code → code; matview deadline_search rebuilt against the restored column.',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Drop the matview so the column rename can succeed.
|
||||
-- =============================================================================
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Rename the column back. Guarded so a down run on a DB where the
|
||||
-- up never ran (or where the column is already named `code`) is a
|
||||
-- no-op rather than an error.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'deadline_rules'
|
||||
AND column_name = 'submission_code'
|
||||
) THEN
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
RENAME COLUMN submission_code TO code;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Restore code values from the pre_098 snapshot. The snapshot was
|
||||
-- captured at the first up-migration run; if the table is missing
|
||||
-- (down run before up), the restore is a no-op.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_snap_exists boolean;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'deadline_rules_pre_098'
|
||||
) INTO v_snap_exists;
|
||||
|
||||
IF NOT v_snap_exists THEN
|
||||
RAISE NOTICE
|
||||
'mig 098 (down): snapshot table paliad.deadline_rules_pre_098 missing — nothing to restore';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET code = snap.code
|
||||
FROM paliad.deadline_rules_pre_098 snap
|
||||
WHERE dr.id = snap.id
|
||||
AND dr.code <> snap.code;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Recreate the deadline_search matview against the restored column.
|
||||
-- Identical body to mig 051 §4, reproduced here so the down leaves
|
||||
-- the schema in the same shape mig 051 created.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||
SELECT
|
||||
'rule'::text AS kind,
|
||||
'r:' || dr.id::text AS row_key,
|
||||
dc.id AS concept_id,
|
||||
dc.slug AS concept_slug,
|
||||
dc.name_de AS concept_name_de,
|
||||
dc.name_en AS concept_name_en,
|
||||
dc.description AS concept_description,
|
||||
dc.aliases AS concept_aliases,
|
||||
dc.party AS concept_party,
|
||||
dc.category AS concept_category,
|
||||
dc.sort_order AS concept_sort_order,
|
||||
dr.id AS rule_id,
|
||||
NULL::bigint AS trigger_event_id,
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name_de,
|
||||
pt.name_en AS proceeding_name_en,
|
||||
pt.jurisdiction AS jurisdiction,
|
||||
pt.display_order AS proceeding_display_order,
|
||||
dr.code AS rule_local_code,
|
||||
dr.name AS rule_name_de,
|
||||
dr.name_en AS rule_name_en,
|
||||
dr.legal_source AS legal_source,
|
||||
dr.rule_code AS rule_code,
|
||||
dr.duration_value,
|
||||
dr.duration_unit,
|
||||
dr.timing,
|
||||
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
|
||||
WHERE dr.is_active
|
||||
AND pt.is_active
|
||||
AND pt.category = 'fristenrechner'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'trigger'::text,
|
||||
't:' || te.id::text,
|
||||
dc.id,
|
||||
dc.slug,
|
||||
dc.name_de,
|
||||
dc.name_en,
|
||||
dc.description,
|
||||
dc.aliases,
|
||||
dc.party,
|
||||
dc.category,
|
||||
dc.sort_order,
|
||||
NULL::uuid,
|
||||
te.id,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
'cross-cutting'::text,
|
||||
9999::int AS proceeding_display_order,
|
||||
te.code,
|
||||
te.name_de,
|
||||
te.name,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::int,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
dc.party
|
||||
FROM paliad.trigger_events te
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||
WHERE te.is_active;
|
||||
|
||||
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
|
||||
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
|
||||
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
|
||||
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
|
||||
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
|
||||
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. Drop the snapshot table so a re-applied up captures a fresh
|
||||
-- snapshot of the current state.
|
||||
-- =============================================================================
|
||||
|
||||
DROP TABLE IF EXISTS paliad.deadline_rules_pre_098;
|
||||
@@ -0,0 +1,275 @@
|
||||
-- t-paliad-209 / workstream B — submission-code prefix + rename.
|
||||
--
|
||||
-- m's 2026-05-18 call: the `paliad.deadline_rules.code` field is a
|
||||
-- SUBMISSION identifier (the event/filing within a proceeding), not the
|
||||
-- legal-citation rule code (which lives in `rule_code` / `legal_source`).
|
||||
-- Two cleanups land here:
|
||||
--
|
||||
-- 1. DATA — prefix every existing submission code with its proceeding
|
||||
-- code so submission codes carry the full hierarchical shape
|
||||
-- (e.g. `inf.soc` on `upc.inf.cfi` → `upc.inf.cfi.soc`,
|
||||
-- `de_inf.klage` on `de.inf.lg` → `de.inf.lg.klage`).
|
||||
-- Algorithm: keep the proceeding-code prefix as-is, strip the
|
||||
-- old single-segment prefix (everything before the first dot in
|
||||
-- `dr.code`) and replace it with the proceeding's full `code`.
|
||||
--
|
||||
-- 2. SCHEMA — rename `paliad.deadline_rules.code` → `submission_code`
|
||||
-- so future devs don't conflate it with `rule_code` (legal
|
||||
-- citation) or `proceeding_types.code`. Explicit name encodes the
|
||||
-- semantic taxonomy ratified in
|
||||
-- docs/design-proceeding-code-taxonomy-2026-05-18.md §0.1.
|
||||
--
|
||||
-- Materialized-view dependency: `paliad.deadline_search` (mig 051) has
|
||||
-- `dr.code AS rule_local_code` baked into its SELECT list. Postgres
|
||||
-- rejects RENAME COLUMN when a matview's column list still resolves
|
||||
-- via the old name — so the matview is dropped before the rename and
|
||||
-- recreated against `submission_code` afterwards, with every index
|
||||
-- reproduced. The mig 047 / 051 indexes are reproduced verbatim here.
|
||||
--
|
||||
-- IDs and FKs are untouched. `deadline_rules.proceeding_type_id` /
|
||||
-- `parent_id` / `spawn_proceeding_type_id` reference ids; no
|
||||
-- code-string FK exists on submission codes (the parent_id chain is on
|
||||
-- UUID `id`, not the code string), so the data UPDATE doesn't risk
|
||||
-- breaking joins.
|
||||
--
|
||||
-- Idempotent:
|
||||
-- * The data UPDATE is gated `WHERE dr.code NOT LIKE pt.code || '.%'`
|
||||
-- — rows already prefixed with their proceeding code (i.e. the
|
||||
-- migration ran before) are skipped.
|
||||
-- * The rename is wrapped in a DO block that checks column existence,
|
||||
-- so a second run is a no-op.
|
||||
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
|
||||
-- * Matview drop/recreate is DROP IF EXISTS + CREATE.
|
||||
--
|
||||
-- audit_reason wrapper required by the mig 079 audit trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 098: t-paliad-209 workstream B — prefix every paliad.deadline_rules.code with its proceeding code, then rename code → submission_code; matview deadline_search rebuilt against the new column. See docs/design-proceeding-code-taxonomy-2026-05-18.md and the t-paliad-209 task brief.',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Backup snapshot of paliad.deadline_rules BEFORE the prefix + rename.
|
||||
-- Captures the rows as they are; serves as the source for the down
|
||||
-- migration and the permanent audit anchor.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_098 AS
|
||||
SELECT *, now() AS snapshotted_at
|
||||
FROM paliad.deadline_rules;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rules_pre_098 IS
|
||||
'Snapshot of paliad.deadline_rules taken before mig 098 prefixed '
|
||||
'every `code` with its proceeding code and renamed the column to '
|
||||
'`submission_code` (t-paliad-209, 2026-05-18). Source-of-truth '
|
||||
'for the down migration; persists post-rename as the permanent '
|
||||
'audit record.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Drop the deadline_search materialized view. It bakes `dr.code AS
|
||||
-- rule_local_code` into its SELECT list (mig 051 §4), and Postgres
|
||||
-- refuses to rename a column that a matview's column list still
|
||||
-- resolves via the old name. The matview is recreated verbatim in §5
|
||||
-- against the renamed column.
|
||||
-- =============================================================================
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Data UPDATE — prefix every submission code with its proceeding
|
||||
-- code. Algorithm:
|
||||
-- * proceeding_code = pt.code
|
||||
-- * suffix = portion of dr.code after the first '.'
|
||||
-- * new code = proceeding_code || '.' || suffix
|
||||
--
|
||||
-- regexp_replace('inf.soc', '^[^.]+\.', '') = 'soc'
|
||||
-- regexp_replace('de_inf_bgh.revision', ...) = 'revision'
|
||||
--
|
||||
-- The WHERE clause skips rows that already start with `pt.code || '.'`
|
||||
-- so re-running the migration is a no-op on already-prefixed rows.
|
||||
-- Archived rows (proceeding `_archived_litigation`) get the same
|
||||
-- treatment — they end up as `_archived_litigation.<suffix>`. The
|
||||
-- shape regex in §6 only inspects active+published rows, so the
|
||||
-- archived form sits outside the constraint by design.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET code = pt.code || '.' || regexp_replace(dr.code, '^[^.]+\.', '')
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND dr.code IS NOT NULL
|
||||
AND position('.' in dr.code) > 0
|
||||
AND dr.code NOT LIKE pt.code || '.%';
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Rename the column. Guarded in a DO block so a second run (e.g. a
|
||||
-- fresh DB built up to mig 098 from an empty schema, or a manual
|
||||
-- re-apply) is a no-op rather than a hard error.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'deadline_rules'
|
||||
AND column_name = 'code'
|
||||
) THEN
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
RENAME COLUMN code TO submission_code;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. Recreate the deadline_search matview against the renamed column.
|
||||
-- Column list reproduced verbatim from mig 051 §4 with the single
|
||||
-- edit: `dr.code AS rule_local_code` → `dr.submission_code AS
|
||||
-- rule_local_code`. All indexes from mig 051 are reproduced too.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||
SELECT
|
||||
'rule'::text AS kind,
|
||||
'r:' || dr.id::text AS row_key,
|
||||
dc.id AS concept_id,
|
||||
dc.slug AS concept_slug,
|
||||
dc.name_de AS concept_name_de,
|
||||
dc.name_en AS concept_name_en,
|
||||
dc.description AS concept_description,
|
||||
dc.aliases AS concept_aliases,
|
||||
dc.party AS concept_party,
|
||||
dc.category AS concept_category,
|
||||
dc.sort_order AS concept_sort_order,
|
||||
dr.id AS rule_id,
|
||||
NULL::bigint AS trigger_event_id,
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name_de,
|
||||
pt.name_en AS proceeding_name_en,
|
||||
pt.jurisdiction AS jurisdiction,
|
||||
pt.display_order AS proceeding_display_order,
|
||||
dr.submission_code AS rule_local_code,
|
||||
dr.name AS rule_name_de,
|
||||
dr.name_en AS rule_name_en,
|
||||
dr.legal_source AS legal_source,
|
||||
dr.rule_code AS rule_code,
|
||||
dr.duration_value,
|
||||
dr.duration_unit,
|
||||
dr.timing,
|
||||
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
|
||||
WHERE dr.is_active
|
||||
AND pt.is_active
|
||||
AND pt.category = 'fristenrechner'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'trigger'::text,
|
||||
't:' || te.id::text,
|
||||
dc.id,
|
||||
dc.slug,
|
||||
dc.name_de,
|
||||
dc.name_en,
|
||||
dc.description,
|
||||
dc.aliases,
|
||||
dc.party,
|
||||
dc.category,
|
||||
dc.sort_order,
|
||||
NULL::uuid,
|
||||
te.id,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
'cross-cutting'::text,
|
||||
9999::int AS proceeding_display_order,
|
||||
te.code,
|
||||
te.name_de,
|
||||
te.name,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::int,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
dc.party
|
||||
FROM paliad.trigger_events te
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||
WHERE te.is_active;
|
||||
|
||||
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
|
||||
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
|
||||
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
|
||||
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
|
||||
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
|
||||
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. Hard assertions. Half-applied migrations would leave the rule
|
||||
-- corpus inconsistent; gate on the shape of every active+published
|
||||
-- row and on column existence so this fails loudly rather than
|
||||
-- leaving the schema in a half-renamed state.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_bad_shape integer;
|
||||
v_null_codes integer;
|
||||
v_col_exists boolean;
|
||||
BEGIN
|
||||
-- 6.1 Every active+published row has the proceeding-code-prefixed
|
||||
-- 4+-segment shape. Archived rows (`_archived_litigation` ones)
|
||||
-- keep their shorter shape by design — they're carved out.
|
||||
-- Suffix segments may include digits (existing data — e.g. EPA rule
|
||||
-- codes like `epa.opp.boa.r106` / `epa.grant.exa.r71_3` carry the
|
||||
-- statutory rule number in the suffix). Allow [a-z_0-9] per segment.
|
||||
SELECT count(*) INTO v_bad_shape
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND submission_code !~ '^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+(\..*)?$';
|
||||
IF v_bad_shape <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 098: expected every active+published deadline_rules row to match the 4+-segment submission_code shape, got % violators',
|
||||
v_bad_shape;
|
||||
END IF;
|
||||
|
||||
-- 6.2 No NULL submission_code on active+published rows that BELONG
|
||||
-- to a proceeding. Orphan rows (`proceeding_type_id IS NULL`)
|
||||
-- are cross-cutting rules without a fixed proceeding home
|
||||
-- (Wiedereinsetzung, Schriftsatznachreichung, etc.) — they
|
||||
-- legitimately carry NULL submission_code because there's no
|
||||
-- proceeding to prefix with. Exempt them.
|
||||
SELECT count(*) INTO v_null_codes
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND proceeding_type_id IS NOT NULL
|
||||
AND submission_code IS NULL;
|
||||
IF v_null_codes <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 098: expected 0 NULL submission_code on active+published rows, got %',
|
||||
v_null_codes;
|
||||
END IF;
|
||||
|
||||
-- 6.3 Column was actually renamed. Catches the case where the DO
|
||||
-- guard in §4 short-circuited because the schema hadn't yet
|
||||
-- been migrated.
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'deadline_rules'
|
||||
AND column_name = 'submission_code'
|
||||
) INTO v_col_exists;
|
||||
IF NOT v_col_exists THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 098: column paliad.deadline_rules.submission_code missing after rename — half-applied migration';
|
||||
END IF;
|
||||
END $$;
|
||||
10
internal/db/migrations/099_drop_with_po_flag.down.sql
Normal file
10
internal/db/migrations/099_drop_with_po_flag.down.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Revert mig 098 — restore the with_po condition_expr (mig 095 shape).
|
||||
-- audit_reason required: set via SET LOCAL paliad.audit_reason in tooling.
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET condition_expr = '{"flag":"with_po"}'::jsonb
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code IN ('upc.inf.cfi', 'upc.rev.cfi')
|
||||
AND dr.rule_code = 'RoP.019.1'
|
||||
AND dr.condition_expr IS NULL;
|
||||
34
internal/db/migrations/099_drop_with_po_flag.up.sql
Normal file
34
internal/db/migrations/099_drop_with_po_flag.up.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- t-paliad-207 — drop the `with_po` flag from the two RoP 19 rules.
|
||||
-- m's call 2026-05-18 (interactive session): the Einspruch (R. 19) is
|
||||
-- not flag-gated — it's just an optional submission the defendant can
|
||||
-- always make, triggered by the SoC. Same reasoning that drove the
|
||||
-- always-fire decision for the appeal-spawn rules in t-paliad-203 F2.3
|
||||
-- ("appeal is always a possibility").
|
||||
--
|
||||
-- Net effect: the calculator will surface the R.19 row on every UPC_INF
|
||||
-- / UPC_REV calc as an optional row (priority='optional' already set
|
||||
-- by mig 095, unchanged here). The save-modal pre-uncheck behaviour
|
||||
-- for optional priority handles the "user opts in" gesture without a
|
||||
-- separate flag.
|
||||
--
|
||||
-- Two rows updated; pinned by proceeding code so this stays correct
|
||||
-- after any rule-id reshuffle. Idempotent: the WHERE clause matches
|
||||
-- the live shape, so re-apply is a no-op.
|
||||
--
|
||||
-- audit_reason set_config required at the top — the mig 079 trigger
|
||||
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
|
||||
-- on any UPDATE without it. Original mig 099 author missed this and
|
||||
-- crash-looped paliad prod; this is the recovery patch.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 099: drop with_po condition_expr on the two RoP.019.1 rows — m''s call 2026-05-18 (t-paliad-207 interactive session), R.19 Einspruch is always-available not flag-gated',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET condition_expr = NULL
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code IN ('upc.inf.cfi', 'upc.rev.cfi')
|
||||
AND dr.rule_code = 'RoP.019.1'
|
||||
AND dr.condition_expr::text LIKE '%with_po%';
|
||||
26
internal/db/migrations/100_ccr_visible_rule.down.sql
Normal file
26
internal/db/migrations/100_ccr_visible_rule.down.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Revert mig 100 — remove the upc.inf.cfi.ccr informational rule and
|
||||
-- restore the sequence_order values of def_to_ccr / app_to_amend.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 100 down: revert upc.inf.cfi.ccr informational rule + sequence reshuffle',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 12
|
||||
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 13;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 11
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 12;
|
||||
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published';
|
||||
97
internal/db/migrations/100_ccr_visible_rule.up.sql
Normal file
97
internal/db/migrations/100_ccr_visible_rule.up.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- t-paliad-207 — make the Nichtigkeitswiderklage (CCR) visible in the
|
||||
-- calculator output when the `with_ccr` flag is set. m's observation
|
||||
-- 2026-05-18 (interactive session): toggling "Mit Nichtigkeitswider-
|
||||
-- klage" surfaces the response rules (def_to_ccr, reply, rejoin, …)
|
||||
-- but the triggering event itself — the act of filing the CCR — is
|
||||
-- invisible. Per R.25 VerfO the CCR is filed AS PART OF the Statement
|
||||
-- of Defence with the same 3-month deadline, so the corpus author
|
||||
-- (mig 028) skipped it. UX is the problem: users see consequences
|
||||
-- without the cause.
|
||||
--
|
||||
-- Net effect: a new `upc.inf.cfi.ccr` row with priority='informational'
|
||||
-- renders the CCR as a notice card on the timeline (no save action,
|
||||
-- no extra deadline-to-track; the SoD's deadline already covers it).
|
||||
-- Date is identical to the SoD (3 months from SoC, same anchor +
|
||||
-- duration). condition_expr={"flag":"with_ccr"} so the row only appears
|
||||
-- when the user has flagged that a CCR is being filed.
|
||||
--
|
||||
-- Sequence reshuffle: inserting at sequence_order=11 pushes
|
||||
-- def_to_ccr 11→12 and app_to_amend 12→13 so the timeline reads
|
||||
-- SoD → CCR → def_to_ccr → app_to_amend (cause before effect). The
|
||||
-- two UPDATEs are guarded by the SOURCE values so re-apply is a no-op.
|
||||
--
|
||||
-- audit_reason set_config required at the top — the deadline_rules
|
||||
-- audit trigger raises EXCEPTION 'audit reason required' on any
|
||||
-- mutation without it (cf. mig 099 hotfix history).
|
||||
--
|
||||
-- Idempotency:
|
||||
-- * INSERT uses NOT EXISTS keyed on (proceeding_type_id,
|
||||
-- submission_code, lifecycle_state='published').
|
||||
-- * UPDATEs are guarded by current sequence_order value.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 100: add upc.inf.cfi.ccr informational rule so CCR filing event is visible when with_ccr flag is set (m''s 2026-05-18 ask, t-paliad-207 interactive session)',
|
||||
true);
|
||||
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state)
|
||||
SELECT
|
||||
8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'upc.inf.cfi.ccr',
|
||||
'Nichtigkeitswiderklage',
|
||||
'Counterclaim for Revocation',
|
||||
'Widerklage des Beklagten auf Nichtigkeit des Klagepatents. Wird gemeinsam mit der Klageerwiderung (Statement of Defence) eingereicht (R.25 VerfO); selbe Frist von 3 Monaten ab Zustellung der Klage. Eigener adversarialer Schriftsatz, der die Folge-Schriftsätze (Erwiderung auf Nichtigkeitswiderklage, Replik, Duplik) auslöst.',
|
||||
'defendant',
|
||||
'filing',
|
||||
3,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.025',
|
||||
'Wird mit der Klageerwiderung eingereicht (R.25 VerfO); kein separater Fristtermin — selbes Datum wie die Klageerwiderung. Wird informativ angezeigt, damit der auslösende Schriftsatz für die Folgefristen sichtbar bleibt.',
|
||||
'Filed together with the Statement of Defence (RoP 25); no separate deadline — same date as the SoD. Surfaced informationally so the triggering submission for the downstream deadlines is visible.',
|
||||
11,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
'UPC.RoP.25.1',
|
||||
false,
|
||||
'{"flag":"with_ccr"}'::jsonb,
|
||||
'informational',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- Sequence reshuffle: bump def_to_ccr and app_to_amend by 1 so the
|
||||
-- new ccr row at 11 sits between SoD (10) and def_to_ccr. Guarded by
|
||||
-- the source values to keep idempotency.
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 12
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 11;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 13
|
||||
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 12;
|
||||
13
internal/db/migrations/101_caldav_multi_calendar.down.sql
Normal file
13
internal/db/migrations/101_caldav_multi_calendar.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Reverse of 101_caldav_multi_calendar.up.sql.
|
||||
--
|
||||
-- Drop the new join + binding tables. CASCADE on the FK references
|
||||
-- isn't needed because we drop targets before bindings, and Postgres
|
||||
-- handles RLS policies / indexes automatically on DROP TABLE.
|
||||
--
|
||||
-- The legacy paliad.appointments.caldav_uid / caldav_etag columns are
|
||||
-- untouched by the up migration, so they're untouched here too —
|
||||
-- rollback returns the system to the pre-Slice-1 state where those
|
||||
-- scalars are the single source of CalDAV truth.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.appointment_caldav_targets;
|
||||
DROP TABLE IF EXISTS paliad.user_calendar_bindings;
|
||||
350
internal/db/migrations/101_caldav_multi_calendar.up.sql
Normal file
350
internal/db/migrations/101_caldav_multi_calendar.up.sql
Normal file
@@ -0,0 +1,350 @@
|
||||
-- t-paliad-212 — Slice 1 of the CalDAV multi-calendar design (see
|
||||
-- docs/design-caldav-multi-calendar-2026-05-19.md). Pure schema +
|
||||
-- backfill; the sync engine is NOT touched in this migration. Slice 2
|
||||
-- wires the per-binding fan-out.
|
||||
--
|
||||
-- What we add:
|
||||
-- 1. paliad.user_calendar_bindings — N bindings per user, each with
|
||||
-- a scope_kind enum (all_visible / personal_only / project /
|
||||
-- client / litigation / patent / case) and an optional scope_id
|
||||
-- pointing at a paliad.projects row when the scope is hierarchy-
|
||||
-- anchored. The same Appointment can be PUT into multiple of
|
||||
-- these bindings (e.g. master cal + per-project cal).
|
||||
-- 2. paliad.appointment_caldav_targets — (appointment_id, binding_id)
|
||||
-- join carrying the per-target caldav_uid + caldav_etag. The
|
||||
-- canonical UID is still per-appointment (paliad-appointment-
|
||||
-- <uuid>@paliad.de) so the same event in N cals shares one UID.
|
||||
-- 3. Backfill: one all_visible binding per existing
|
||||
-- user_caldav_config row, plus one target row per Appointment
|
||||
-- already pushed (caldav_uid IS NOT NULL). Backfill maps the
|
||||
-- target's binding_id to the appointment creator's binding —
|
||||
-- that matches today's Phase F semantics, where the creator's
|
||||
-- sync goroutine owns the etag.
|
||||
--
|
||||
-- The scalar columns paliad.appointments.caldav_uid / caldav_etag
|
||||
-- STAY in place through Slice 1 and Slice 2. Slice 1 keeps them as
|
||||
-- read-once denormalised pointers to the default binding's target
|
||||
-- row; Slice 4 drops them after telemetry confirms no path still
|
||||
-- reads them.
|
||||
--
|
||||
-- Idempotent: every CREATE uses IF NOT EXISTS, both backfills are
|
||||
-- guarded by NOT EXISTS. Safe to re-run.
|
||||
--
|
||||
-- audit_reason set_config required at the top because m's recent
|
||||
-- migration friction had several mig failures from missing reasons.
|
||||
-- The trigger raising 'audit reason required' is on
|
||||
-- paliad.deadline_rules only — this migration doesn't touch that
|
||||
-- table — but we set the reason for symmetry per paliadin's 2026-05-19
|
||||
-- coder-shift brief.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 101: CalDAV multi-calendar schema + backfill (Slice 1 of t-paliad-212; design doc docs/design-caldav-multi-calendar-2026-05-19.md). No row mutations on existing trigger-guarded tables; this is a defensive symmetry set_config.',
|
||||
true);
|
||||
|
||||
-- =========================================================================
|
||||
-- 1. paliad.user_calendar_bindings
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.user_calendar_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Full URL or path under user_caldav_config.url. The CalDAV client
|
||||
-- resolves it against the user's server URL the same way it
|
||||
-- resolves the legacy user_caldav_config.calendar_path today.
|
||||
calendar_path text NOT NULL,
|
||||
|
||||
-- What the picker UI shows for this binding. Discovered via
|
||||
-- PROPFIND <displayname/> at add-time and cached here so we don't
|
||||
-- re-fetch every render. Default '' (Slice 1 backfill leaves it
|
||||
-- empty; Slice 2 fills it during the picker flow).
|
||||
display_name text NOT NULL DEFAULT '',
|
||||
|
||||
-- Which appointments push into this calendar. Slice 1 only really
|
||||
-- needs 'all_visible' (that's all the backfill creates) but we
|
||||
-- ship the full enum now so the schema is final and Slice 2/3
|
||||
-- don't have to ALTER it.
|
||||
scope_kind text NOT NULL,
|
||||
scope_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
|
||||
-- Only meaningful when scope_kind is hierarchy-anchored
|
||||
-- (project / client / litigation / patent / case). When true,
|
||||
-- the binding ALSO receives the user's personal (project_id IS
|
||||
-- NULL AND created_by = user_id) appointments. Ignored for
|
||||
-- 'all_visible' (already includes them) and 'personal_only'.
|
||||
include_personal boolean NOT NULL DEFAULT false,
|
||||
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
last_sync_at timestamptz,
|
||||
last_sync_error text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT user_calendar_bindings_scope_kind_chk CHECK (
|
||||
scope_kind IN ('all_visible','personal_only','project','client','litigation','patent','case')
|
||||
),
|
||||
CONSTRAINT user_calendar_bindings_scope_id_chk CHECK (
|
||||
(scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
|
||||
OR
|
||||
(scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- One binding per (user, calendar) — can't bind the same external
|
||||
-- calendar twice for the same user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_user_path_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, calendar_path);
|
||||
|
||||
-- One hierarchy binding per (user, scope_kind, scope_id) — a user
|
||||
-- can't have two bindings for the same project, but CAN have a
|
||||
-- 'project' binding for project X alongside an 'all_visible'
|
||||
-- master binding (different scope_kind ⇒ different row).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_hier_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, scope_kind, scope_id)
|
||||
WHERE scope_id IS NOT NULL;
|
||||
|
||||
-- One scope-less binding per (user, scope_kind) — at most one
|
||||
-- 'all_visible' and one 'personal_only' per user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_root_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, scope_kind)
|
||||
WHERE scope_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_calendar_bindings_user_idx
|
||||
ON paliad.user_calendar_bindings (user_id)
|
||||
WHERE enabled;
|
||||
|
||||
-- No updated_at trigger — paliad.user_caldav_config also doesn't have
|
||||
-- one. The Go service layer sets updated_at = NOW() explicitly on
|
||||
-- every write (see SaveConfig in caldav_service.go); we follow the
|
||||
-- same convention here so all CalDAV-related tables are consistent.
|
||||
|
||||
ALTER TABLE paliad.user_calendar_bindings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Same shape as user_caldav_config policies: a user sees + mutates
|
||||
-- only their own rows. auth.uid() returns the authenticated user's
|
||||
-- id (mirrors auth.uid()).
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_select ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_select ON paliad.user_calendar_bindings
|
||||
FOR SELECT TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_insert ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_insert ON paliad.user_calendar_bindings
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_update ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_update ON paliad.user_calendar_bindings
|
||||
FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_delete ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_delete ON paliad.user_calendar_bindings
|
||||
FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 2. paliad.appointment_caldav_targets
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.appointment_caldav_targets (
|
||||
appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
|
||||
binding_id uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,
|
||||
|
||||
-- 'paliad-appointment-<uuid>@paliad.de' — derived from
|
||||
-- appointment_id, identical across all bindings of one appointment.
|
||||
caldav_uid text NOT NULL,
|
||||
|
||||
-- ETag returned by the CalDAV server on the last successful PUT.
|
||||
-- NULLABLE to match the legacy paliad.appointments.caldav_etag
|
||||
-- column: some servers don't return ETag on PUT and we
|
||||
-- re-PROPFIND lazily on next tick.
|
||||
caldav_etag text,
|
||||
|
||||
last_pushed_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
PRIMARY KEY (appointment_id, binding_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS appointment_caldav_targets_binding_idx
|
||||
ON paliad.appointment_caldav_targets (binding_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS appointment_caldav_targets_uid_idx
|
||||
ON paliad.appointment_caldav_targets (caldav_uid);
|
||||
|
||||
ALTER TABLE paliad.appointment_caldav_targets ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- A target row is visible/mutable to the user who owns the binding.
|
||||
-- Appointment-side visibility is enforced separately by AppointmentService;
|
||||
-- the target is a sync-state row, scoped per-user.
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets
|
||||
FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets
|
||||
FOR UPDATE TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets
|
||||
FOR DELETE TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 3. Backfill — one all_visible binding per existing CalDAV-configured user
|
||||
-- =========================================================================
|
||||
|
||||
-- For every paliad.user_caldav_config row, insert an 'all_visible'
|
||||
-- binding that mirrors today's single-target Phase F push. The new
|
||||
-- binding inherits the legacy `calendar_path` (or, when that's empty,
|
||||
-- the server URL itself — same fallback the client uses today). The
|
||||
-- enabled flag carries over.
|
||||
--
|
||||
-- Idempotent: skipped when this user already has an all_visible binding
|
||||
-- (re-running the migration is a no-op).
|
||||
INSERT INTO paliad.user_calendar_bindings
|
||||
(user_id, calendar_path, display_name, scope_kind, scope_id, include_personal, enabled)
|
||||
SELECT
|
||||
c.user_id,
|
||||
COALESCE(NULLIF(c.calendar_path, ''), c.url),
|
||||
'',
|
||||
'all_visible',
|
||||
NULL,
|
||||
false,
|
||||
c.enabled
|
||||
FROM paliad.user_caldav_config c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.user_id = c.user_id
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 4. Backfill — one target row per already-pushed appointment
|
||||
-- =========================================================================
|
||||
|
||||
-- For every appointment with a non-null caldav_uid, insert one target
|
||||
-- row pointing at the appointment creator's new all_visible binding.
|
||||
-- That preserves the (appointment, calendar) sync state exactly as it
|
||||
-- existed before this migration.
|
||||
--
|
||||
-- Why created_by, not "every visible user": today's Phase F
|
||||
-- caldav_uid/caldav_etag scalars on appointments are populated by
|
||||
-- whoever happened to push last; in practice the etag almost always
|
||||
-- belongs to the creator's calendar because pull-side updates only
|
||||
-- run when CreatedBy = userID (caldav_service.go:449). Mapping the
|
||||
-- backfill target to the creator's binding keeps the etag pointing
|
||||
-- where it actually came from. Other users' goroutines will create
|
||||
-- their own target rows on their next sync tick after Slice 2 ships.
|
||||
--
|
||||
-- Idempotent: skipped when (appointment_id, binding_id) target already
|
||||
-- exists.
|
||||
INSERT INTO paliad.appointment_caldav_targets
|
||||
(appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at)
|
||||
SELECT
|
||||
a.id,
|
||||
b.id,
|
||||
a.caldav_uid,
|
||||
a.caldav_etag,
|
||||
a.updated_at
|
||||
FROM paliad.appointments a
|
||||
JOIN paliad.user_calendar_bindings b
|
||||
ON b.user_id = a.created_by
|
||||
AND b.scope_kind = 'all_visible'
|
||||
WHERE a.caldav_uid IS NOT NULL
|
||||
AND a.created_by IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM paliad.appointment_caldav_targets t
|
||||
WHERE t.appointment_id = a.id
|
||||
AND t.binding_id = b.id
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 5. Assertions — hard fail if the backfill didn't catch every row
|
||||
-- =========================================================================
|
||||
|
||||
-- Every paliad.user_caldav_config row must have at least one
|
||||
-- all_visible binding after this migration. If it doesn't, either a
|
||||
-- row was inserted between the backfill and the assertion (race —
|
||||
-- run is wrapped in a transaction by golang-migrate, so this can't
|
||||
-- happen) or the backfill is buggy. Hard fail either way.
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_users int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO missing_users
|
||||
FROM paliad.user_caldav_config c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.user_id = c.user_id
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
IF missing_users > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 101 assertion failed: % paliad.user_caldav_config row(s) without an all_visible binding',
|
||||
missing_users;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Every appointment with a non-null caldav_uid AND a non-null
|
||||
-- created_by must have a target row pointing at its creator's
|
||||
-- all_visible binding. created_by can be NULL on legacy rows
|
||||
-- (e.g. seed data) so we exclude those from the assertion.
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_targets int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO missing_targets
|
||||
FROM paliad.appointments a
|
||||
WHERE a.caldav_uid IS NOT NULL
|
||||
AND a.created_by IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.appointment_caldav_targets t
|
||||
JOIN paliad.user_calendar_bindings b
|
||||
ON b.id = t.binding_id
|
||||
WHERE t.appointment_id = a.id
|
||||
AND b.user_id = a.created_by
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
IF missing_targets > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 101 assertion failed: % appointment(s) with caldav_uid but no all_visible target row',
|
||||
missing_targets;
|
||||
END IF;
|
||||
END $$;
|
||||
15
internal/db/migrations/102_system_audit_log.down.sql
Normal file
15
internal/db/migrations/102_system_audit_log.down.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Revert mig 102 — drop paliad.system_audit_log and its indexes / policies.
|
||||
-- audit_reason set_config required by the mig 079 trigger pattern.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 102 down: drop paliad.system_audit_log (t-paliad-214 Slice 1 revert)',
|
||||
true);
|
||||
|
||||
DROP POLICY IF EXISTS system_audit_log_select_admin ON paliad.system_audit_log;
|
||||
DROP POLICY IF EXISTS system_audit_log_select_self ON paliad.system_audit_log;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.system_audit_log_event_type_created_at_idx;
|
||||
DROP INDEX IF EXISTS paliad.system_audit_log_actor_id_created_at_idx;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.system_audit_log;
|
||||
79
internal/db/migrations/102_system_audit_log.up.sql
Normal file
79
internal/db/migrations/102_system_audit_log.up.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- t-paliad-214 Slice 1 — create paliad.system_audit_log as the 6th source
|
||||
-- in the AuditService.ListEntries union. Captures org-wide / scope-spanning
|
||||
-- actions that don't naturally belong on any single project_events row.
|
||||
--
|
||||
-- Design: docs/design-paliad-data-export-2026-05-19.md §4.
|
||||
--
|
||||
-- Initial use case is data-export auditing (every export run writes one row,
|
||||
-- before the artifact is generated, then is patched with row_counts +
|
||||
-- file_size_bytes on completion). The table is intentionally generic
|
||||
-- (`event_type` + `metadata jsonb`) so future org-wide actions can land here
|
||||
-- without a new table per concept.
|
||||
--
|
||||
-- Idempotent: CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS.
|
||||
-- audit_reason set_config required by the mig 079 trigger pattern when
|
||||
-- migrations touch the database — universal convention even for pure-DDL
|
||||
-- migrations.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 102: add paliad.system_audit_log for org-wide / scope-spanning audit events (t-paliad-214 Slice 1 — data-export audit chain)',
|
||||
true);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.system_audit_log (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_type text NOT NULL,
|
||||
actor_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
-- actor_email is captured at write time so the audit row survives a
|
||||
-- subsequent user-deletion (FK above sets NULL, but the historical
|
||||
-- identity stays readable).
|
||||
actor_email text NOT NULL,
|
||||
scope text NOT NULL CHECK (scope IN ('org', 'project', 'personal')),
|
||||
-- scope_root is the project_id for scope='project'; NULL otherwise.
|
||||
-- Not a hard FK because we want the audit row to outlive a project
|
||||
-- deletion. Resolution happens at read time.
|
||||
scope_root uuid,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Indexes mirror the read patterns:
|
||||
-- - actor lookup ("show me what I've exported"): actor_id + created_at desc
|
||||
-- - scope rollup ("how much org-wide activity in the last 30 days"): event_type + created_at desc
|
||||
CREATE INDEX IF NOT EXISTS system_audit_log_actor_id_created_at_idx
|
||||
ON paliad.system_audit_log (actor_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS system_audit_log_event_type_created_at_idx
|
||||
ON paliad.system_audit_log (event_type, created_at DESC);
|
||||
|
||||
-- RLS: every authenticated user can SELECT their own rows (actor_id = auth.uid());
|
||||
-- global_admins see everything. INSERT / UPDATE happen via the Go service path
|
||||
-- under the migration-runner role (no end-user write surface) so no INSERT
|
||||
-- policy is needed for end users.
|
||||
ALTER TABLE paliad.system_audit_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS system_audit_log_select_self ON paliad.system_audit_log;
|
||||
CREATE POLICY system_audit_log_select_self ON paliad.system_audit_log
|
||||
FOR SELECT
|
||||
USING (actor_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS system_audit_log_select_admin ON paliad.system_audit_log;
|
||||
CREATE POLICY system_audit_log_select_admin ON paliad.system_audit_log
|
||||
FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.system_audit_log IS
|
||||
'Org-wide / scope-spanning audit events. 6th source of AuditService union. Generic event_type + metadata jsonb. Initial users: data-export audit chain (t-paliad-214). Audit rows persist forever; artifact retention is separate.';
|
||||
|
||||
COMMENT ON COLUMN paliad.system_audit_log.actor_email IS
|
||||
'Captured at write time so the audit row survives user deletion (actor_id FK uses ON DELETE SET NULL).';
|
||||
|
||||
COMMENT ON COLUMN paliad.system_audit_log.scope_root IS
|
||||
'project_id for scope=project; NULL otherwise. Not a hard FK so audit survives project deletion.';
|
||||
27
internal/db/migrations/103_approval_suggest_changes.down.sql
Normal file
27
internal/db/migrations/103_approval_suggest_changes.down.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Reverse of 103_approval_suggest_changes.up.sql.
|
||||
--
|
||||
-- Drops the previous_request_id index + column, drops counter_payload, and
|
||||
-- restores the original status CHECK (without 'changes_requested'). If any
|
||||
-- live rows are at status='changes_requested' OR carry a non-NULL
|
||||
-- counter_payload OR previous_request_id, the down will fail on the CHECK
|
||||
-- restore. That is intentional: it forces an explicit cleanup decision
|
||||
-- before tearing the schema back.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 103 DOWN: revert suggest-changes schema extensions (t-paliad-216)',
|
||||
true);
|
||||
|
||||
DROP INDEX IF EXISTS paliad.approval_requests_previous_idx;
|
||||
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP COLUMN IF EXISTS previous_request_id;
|
||||
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP COLUMN IF EXISTS counter_payload;
|
||||
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD CONSTRAINT approval_requests_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded'));
|
||||
57
internal/db/migrations/103_approval_suggest_changes.up.sql
Normal file
57
internal/db/migrations/103_approval_suggest_changes.up.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- t-paliad-216 Slice A — add the "Suggest changes" action to the approval
|
||||
-- flow alongside Approve / Reject / Revoke. Design:
|
||||
-- docs/design-approval-suggest-changes-2026-05-19.md.
|
||||
--
|
||||
-- Mental model (m's 2026-05-19 decisions, §0a of the design doc):
|
||||
-- "Suggest changes" is not a soft-reject with a hint. It is the approver
|
||||
-- AUTHORING A COUNTER-PROPOSAL that gets re-injected into the approval
|
||||
-- flow as a fresh `pending` row. The original requester (no longer the
|
||||
-- new row's requested_by) becomes potentially-eligible to approve the
|
||||
-- counter — 4-Augen still holds via the standard self-approval guard.
|
||||
--
|
||||
-- Three schema additions to paliad.approval_requests:
|
||||
-- 1. Extend the status CHECK to allow 'changes_requested'.
|
||||
-- 2. counter_payload jsonb NULL — the approver's edited values,
|
||||
-- stored on the OLD (changes_requested) row so the audit chain
|
||||
-- can show "approver edited X, Y, Z" without joining forward.
|
||||
-- Also used as the `payload` for the NEW row spawned in the same
|
||||
-- tx by ApprovalService.SuggestChanges.
|
||||
-- 3. previous_request_id uuid NULL FK — back-pointer on the NEW row
|
||||
-- to the OLD (changes_requested) row that spawned it. ON DELETE
|
||||
-- SET NULL keeps a survivor row intact if either end is ever
|
||||
-- pruned. Partial index covers chain traversal.
|
||||
--
|
||||
-- The set_config('paliad.audit_reason', ...) line is the universal
|
||||
-- convention for paliad migrations (mig 079 trigger pattern) — even
|
||||
-- pure-DDL migrations set it so an audit trigger that fires on any
|
||||
-- migration-touched table has a non-NULL reason string to record.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 103: add suggest-changes action — extend approval_requests.status CHECK with changes_requested, add counter_payload jsonb + previous_request_id FK (t-paliad-216 Slice A)',
|
||||
true);
|
||||
|
||||
-- 1. Extend approval_requests.status CHECK.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD CONSTRAINT approval_requests_status_check
|
||||
CHECK (status IN (
|
||||
'pending', 'approved', 'rejected', 'revoked', 'superseded', 'changes_requested'
|
||||
));
|
||||
|
||||
-- 2. counter_payload — the approver's edited values when suggesting
|
||||
-- changes. Stays NULL for every status other than changes_requested.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN counter_payload jsonb;
|
||||
|
||||
-- 3. previous_request_id — back-pointer FK. NULL for first-attempt rows;
|
||||
-- set to the prior (changes_requested) row's id on the NEW row spawned
|
||||
-- by SuggestChanges. ON DELETE SET NULL keeps survivor rows intact.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN previous_request_id uuid
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS approval_requests_previous_idx
|
||||
ON paliad.approval_requests (previous_request_id)
|
||||
WHERE previous_request_id IS NOT NULL;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Revert mig 104 — restore the bracket-bearing Einspruch names and
|
||||
-- flip the CCR priority back to 'informational'.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 104 down: restore "Einspruch (R. 19 VerfO)" and "Einspruch (R. 19 i.V.m. R. 46 VerfO)" names + flip upc.inf.cfi.ccr priority back to informational',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection (RoP 19)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch (R. 19 VerfO)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET priority = 'informational'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.ccr'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.priority = 'optional';
|
||||
@@ -0,0 +1,89 @@
|
||||
-- t-paliad-207 (m's interactive session) — two label/priority polish
|
||||
-- fixes on upc.inf.cfi / upc.rev.cfi:
|
||||
--
|
||||
-- 1. **CCR priority informational → optional.** m's correction
|
||||
-- 2026-05-18 18:01: the Nichtigkeitswiderklage is a substantive
|
||||
-- defensive choice the defendant makes — not just an informational
|
||||
-- notice. priority='optional' renders it as an unchecked save row
|
||||
-- the user can opt into. The fermi amend (commit e8d658a) flipping
|
||||
-- this didn't land in main — paliadin's merge of mig 100 (commit
|
||||
-- c10f8cf, merge 4ddcd28) picked up the pre-amend 'informational'
|
||||
-- version. This is the recovery.
|
||||
--
|
||||
-- 2. **Strip rule citation from Einspruch names.** m's correction
|
||||
-- 2026-05-18 18:08: every other rule name in the corpus carries
|
||||
-- the act-name without a parenthetical rule cite (Klageerwiderung,
|
||||
-- Antrag auf Patentänderung, Replik, etc.). The Einspruch rule
|
||||
-- names are the outliers:
|
||||
-- upc.inf.cfi.prelim "Einspruch (R. 19 VerfO)" → "Einspruch"
|
||||
-- upc.rev.cfi.prelim "Einspruch (R. 19 i.V.m. R. 46 VerfO)" → "Einspruch"
|
||||
-- and EN equivalents:
|
||||
-- "Preliminary Objection (RoP 19)" → "Preliminary Objection"
|
||||
-- "Preliminary Objection (RoP 19 in conjunction with RoP 46)"
|
||||
-- → "Preliminary Objection"
|
||||
-- The legal_source / rule_code columns already carry the citation
|
||||
-- and render in the deadline card's meta line, so the name stays
|
||||
-- clean. The R.46-i.V.m. distinction is preserved in the legal
|
||||
-- source field (RoP.019.1 for both — m may want to further
|
||||
-- differentiate; flagged in description text instead).
|
||||
--
|
||||
-- audit_reason set_config required at the top — the deadline_rules
|
||||
-- audit trigger raises EXCEPTION 'audit reason required' on any
|
||||
-- mutation without it (cf. mig 099 hotfix history).
|
||||
--
|
||||
-- Idempotency:
|
||||
-- * Priority UPDATE guarded on the current 'informational' value.
|
||||
-- * Name UPDATEs guarded on the current parenthetical-bearing names.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 104: flip upc.inf.cfi.ccr priority informational→optional + strip rule-cite brackets from R.19 Einspruch names on both upc.inf.cfi.prelim and upc.rev.cfi.prelim (m''s corrections 2026-05-18, t-paliad-207 interactive session)',
|
||||
true);
|
||||
|
||||
-- 1) Flip CCR priority
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET priority = 'optional'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.ccr'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.priority = 'informational';
|
||||
|
||||
-- 2a) Strip "(R. 19 VerfO)" from upc.inf.cfi.prelim DE/EN names
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch (R. 19 VerfO)';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection (RoP 19)';
|
||||
|
||||
-- 2b) Strip "(R. 19 i.V.m. R. 46 VerfO)" from upc.rev.cfi.prelim DE/EN names
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Revert mig 105 — restore the pre-mig-105 sequence_order values
|
||||
-- (post-mig-100 state). Same two-phase swap pattern.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 105 down: restore pre-track-aware sequence_order on upc.inf.cfi rules',
|
||||
true);
|
||||
|
||||
-- Phase 1: park
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1011 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 20;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1012 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 22;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1013 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 30;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1020 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 12;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1021 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 32;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1022 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 24;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1030 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 14;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1031 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 34;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1032 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 26;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1033 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 36;
|
||||
|
||||
-- Phase 2: assign originals
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 11 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1011;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 12 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1012;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 13 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1013;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 20 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1020;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 21 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1021;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 22 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1022;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 30 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1030;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 31 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1031;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 32 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1032;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 33 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1033;
|
||||
211
internal/db/migrations/105_upc_inf_track_aware_sequence.up.sql
Normal file
211
internal/db/migrations/105_upc_inf_track_aware_sequence.up.sql
Normal file
@@ -0,0 +1,211 @@
|
||||
-- t-paliad-207 — re-sequence upc.inf.cfi rules so within any tied-date
|
||||
-- group the infringement-track responses sit ABOVE the revocation-
|
||||
-- track responses ABOVE the amendment-track responses. m's ask
|
||||
-- 2026-05-18 18:08: "the infringement parts (like Replik) should show
|
||||
-- above the part for the revocation (Erwiderung Nichtigkeitswider-
|
||||
-- klage)".
|
||||
--
|
||||
-- Three tracks coexist on upc.inf.cfi once the with_ccr / with_amend
|
||||
-- flags are set. They share calendar dates because R.29 / R.30 / R.32
|
||||
-- all key off the SoD or its descendants. The current sequence_orders
|
||||
-- (post-mig 100) interleave them; the user sees Erwiderung-zur-CCR
|
||||
-- before Replik even though Replik is the infringement-side response
|
||||
-- to the same triggering event.
|
||||
--
|
||||
-- New sequence_order assignment (preserves the soc=0, prelim=5,
|
||||
-- sod=10, ccr=11 anchors at the head; phase markers interim/oral/
|
||||
-- decision/cost_app/appeal_spawn keep their existing 40/50/60/70/80
|
||||
-- slots at the tail):
|
||||
--
|
||||
-- Old → New submission_code track date
|
||||
-- --- --- --------------- ----- ----
|
||||
-- 0 0 upc.inf.cfi.soc — D+0
|
||||
-- 5 5 upc.inf.cfi.prelim — D+1mo
|
||||
-- 10 10 upc.inf.cfi.sod infringement D+3mo
|
||||
-- 11 20 upc.inf.cfi.ccr revocation D+3mo
|
||||
-- 20 12 upc.inf.cfi.reply infringement D+5mo ← MOVED UP
|
||||
-- 12 22 upc.inf.cfi.def_to_ccr revocation D+5mo
|
||||
-- 13 30 upc.inf.cfi.app_to_amend amendment D+5mo
|
||||
-- 30 14 upc.inf.cfi.rejoin infringement D+6mo ← MOVED UP
|
||||
-- 22 24 upc.inf.cfi.reply_def_ccr revocation D+7mo
|
||||
-- 21 32 upc.inf.cfi.def_to_amend amendment D+7mo
|
||||
-- 32 26 upc.inf.cfi.rejoin_reply_ccr revocation D+8mo
|
||||
-- 31 34 upc.inf.cfi.reply_def_amd amendment D+8mo
|
||||
-- 33 36 upc.inf.cfi.rejoin_amd amendment D+9mo
|
||||
-- 40 40 upc.inf.cfi.interim phase later
|
||||
-- 50 50 upc.inf.cfi.oral phase later
|
||||
-- 60 60 upc.inf.cfi.decision phase later
|
||||
-- 70 70 upc.inf.cfi.cost_app phase later
|
||||
-- 80 80 upc.inf.cfi.appeal_spawn phase later
|
||||
--
|
||||
-- Order within each tied-date group after the reshuffle:
|
||||
-- D+3mo: sod(10), ccr(20) — SoD then its CCR
|
||||
-- D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
|
||||
-- D+7mo: reply_def_ccr(24), def_to_amend(32) — rev → amd
|
||||
-- D+8mo: rejoin_reply_ccr(26), reply_def_amd(34) — rev → amd
|
||||
--
|
||||
-- (no infringement-track rule at +7mo or +8mo so revocation leads
|
||||
-- those dates; rejoin sits alone at +6mo so it has no peers to order
|
||||
-- against.)
|
||||
--
|
||||
-- audit_reason set_config required at the top — the deadline_rules
|
||||
-- audit trigger raises EXCEPTION 'audit reason required' on any
|
||||
-- mutation without it (cf. mig 099 hotfix history).
|
||||
--
|
||||
-- Idempotency: every UPDATE is guarded by both the submission_code
|
||||
-- AND the SOURCE sequence_order, so re-apply is a no-op once the new
|
||||
-- numbers are in place.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 105: re-sequence upc.inf.cfi rules track-aware (infringement → revocation → amendment within tied-date groups; m''s 2026-05-18 ask, t-paliad-207 interactive session)',
|
||||
true);
|
||||
|
||||
-- Two-phase swap to avoid sequence collisions during the UPDATE
|
||||
-- (otherwise two rules can briefly share a sequence_order if Postgres
|
||||
-- evaluates them in parallel). Phase 1: move every reshuffled rule to
|
||||
-- a high temporary number (1000+). Phase 2: assign final numbers.
|
||||
|
||||
-- ─── Phase 1: park reshuffled rules at 1000+ ────────────────────────
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1011
|
||||
WHERE submission_code = 'upc.inf.cfi.ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 11;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1012
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 12;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1013
|
||||
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 13;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1020
|
||||
WHERE submission_code = 'upc.inf.cfi.reply'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 20;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1021
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 21;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1022
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 22;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1030
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 30;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1031
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 31;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1032
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 32;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1033
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 33;
|
||||
|
||||
-- ─── Phase 2: assign final track-aware numbers ──────────────────────
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 12
|
||||
WHERE submission_code = 'upc.inf.cfi.reply'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1020;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 14
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1030;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 20
|
||||
WHERE submission_code = 'upc.inf.cfi.ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1011;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 22
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1012;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 24
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1022;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 26
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1032;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 30
|
||||
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1013;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 32
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1021;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 34
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1031;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 36
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1033;
|
||||
28
internal/db/migrations/106_add_madrid_office.down.sql
Normal file
28
internal/db/migrations/106_add_madrid_office.down.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Revert mig 106 — drop 'madrid' from the office CHECK constraints.
|
||||
--
|
||||
-- Will fail if any users.office or partner_units.office row carries
|
||||
-- 'madrid' — that's intentional (the down has no opinion on the data;
|
||||
-- caller must clean up first or accept the failure).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 106 down: restore pre-madrid office CHECK on users + partner_units',
|
||||
true);
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_office_check;
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan'
|
||||
));
|
||||
|
||||
ALTER TABLE paliad.partner_units
|
||||
DROP CONSTRAINT IF EXISTS partner_units_office_check;
|
||||
ALTER TABLE paliad.partner_units
|
||||
ADD CONSTRAINT partner_units_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan'
|
||||
));
|
||||
42
internal/db/migrations/106_add_madrid_office.up.sql
Normal file
42
internal/db/migrations/106_add_madrid_office.up.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- mig 106 — add 'madrid' to firm office CHECK constraints
|
||||
--
|
||||
-- m's ask 2026-05-20 09:42: add Madrid as an HLC office, alongside the
|
||||
-- existing seven (munich, duesseldorf, hamburg, amsterdam, london,
|
||||
-- paris, milan). Two active CHECK constraints to extend:
|
||||
-- - paliad.users.office (mig 002)
|
||||
-- - paliad.partner_units.office (mig 018; renamed mig 024 + mig 027)
|
||||
--
|
||||
-- The Go-side source of truth lives in internal/offices/offices.go;
|
||||
-- this migration keeps the DB in sync.
|
||||
--
|
||||
-- Long-term, the admin area will let firms manage their own office
|
||||
-- list (separate issue) — but for now the list is hard-coded here
|
||||
-- + offices.go.
|
||||
--
|
||||
-- Non-blocking: extending a CHECK constraint is a metadata-only change
|
||||
-- on a small enum-style column.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 106: add madrid to firm office CHECK on users + partner_units',
|
||||
true);
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_office_check;
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan',
|
||||
'madrid'
|
||||
));
|
||||
|
||||
ALTER TABLE paliad.partner_units
|
||||
DROP CONSTRAINT IF EXISTS partner_units_office_check;
|
||||
ALTER TABLE paliad.partner_units
|
||||
ADD CONSTRAINT partner_units_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan',
|
||||
'madrid'
|
||||
));
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Reverse of 107: drop the binding_id column from caldav_sync_log.
|
||||
-- The associated index drops automatically with the column.
|
||||
|
||||
ALTER TABLE paliad.caldav_sync_log
|
||||
DROP COLUMN IF EXISTS binding_id;
|
||||
53
internal/db/migrations/107_caldav_sync_log_binding_id.up.sql
Normal file
53
internal/db/migrations/107_caldav_sync_log_binding_id.up.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- t-paliad-212 — Slice 2a of CalDAV multi-calendar.
|
||||
--
|
||||
-- Adds paliad.caldav_sync_log.binding_id so the per-tick sync log
|
||||
-- records which binding the entry belongs to. NULL for legacy rows
|
||||
-- and for "global" log entries that aren't per-binding (Slice 2a
|
||||
-- still writes one row per user per tick — Slice 2b's sync rewrite
|
||||
-- moves to one row per (user, binding) per tick).
|
||||
--
|
||||
-- FK uses ON DELETE SET NULL so deleting a binding doesn't blow away
|
||||
-- its historical sync log (audit trail wins over referential tidiness).
|
||||
--
|
||||
-- Idempotent: column added via DO block with information_schema check.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 107: add caldav_sync_log.binding_id for per-binding sync log entries (t-paliad-212 Slice 2a)',
|
||||
true);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'caldav_sync_log'
|
||||
AND column_name = 'binding_id'
|
||||
) THEN
|
||||
ALTER TABLE paliad.caldav_sync_log
|
||||
ADD COLUMN binding_id uuid
|
||||
REFERENCES paliad.user_calendar_bindings(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS caldav_sync_log_binding_idx
|
||||
ON paliad.caldav_sync_log (binding_id, occurred_at DESC)
|
||||
WHERE binding_id IS NOT NULL;
|
||||
|
||||
-- Assertion: column exists and is nullable.
|
||||
DO $$
|
||||
DECLARE
|
||||
col_nullable text;
|
||||
BEGIN
|
||||
SELECT is_nullable INTO col_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'caldav_sync_log'
|
||||
AND column_name = 'binding_id';
|
||||
IF col_nullable IS NULL THEN
|
||||
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id missing';
|
||||
END IF;
|
||||
IF col_nullable <> 'YES' THEN
|
||||
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id is NOT NULL (must be nullable)';
|
||||
END IF;
|
||||
END $$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user