Compare commits
45 Commits
mai/ohm/wo
...
mai/copern
| Author | SHA1 | Date | |
|---|---|---|---|
| d6caa490dc | |||
| 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 | |||
| edcf41d203 | |||
| 391be09b1e |
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 ./...
|
||||
@@ -177,6 +177,10 @@ func main() {
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(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.
|
||||
|
||||
170
cmd/server/main_smoke_test.go
Normal file
170
cmd/server/main_smoke_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// 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. The migration tracker (public.paliad_schema_migrations) advances to
|
||||
// the highest *.up.sql version on disk — no migrations were silently
|
||||
// skipped, no "dirty=true" stragglers left behind.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
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 tracker advanced to the highest *.up.sql version we
|
||||
// embed. If a migration was silently skipped or the tracker is dirty,
|
||||
// the prod container would crash-loop — this turns that into a test
|
||||
// failure with a precise reason.
|
||||
expected := highestEmbeddedMigrationVersion(t)
|
||||
got, dirty := readTrackerVersion(t, url)
|
||||
if dirty {
|
||||
t.Errorf("tracker reports dirty=true at version %d — investigate before deploying", got)
|
||||
}
|
||||
if got != expected {
|
||||
t.Errorf("tracker at version %d; expected %d (highest *.up.sql on disk). "+
|
||||
"A migration was skipped or applied out of order.",
|
||||
got, expected)
|
||||
}
|
||||
|
||||
// (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)
|
||||
}
|
||||
}
|
||||
|
||||
// highestEmbeddedMigrationVersion finds max(N) over every NNN_*.up.sql
|
||||
// file in internal/db/migrations/ on disk. Used as the expected tracker
|
||||
// version after a clean apply. We read from disk (not the embed.FS in
|
||||
// the db package — it's unexported) since the test runs from the repo.
|
||||
func highestEmbeddedMigrationVersion(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[len(versions)-1]
|
||||
}
|
||||
|
||||
// readTrackerVersion fetches the lone row from the tracker. golang-migrate
|
||||
// keeps exactly one row; if we ever see zero or more, that's the dirty-state
|
||||
// the test is designed to flag.
|
||||
func readTrackerVersion(t *testing.T, url string) (version int, dirty bool) {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
row := conn.QueryRow(`SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`)
|
||||
if err := row.Scan(&version, &dirty); err != nil {
|
||||
t.Fatalf("read tracker: %v", err)
|
||||
}
|
||||
return version, dirty
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
BIN
frontend/public/patentstyle/HL-Patents-Style.dotm
Normal file
BIN
frontend/public/patentstyle/HL-Patents-Style.dotm
Normal file
Binary file not shown.
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"
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -112,11 +112,14 @@ async function calculate() {
|
||||
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
|
||||
const priorityDate = selectedType === "epa.grant.exa" && priorityInput?.value ? priorityInput.value : "";
|
||||
|
||||
// Flags — three proceeding-specific checkboxes:
|
||||
// Flags — proceeding-specific checkboxes:
|
||||
// upc.inf.cfi: with_ccr (always available); with_amend (nested under
|
||||
// with_ccr — R.30 application is only available with a CCR).
|
||||
// upc.rev.cfi: with_amend (R.49.2.a) and with_cci (R.49.2.b) as two
|
||||
// independent gates; both can be on simultaneously.
|
||||
// R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18 call): it's
|
||||
// an always-available optional submission, surfaced as priority='optional'
|
||||
// without a separate checkbox.
|
||||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||||
const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
|
||||
|
||||
@@ -235,11 +235,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.disc.cfi": "Bucheinsicht",
|
||||
"deadlines.upc.apl.cost": "Berufung Kosten",
|
||||
"deadlines.upc.apl.order": "Berufung Anordnungen",
|
||||
"deadlines.de.inf.lg": "Verletzungsklage (LG)",
|
||||
"deadlines.de.inf.olg": "Berufung OLG",
|
||||
"deadlines.de.inf.bgh": "Revision/NZB BGH",
|
||||
"deadlines.de.null.bpatg": "Nichtigkeitsverfahren",
|
||||
"deadlines.de.null.bgh": "Berufung BGH (Nichtigk.)",
|
||||
"deadlines.de.group.inf": "Verletzungsverfahren",
|
||||
"deadlines.de.group.null": "Nichtigkeitsverfahren",
|
||||
"deadlines.de.inf.lg": "LG (1. Instanz)",
|
||||
"deadlines.de.inf.olg": "OLG (Berufung)",
|
||||
"deadlines.de.inf.bgh": "BGH (Revision / NZB)",
|
||||
"deadlines.de.null.bpatg": "BPatG (1. Instanz)",
|
||||
"deadlines.de.null.bgh": "BGH (Berufung)",
|
||||
"deadlines.epa.opp.opd": "Einspruchsverfahren",
|
||||
"deadlines.epa.opp.boa": "Beschwerdeverfahren",
|
||||
"deadlines.epa.grant.exa": "EP-Erteilungsverfahren",
|
||||
@@ -837,6 +839,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"cal.month.9": "Oktober",
|
||||
"cal.month.10": "November",
|
||||
"cal.month.11": "Dezember",
|
||||
"cal.view.month": "Monat",
|
||||
"cal.view.week": "Woche",
|
||||
"cal.view.day": "Tag",
|
||||
"cal.month.prev": "Vorheriger Monat",
|
||||
"cal.month.next": "Nächster Monat",
|
||||
"cal.week.prev": "Vorherige Woche",
|
||||
"cal.week.next": "Nächste Woche",
|
||||
"cal.day.prev": "Vorheriger Tag",
|
||||
"cal.day.next": "Nächster Tag",
|
||||
"cal.day.back_to_month": "Zurück zum Monat",
|
||||
"cal.day.open_day": "Tagesansicht öffnen",
|
||||
"cal.day.no_entries": "Keine Einträge an diesem Tag.",
|
||||
|
||||
// Akten detail — Fristen tab (Phase E)
|
||||
|
||||
@@ -1112,6 +1126,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.tab.profil": "Profil",
|
||||
"einstellungen.tab.benachrichtigungen": "Benachrichtigungen",
|
||||
"einstellungen.tab.caldav": "CalDAV",
|
||||
"einstellungen.tab.export": "Datenexport",
|
||||
"einstellungen.export.subtitle": "Laden Sie Ihre pers\u00f6nlichen Paliad-Daten als Excel- + JSON- + CSV-Paket herunter. Enthalten ist alles, was Sie aktuell sehen k\u00f6nnen \u2014 Ihre Projekte, Fristen, Termine, Notizen, Genehmigungen und Einstellungen.",
|
||||
"einstellungen.export.heading": "Pers\u00f6nlicher Datenexport",
|
||||
"einstellungen.export.what": "Das Paket enth\u00e4lt Ihre sichtbaren Daten in drei Formaten in einem .zip:",
|
||||
"einstellungen.export.bullet.xlsx": "paliad-export.xlsx \u2014 eine Excel-Mappe pro Entit\u00e4t.",
|
||||
"einstellungen.export.bullet.json": "paliad-export.json \u2014 maschinenlesbare Kopie f\u00fcr Skripte und Tools.",
|
||||
"einstellungen.export.bullet.csv": "csv/<sheet>.csv \u2014 Tabellen einzeln als CSV (UTF-8 mit BOM).",
|
||||
"einstellungen.export.scope": "Umfang: alles, was Sie aktuell in Paliad sehen k\u00f6nnen (Sichtbarkeit zum Zeitpunkt des Exports). Passw\u00f6rter, CalDAV-Zugangsdaten und andere Geheimnisse werden nie exportiert.",
|
||||
"einstellungen.export.audit": "Jeder Export wird im Audit-Log protokolliert.",
|
||||
"einstellungen.export.button": "Daten exportieren",
|
||||
"einstellungen.export.started": "Download gestartet. Falls nichts passiert, pr\u00fcfen Sie Ihren Browser-Downloadordner.",
|
||||
"projects.title": "Projekte \u2014 Paliad",
|
||||
"projects.heading": "Projekte",
|
||||
"projects.subtitle": "Mandanten, Streitsachen, Patente und Verfahren \u2014 hierarchisch organisiert.",
|
||||
@@ -1591,7 +1616,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"events.toggle.deadline": "Fristen",
|
||||
"events.toggle.appointment": "Termine",
|
||||
"events.toggle.all": "Beides",
|
||||
"events.filter.status.all": "Alle",
|
||||
"events.filter.status.all": "Alle (auch vergangene)",
|
||||
"events.filter.status.upcoming": "Ab heute",
|
||||
"events.summary.later": "Sp\u00e4ter",
|
||||
"events.col.date": "Datum",
|
||||
"events.col.location": "Ort",
|
||||
@@ -2237,6 +2263,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.shape.calendar": "Kalender",
|
||||
"views.shape.timeline": "Timeline",
|
||||
"views.timeline.caveat.body": "Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.",
|
||||
"views.timeline.zoom.label": "Zoom",
|
||||
"views.timeline.zoom.in": "Heranzoomen",
|
||||
"views.timeline.zoom.out": "Herauszoomen",
|
||||
"views.timeline.zoom.1y": "±1 J.",
|
||||
"views.timeline.zoom.2y": "±2 J.",
|
||||
"views.timeline.zoom.all": "Alles",
|
||||
"views.save_as": "Als Ansicht speichern",
|
||||
"views.action.edit": "Bearbeiten",
|
||||
"views.empty.title": "Keine Einträge gefunden.",
|
||||
@@ -2802,11 +2834,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.disc.cfi": "Lay-open Books",
|
||||
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
|
||||
"deadlines.upc.apl.order": "Order Appeal (15-day)",
|
||||
"deadlines.de.inf.lg": "Infringement (Regional Court)",
|
||||
"deadlines.de.inf.olg": "Appeal OLG",
|
||||
"deadlines.de.inf.bgh": "Revision / NZB BGH",
|
||||
"deadlines.de.null.bpatg": "Nullity",
|
||||
"deadlines.de.null.bgh": "Appeal BGH (Nullity)",
|
||||
"deadlines.de.group.inf": "Infringement proceedings",
|
||||
"deadlines.de.group.null": "Nullity proceedings",
|
||||
"deadlines.de.inf.lg": "LG (1st instance)",
|
||||
"deadlines.de.inf.olg": "OLG (Appeal)",
|
||||
"deadlines.de.inf.bgh": "BGH (Revision / NZB)",
|
||||
"deadlines.de.null.bpatg": "BPatG (1st instance)",
|
||||
"deadlines.de.null.bgh": "BGH (Appeal)",
|
||||
"deadlines.epa.opp.opd": "Opposition",
|
||||
"deadlines.epa.opp.boa": "Appeal",
|
||||
"deadlines.epa.grant.exa": "Grant Procedure",
|
||||
@@ -3404,6 +3438,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"cal.month.9": "October",
|
||||
"cal.month.10": "November",
|
||||
"cal.month.11": "December",
|
||||
"cal.view.month": "Month",
|
||||
"cal.view.week": "Week",
|
||||
"cal.view.day": "Day",
|
||||
"cal.month.prev": "Previous month",
|
||||
"cal.month.next": "Next month",
|
||||
"cal.week.prev": "Previous week",
|
||||
"cal.week.next": "Next week",
|
||||
"cal.day.prev": "Previous day",
|
||||
"cal.day.next": "Next day",
|
||||
"cal.day.back_to_month": "Back to month",
|
||||
"cal.day.open_day": "Open day view",
|
||||
"cal.day.no_entries": "Nothing scheduled this day.",
|
||||
|
||||
// Akten detail — Fristen tab (Phase E)
|
||||
|
||||
@@ -3667,6 +3713,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.tab.profil": "Profile",
|
||||
"einstellungen.tab.benachrichtigungen": "Notifications",
|
||||
"einstellungen.tab.caldav": "CalDAV",
|
||||
"einstellungen.tab.export": "Data export",
|
||||
"einstellungen.export.subtitle": "Download your personal Paliad data as an Excel + JSON + CSV bundle. The package contains everything you can currently see \u2014 your projects, deadlines, appointments, notes, approvals and settings.",
|
||||
"einstellungen.export.heading": "Personal data export",
|
||||
"einstellungen.export.what": "The package contains your visible data in three formats in one .zip:",
|
||||
"einstellungen.export.bullet.xlsx": "paliad-export.xlsx \u2014 one Excel sheet per entity.",
|
||||
"einstellungen.export.bullet.json": "paliad-export.json \u2014 machine-readable copy for scripts and tools.",
|
||||
"einstellungen.export.bullet.csv": "csv/<sheet>.csv \u2014 individual tables as CSV (UTF-8 with BOM).",
|
||||
"einstellungen.export.scope": "Scope: everything you can currently see in Paliad (visibility at the moment of export). Passwords, CalDAV credentials and other secrets are never exported.",
|
||||
"einstellungen.export.audit": "Every export is logged in the audit log.",
|
||||
"einstellungen.export.button": "Export data",
|
||||
"einstellungen.export.started": "Download started. If nothing happens, check your browser's downloads folder.",
|
||||
"projects.title": "Projects \u2014 Paliad",
|
||||
"projects.heading": "Projects",
|
||||
"projects.subtitle": "Clients, litigations, patents and cases \u2014 organised hierarchically.",
|
||||
@@ -4142,7 +4199,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"events.toggle.deadline": "Deadlines",
|
||||
"events.toggle.appointment": "Appointments",
|
||||
"events.toggle.all": "Both",
|
||||
"events.filter.status.all": "All",
|
||||
"events.filter.status.all": "All (incl. past)",
|
||||
"events.filter.status.upcoming": "From today",
|
||||
"events.summary.later": "Later",
|
||||
"events.col.date": "Date",
|
||||
"events.col.location": "Location",
|
||||
@@ -4788,6 +4846,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.shape.calendar": "Calendar",
|
||||
"views.shape.timeline": "Timeline",
|
||||
"views.timeline.caveat.body": "Custom Views show actual events only. Open the project's chart for projected rules.",
|
||||
"views.timeline.zoom.label": "Zoom",
|
||||
"views.timeline.zoom.in": "Zoom in",
|
||||
"views.timeline.zoom.out": "Zoom out",
|
||||
"views.timeline.zoom.1y": "±1 yr",
|
||||
"views.timeline.zoom.2y": "±2 yr",
|
||||
"views.timeline.zoom.all": "All",
|
||||
"views.save_as": "Save as view",
|
||||
"views.action.edit": "Edit",
|
||||
"views.empty.title": "No matches found.",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,6 +663,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", () => {
|
||||
@@ -674,6 +717,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("caldav-form")!.addEventListener("submit", saveCalDAV);
|
||||
document.getElementById("caldav-test-btn")!.addEventListener("click", testCalDAVConnection);
|
||||
document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteCalDAVConfig);
|
||||
const exportBtn = document.getElementById("export-btn");
|
||||
if (exportBtn) exportBtn.addEventListener("click", runExport);
|
||||
|
||||
onLangChange(() => {
|
||||
if (loadedTabs.has("profil")) renderOfficeOptions();
|
||||
|
||||
@@ -25,6 +25,35 @@ let lastResponse: DeadlineResponse | null = null;
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// 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 +75,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;
|
||||
@@ -61,6 +115,7 @@ async function doCalc() {
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
flags: readFlags(),
|
||||
courtId,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
@@ -70,13 +125,42 @@ 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>
|
||||
@@ -89,6 +173,8 @@ function renderResults(data: DeadlineResponse) {
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
if (toggle) toggle.style.display = "";
|
||||
|
||||
syncTriggerEventLabel();
|
||||
}
|
||||
|
||||
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
|
||||
@@ -100,18 +186,47 @@ 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 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 +284,35 @@ 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());
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -38,6 +38,14 @@ export interface CalculatedDeadline {
|
||||
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;
|
||||
@@ -240,9 +248,20 @@ 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
|
||||
|
||||
@@ -65,12 +65,21 @@ const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
const DE_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", 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.bpatg", i18nKey: "deadlines.de.null.bpatg", 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[] = [
|
||||
@@ -425,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>
|
||||
|
||||
|
||||
@@ -653,8 +653,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"
|
||||
@@ -672,6 +677,13 @@ 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.delete"
|
||||
| "caldav.delete.confirm"
|
||||
| "caldav.delete.done"
|
||||
@@ -919,6 +931,8 @@ export type I18nKey =
|
||||
| "deadlines.court.set"
|
||||
| "deadlines.date.edit.hint"
|
||||
| "deadlines.de"
|
||||
| "deadlines.de.group.inf"
|
||||
| "deadlines.de.group.null"
|
||||
| "deadlines.de.inf.bgh"
|
||||
| "deadlines.de.inf.lg"
|
||||
| "deadlines.de.inf.olg"
|
||||
@@ -1215,6 +1229,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"
|
||||
@@ -1258,6 +1282,7 @@ export type I18nKey =
|
||||
| "einstellungen.subtitle"
|
||||
| "einstellungen.tab.benachrichtigungen"
|
||||
| "einstellungen.tab.caldav"
|
||||
| "einstellungen.tab.export"
|
||||
| "einstellungen.tab.profil"
|
||||
| "einstellungen.title"
|
||||
| "event.description.appointment_approval_approved"
|
||||
@@ -1362,6 +1387,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"
|
||||
@@ -2421,6 +2447,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>
|
||||
|
||||
@@ -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 ---------------------------------------- */}
|
||||
@@ -342,6 +343,49 @@ 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>
|
||||
|
||||
@@ -3075,6 +3075,25 @@ input[type="range"]::-moz-range-thumb {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Sub-group inside a .proceeding-group — used today by the DE block
|
||||
to split Verletzungsverfahren tiles from Nichtigkeitsverfahren tiles
|
||||
under one "Deutsche Gerichte" h4. Heading is one tier below the h4
|
||||
(mixed-case, no upper-tracking) so the two-level hierarchy reads at
|
||||
a glance. */
|
||||
.proceeding-subgroup {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.proceeding-subgroup:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.proceeding-subgroup-heading {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 0.35rem 0;
|
||||
}
|
||||
|
||||
.proceeding-btns {
|
||||
display: flex;
|
||||
@@ -11773,16 +11792,58 @@ dialog.quick-add-sheet::backdrop {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* shape=calendar. */
|
||||
/* shape=calendar. month / week / day views share .views-calendar wrapper;
|
||||
the variant class .views-calendar--<view> drives any per-view tweaks. */
|
||||
.views-calendar-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.views-calendar-view-switcher {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.views-calendar-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.views-calendar-nav-btn {
|
||||
min-width: 32px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.views-calendar-nav-label {
|
||||
font-weight: 600;
|
||||
min-width: 12ch;
|
||||
text-align: center;
|
||||
}
|
||||
.views-calendar-back-to-month {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-link, var(--color-accent));
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.views-calendar-back-to-month:hover {
|
||||
color: var(--color-link-hover, var(--color-accent));
|
||||
}
|
||||
|
||||
.views-calendar-month-label {
|
||||
font-size: 18px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.views-calendar-weekdays {
|
||||
|
||||
/* Month view — one grid contains both the weekday header row and the day
|
||||
cells, so they share the same column template (no drift). */
|
||||
.views-calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.views-calendar-weekday {
|
||||
font-size: 12px;
|
||||
@@ -11790,11 +11851,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px;
|
||||
}
|
||||
.views-calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.views-calendar-cell {
|
||||
min-height: 80px;
|
||||
@@ -11803,15 +11860,32 @@ dialog.quick-add-sheet::backdrop {
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.views-calendar-cell--out {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border);
|
||||
}
|
||||
.views-calendar-cell--today {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
.views-calendar-cell-day {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
font-weight: 600;
|
||||
}
|
||||
.views-calendar-cell-day:hover,
|
||||
.views-calendar-cell-day:focus-visible {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.views-calendar-pills {
|
||||
list-style: none;
|
||||
@@ -11830,12 +11904,147 @@ dialog.quick-add-sheet::backdrop {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.views-calendar-pill:hover {
|
||||
background: var(--color-surface-hover, var(--color-surface-muted));
|
||||
}
|
||||
.views-calendar-pill--more {
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Week view — 7 columns, scrollable per column when overflowing. */
|
||||
.views-calendar-week-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.views-calendar-week-column {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.views-calendar-week-column--today {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
.views-calendar-week-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.views-calendar-week-dow {
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.views-calendar-week-dnum {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-calendar-week-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.views-calendar-week-empty {
|
||||
margin: 0;
|
||||
padding: 12px 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Day view — single chronological list. */
|
||||
.views-calendar-day-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.views-calendar-day-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.views-calendar-day-empty {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Row anchors used by both week and day views. */
|
||||
.views-calendar-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-calendar-row:hover {
|
||||
background: var(--color-surface-hover, var(--color-surface-muted));
|
||||
}
|
||||
.views-calendar-row-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-top: 6px;
|
||||
flex: 0 0 8px;
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
.views-calendar-row-dot--deadline { background: var(--color-accent); }
|
||||
.views-calendar-row-dot--appointment { background: #3b82f6; }
|
||||
.views-calendar-row-dot--project_event { background: #a855f7; }
|
||||
.views-calendar-row-dot--approval_request { background: #f59e0b; }
|
||||
.views-calendar-row-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.views-calendar-row-title {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.views-calendar-row-meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-calendar-row--week .views-calendar-row-title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.views-calendar-mobile-notice {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 12px;
|
||||
@@ -14603,7 +14812,14 @@ dialog.quick-add-sheet::backdrop {
|
||||
.smart-timeline-chart .chart-lane-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
fill: var(--chart-lane-label);
|
||||
color: var(--chart-lane-label);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
}
|
||||
.smart-timeline-chart .chart-today-rule {
|
||||
stroke: var(--chart-today-rule);
|
||||
@@ -14713,6 +14929,45 @@ dialog.quick-add-sheet::backdrop {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Custom Views timeline toolbar (t-paliad-211) — zoom controls above the
|
||||
chart canvas. Stays in flow so it doesn't overlap the SVG date axis. */
|
||||
.views-timeline-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.views-timeline-zoom-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.views-timeline-zoom-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.views-timeline-zoom-btn {
|
||||
min-width: 32px;
|
||||
padding: 4px 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.views-timeline-zoom-btn[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.views-timeline-zoom-chips {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.views-timeline-chart-host-inner {
|
||||
/* Reserve a min-height so the loading placeholder doesn't collapse
|
||||
and the toolbar/chart stack stays predictable. */
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* ---- Palette presets (t-paliad-177 Slice 2, design §5.1) ----
|
||||
Each palette is a pure data-attribute swap of the --chart-* tokens.
|
||||
Renderer code never reads palette state — it just emits classed SVG
|
||||
|
||||
@@ -40,12 +40,21 @@ const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
const DE_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", 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.bpatg", i18nKey: "deadlines.de.null.bpatg", 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[] = [
|
||||
@@ -108,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>
|
||||
|
||||
@@ -156,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
go.mod
12
go.mod
@@ -9,3 +9,15 @@ require (
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lib/pq v1.12.3
|
||||
)
|
||||
|
||||
require (
|
||||
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/excelize/v2 v2.10.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
|
||||
)
|
||||
|
||||
18
go.sum
18
go.sum
@@ -57,8 +57,20 @@ 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/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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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=
|
||||
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=
|
||||
@@ -69,7 +81,13 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd
|
||||
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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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=
|
||||
|
||||
198
internal/db/migrate_test.go
Normal file
198
internal/db/migrate_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Package db tests — migration dry-run gate.
|
||||
//
|
||||
// This is the test that catches mig-N crash-loops before they reach prod.
|
||||
// The convention since t-paliad-098/099 is that paliad migrations land in
|
||||
// numeric order on a single trunk; the next deploy runs whichever ones are
|
||||
// pending against the live `public.paliad_schema_migrations` tracker. 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 greater than the scratch DB's current tracker
|
||||
// version, 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 version.
|
||||
//
|
||||
// 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.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// migration is one *.up.sql file from the embedded migrations FS.
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
filename string
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// What "pending" means: greater than the scratch DB's current tracker
|
||||
// version (or 0 if the tracker doesn't exist yet). In CI against a fresh
|
||||
// scratch DB, 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 the start version 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.
|
||||
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)
|
||||
}
|
||||
|
||||
startVersion, dirty, err := currentTrackerVersion(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read tracker: %v", err)
|
||||
}
|
||||
if dirty {
|
||||
t.Fatalf("tracker is dirty at version %d — fix that first (DROP the tracker row "+
|
||||
"or restore from backup); the dry-run cannot trust a dirty starting state",
|
||||
startVersion)
|
||||
}
|
||||
t.Logf("scratch DB tracker at version %d; walking pending migrations from %d upward",
|
||||
startVersion, startVersion+1)
|
||||
|
||||
migs, err := loadPendingMigrations(startVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("load migrations: %v", err)
|
||||
}
|
||||
if len(migs) == 0 {
|
||||
t.Logf("no pending migrations — scratch DB is at HEAD (%d)", startVersion)
|
||||
return
|
||||
}
|
||||
|
||||
for _, m := range migs {
|
||||
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 version than where it started. Rollback is
|
||||
// safe to call even 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// currentTrackerVersion reads the latest version + dirty flag from the
|
||||
// `public.paliad_schema_migrations` tracker. Returns (0, false, nil) when the
|
||||
// tracker doesn't exist yet — that's the "fresh scratch DB" path.
|
||||
//
|
||||
// We don't use golang-migrate's API to read this because golang-migrate's
|
||||
// driver locks the tracker row on read; a test runner that calls this while
|
||||
// the developer has paliad running locally would race. A plain SELECT is
|
||||
// race-safe and matches what `psql` would show.
|
||||
func currentTrackerVersion(conn *sql.DB) (version int, dirty bool, err error) {
|
||||
const q = `SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`
|
||||
row := conn.QueryRow(q)
|
||||
if scanErr := row.Scan(&version, &dirty); scanErr != nil {
|
||||
// Missing table → fresh DB → start at 0. lib/pq surfaces this
|
||||
// as `pq.Error.Code = "42P01"` (undefined_table); the simpler
|
||||
// sql.ErrNoRows fires if the table exists but is empty (also
|
||||
// fresh-DB-shaped).
|
||||
if errors.Is(scanErr, sql.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if strings.Contains(scanErr.Error(), "does not exist") {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, scanErr
|
||||
}
|
||||
return version, dirty, nil
|
||||
}
|
||||
|
||||
// loadPendingMigrations returns every *.up.sql in the embedded FS whose
|
||||
// version is greater than startVersion, sorted by version ascending. A
|
||||
// filename like "098_submission_codes_prefix_and_rename.up.sql" yields
|
||||
// version=98, name="submission_codes_prefix_and_rename".
|
||||
func loadPendingMigrations(startVersion int) ([]migration, error) {
|
||||
entries, err := migrationFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
var out []migration
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".up.sql") {
|
||||
continue
|
||||
}
|
||||
v, n, ok := parseMigrationName(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unparseable migration filename: %s "+
|
||||
"(expected NNN_description.up.sql)", name)
|
||||
}
|
||||
if v <= startVersion {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// parseMigrationName splits "NNN_description.up.sql" into (NNN, description).
|
||||
// Returns ok=false on any deviation from that shape.
|
||||
func parseMigrationName(filename string) (version int, name string, ok bool) {
|
||||
base := strings.TrimSuffix(filename, ".up.sql")
|
||||
if base == filename { // suffix wasn't present
|
||||
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
|
||||
}
|
||||
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 $$;
|
||||
@@ -226,24 +226,31 @@ 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_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$';
|
||||
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. The column
|
||||
-- is nullable for legacy reasons, but every live row should
|
||||
-- carry a code after the prefix step.
|
||||
-- 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
|
||||
|
||||
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.';
|
||||
125
internal/handlers/export.go
Normal file
125
internal/handlers/export.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
// Data-export handlers (t-paliad-214).
|
||||
//
|
||||
// Slice 1 ships the personal scope only:
|
||||
//
|
||||
// GET /api/me/export → streams a personal-scope export .zip
|
||||
//
|
||||
// Slices 2 + 3 (project + org) layer onto this file when they ship.
|
||||
//
|
||||
// Authentication: the existing protected mux middleware (auth.Middleware +
|
||||
// auth.WithUserID) populates the user UUID in the context. We do not gate
|
||||
// on global_role here — personal export is available to every authenticated
|
||||
// user.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// exportRequestTimeout caps any single export request. Personal-scope
|
||||
// exports at firm-scale data shape complete in well under this; the
|
||||
// timeout is the watchdog that surfaces "too large for sync" loudly
|
||||
// (the user gets a 503 and slice 3's async path becomes the answer).
|
||||
const exportRequestTimeout = 30 * time.Second
|
||||
|
||||
// handleMeExport streams the caller's personal-scope export .zip.
|
||||
//
|
||||
// Order of operations:
|
||||
//
|
||||
// 1. Validate auth + db wiring.
|
||||
// 2. Look up the caller's user row for actor_email / actor_label.
|
||||
// 3. Write an audit row (event_type='data_export', scope='personal').
|
||||
// 4. Run the export into an in-memory buffer (so we can patch the
|
||||
// audit row with file_size_bytes before flushing to the client).
|
||||
// 5. Set headers + flush.
|
||||
// 6. Patch the audit row with success (row_counts + file_size).
|
||||
// On any error after step 3, the audit row is patched as failed.
|
||||
func handleMeExport(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.export == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "export service not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply the per-request watchdog.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), exportRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("export: user lookup failed for %s: %v", uid, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "user lookup failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
spec := services.ExportSpec{
|
||||
Scope: services.ExportScopePersonal,
|
||||
ActorID: uid,
|
||||
ActorEmail: user.Email,
|
||||
ActorLabel: user.DisplayName,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
auditID, err := dbSvc.export.WriteAuditRow(ctx, spec)
|
||||
if err != nil {
|
||||
log.Printf("export: audit insert failed for %s: %v", uid, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "audit write failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate into a memory buffer so we can size + audit-patch BEFORE
|
||||
// writing to the response (otherwise headers are committed and we
|
||||
// can't return a 500 if anything fails). At personal scale this is a
|
||||
// sub-megabyte buffer.
|
||||
var buf bytes.Buffer
|
||||
meta, err := dbSvc.export.WritePersonal(ctx, &buf, spec)
|
||||
if err != nil {
|
||||
dbSvc.export.PatchAuditRowFailure(context.Background(), auditID, err.Error())
|
||||
log.Printf("export: WritePersonal failed for %s (audit=%s): %v", uid, auditID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "export generation failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
filename := services.ExportFilename(services.ExportScopePersonal, "", spec.GeneratedAt)
|
||||
size := int64(buf.Len())
|
||||
|
||||
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
|
||||
// Audit-patch failure isn't fatal to the user — they still get
|
||||
// their export. Log it; the data already left the system.
|
||||
log.Printf("export: audit patch failed for %s (audit=%s): %v", uid, auditID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.Header().Set("X-Paliad-Export-Audit-Id", auditID.String())
|
||||
if _, err := w.Write(buf.Bytes()); err != nil {
|
||||
// Connection dropped mid-flush — the user didn't get the file.
|
||||
// We don't patch the audit row a second time; the success patch
|
||||
// already recorded the row counts. A separate event would be
|
||||
// noise (the failure is at the network layer, not in our path).
|
||||
log.Printf("export: response write failed for %s (audit=%s): %v", uid, auditID, err)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ type Services struct {
|
||||
Pin *services.PinService
|
||||
CardLayout *services.CardLayoutService
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
@@ -125,9 +126,21 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
pin: svc.Pin,
|
||||
cardLayout: svc.CardLayout,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
}
|
||||
}
|
||||
|
||||
// Liveness probe. Public, no auth, no DB touch — just confirms the
|
||||
// process bound the listener and the goroutine is alive. Used by the
|
||||
// boot-smoke test (cmd/server/main_smoke_test.go) to assert the server
|
||||
// reaches a serving state after migrations apply; also safe for any
|
||||
// future container orchestrator or uptime check.
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
// API endpoints (JSON, public)
|
||||
mux.HandleFunc("POST /api/login", handleAPILogin)
|
||||
mux.HandleFunc("POST /api/register", handleAPIRegister)
|
||||
@@ -159,6 +172,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
mux.Handle("GET /icons/", noCacheAssets(http.StripPrefix("/icons/", http.FileServer(http.Dir("dist/icons")))))
|
||||
mux.HandleFunc("GET /sw.js", servePWAServiceWorker)
|
||||
|
||||
// HL Patents Style auto-update endpoint. version.json is the manifest
|
||||
// the installed Word client polls; HL-Patents-Style.dotm is fetched on
|
||||
// version mismatch. Source files live in frontend/public/patentstyle/
|
||||
// (copied into dist/ at build time). noCacheAssets ensures the manifest
|
||||
// is never stale after a release.
|
||||
mux.Handle("GET /patentstyle/", noCacheAssets(http.StripPrefix("/patentstyle/", http.FileServer(http.Dir("dist/patentstyle")))))
|
||||
|
||||
// Protected routes
|
||||
protected := http.NewServeMux()
|
||||
protected.HandleFunc("GET /tools/kostenrechner", handleKostenrechnerPage)
|
||||
@@ -333,6 +353,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
|
||||
protected.HandleFunc("GET /api/me", handleGetMe)
|
||||
protected.HandleFunc("PATCH /api/me", handleUpdateMe)
|
||||
// t-paliad-214 Slice 1 — personal-scope data export. Bundles xlsx +
|
||||
// JSON + per-sheet CSVs in one deterministic .zip; streams the result
|
||||
// inline. Audit row written to paliad.system_audit_log.
|
||||
protected.HandleFunc("GET /api/me/export", handleMeExport)
|
||||
protected.HandleFunc("GET /api/users", handleListUsers)
|
||||
protected.HandleFunc("GET /api/offices", handleListOffices)
|
||||
protected.HandleFunc("GET /api/dashboard", handleDashboardAPI)
|
||||
|
||||
@@ -52,6 +52,7 @@ type dbServices struct {
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
@@ -9,6 +9,7 @@ package services
|
||||
// - paliad.reminder_log — bundled-digest reminder sends
|
||||
// - paliad.partner_unit_events — partner-unit CRUD + membership changes
|
||||
// - paliad.policy_audit_log — approval-policy CRUD (t-paliad-154)
|
||||
// - paliad.system_audit_log — org-wide / scope-spanning actions (t-paliad-214)
|
||||
//
|
||||
// The union happens in SQL (one round-trip, server-side ordering) and is
|
||||
// keyset-paginated on (timestamp, id) DESC so the cursor stays stable across
|
||||
@@ -37,6 +38,7 @@ const (
|
||||
AuditSourceReminderLog = "reminder_log"
|
||||
AuditSourcePartnerUnitEvents = "partner_unit_events"
|
||||
AuditSourcePolicyAuditLog = "policy_audit_log"
|
||||
AuditSourceSystemAuditLog = "system_audit_log"
|
||||
)
|
||||
|
||||
// MaxAuditPageLimit caps a single ListEntries page.
|
||||
@@ -216,6 +218,27 @@ WITH unioned AS (
|
||||
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'policy_audit_log')
|
||||
AND ($2::timestamptz IS NULL OR pal.created_at >= $2)
|
||||
AND ($3::timestamptz IS NULL OR pal.created_at <= $3)
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- t-paliad-214 — org-wide / scope-spanning actions. First user is the
|
||||
-- data-export audit chain. scope_root is the project_id for
|
||||
-- scope='project'; NULL otherwise. project_id forwarded so timeline
|
||||
-- filtering by project surfaces project-scope exports too.
|
||||
SELECT
|
||||
'system_audit_log'::text AS source,
|
||||
sal.id AS id,
|
||||
sal.created_at AS ts,
|
||||
sal.event_type AS event_type,
|
||||
sal.actor_email AS actor,
|
||||
COALESCE(sal.scope, 'system') AS subject,
|
||||
sal.scope_root AS project_id,
|
||||
NULL::text AS title,
|
||||
sal.metadata::text AS description
|
||||
FROM paliad.system_audit_log sal
|
||||
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'system_audit_log')
|
||||
AND ($2::timestamptz IS NULL OR sal.created_at >= $2)
|
||||
AND ($3::timestamptz IS NULL OR sal.created_at <= $3)
|
||||
)
|
||||
SELECT source, id, ts, event_type, actor, subject, project_id, title, description
|
||||
FROM unioned
|
||||
|
||||
@@ -870,6 +870,77 @@ func FormatLegalSourceDisplay(src string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// BuildLegalSourceURL maps a structured legal_source code to a
|
||||
// youpc.org/laws permalink when the cited body is hosted there. Today
|
||||
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
|
||||
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
|
||||
// home yet, so the helper returns the empty string for those and the
|
||||
// caller renders the display string as plain text.
|
||||
//
|
||||
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
|
||||
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
|
||||
// the law-number position are dropped; youpc resolves the page at
|
||||
// <type>.<number> granularity. The law-number is zero-padded to 3
|
||||
// digits to match how youpc stores law_number (laws-data.json carries
|
||||
// "001" / "023" / "220" forms).
|
||||
//
|
||||
// URL shape uses the hash-fragment form that youpc itself emits from
|
||||
// its laws-page redirect (handlers/laws.go:215+229) — the canonical
|
||||
// in-app deep link target. The `/laws/:type/:number` pretty route also
|
||||
// resolves the same page but redirects to the hash form anyway.
|
||||
//
|
||||
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
|
||||
// UPC.RoP.139 → https://youpc.org/laws#UPCRoP.139
|
||||
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
|
||||
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
|
||||
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
|
||||
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
|
||||
func BuildLegalSourceURL(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
var lawType string
|
||||
switch parts[0] + "." + parts[1] {
|
||||
case "UPC.RoP":
|
||||
lawType = "UPCRoP"
|
||||
case "UPC.UPCA":
|
||||
lawType = "UPCA"
|
||||
case "UPC.UPCS":
|
||||
lawType = "UPCS"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
number := padLawNumber(parts[2])
|
||||
if number == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://youpc.org/laws#" + lawType + "." + number
|
||||
}
|
||||
|
||||
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
|
||||
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
|
||||
// 112a) pass through unchanged so the URL still resolves. Empty input
|
||||
// returns the empty string.
|
||||
func padLawNumber(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if len(s) >= 3 {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat("0", 3-len(s)) + s
|
||||
}
|
||||
|
||||
// RefreshSearchView re-populates the materialised view. Safe to call on
|
||||
// every server boot — it's a CONCURRENTLY refresh against a < 1k row
|
||||
// view, well under 100 ms in practice. Called from cmd/server/main.go
|
||||
|
||||
@@ -40,6 +40,38 @@ func TestFormatLegalSourceDisplay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLegalSourceURL covers the structured-form → youpc.org/laws
|
||||
// permalink mapping. Only the UPC corpus has a youpc home today;
|
||||
// DE/EPA/EU bodies fall through to the empty string and the renderer
|
||||
// shows display text without a link.
|
||||
func TestBuildLegalSourceURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"UPC.RoP.23.1", "https://youpc.org/laws#UPCRoP.023"},
|
||||
{"UPC.RoP.139", "https://youpc.org/laws#UPCRoP.139"},
|
||||
{"UPC.RoP.220.1", "https://youpc.org/laws#UPCRoP.220"},
|
||||
{"UPC.RoP.29.a", "https://youpc.org/laws#UPCRoP.029"},
|
||||
{"UPC.RoP.49.2.a", "https://youpc.org/laws#UPCRoP.049"},
|
||||
{"UPC.RoP.19.1", "https://youpc.org/laws#UPCRoP.019"},
|
||||
{"UPC.UPCA.83", "https://youpc.org/laws#UPCA.083"},
|
||||
{"UPC.UPCS.40.1", "https://youpc.org/laws#UPCS.040"},
|
||||
{"DE.PatG.82.1", ""},
|
||||
{"DE.ZPO.276.1", ""},
|
||||
{"EU.EPÜ.108", ""},
|
||||
{"EU.EPC-R.79.1", ""},
|
||||
{"EU.RPBA.12.1.c", ""},
|
||||
{"UPC.RoP", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := BuildLegalSourceURL(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("BuildLegalSourceURL(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeQuery covers the input-side legal-prefix stripping that
|
||||
// keeps "§ 82" / "Art. 108" findable against structured legal_source
|
||||
// values that don't carry the prefix.
|
||||
|
||||
956
internal/services/export_service.go
Normal file
956
internal/services/export_service.go
Normal file
@@ -0,0 +1,956 @@
|
||||
package services
|
||||
|
||||
// ExportService streams a paliad data-export bundle to an io.Writer.
|
||||
//
|
||||
// One .zip per export, containing:
|
||||
//
|
||||
// - paliad-export.xlsx canonical workbook, one sheet per entity
|
||||
// - paliad-export.json Excel-independent re-ingest twin
|
||||
// - csv/<sheet>.csv per-sheet flat tables (RFC 4180 + UTF-8 BOM)
|
||||
// - README.txt human-readable explainer
|
||||
// - __meta.json standalone meta (same as the __meta sheet)
|
||||
//
|
||||
// Three scopes (per docs/design-paliad-data-export-2026-05-19.md):
|
||||
//
|
||||
// - personal — caller's RLS-visible projection + personal sidecars
|
||||
// - project — one project + its ltree subtree (slice 2, not in this file yet)
|
||||
// - org — full schema dump (slice 3, async path)
|
||||
//
|
||||
// Slice 1 ships personal only; the writer abstraction is scope-aware so
|
||||
// slices 2 + 3 layer on without rewriting the core.
|
||||
//
|
||||
// Determinism: sheets emitted in a fixed canonical order; rows ordered by
|
||||
// id ASC (or another stable tuple where no id exists); JSON object keys
|
||||
// sorted alphabetically; the outer zip writes its file list in sorted
|
||||
// order. Same row-state → identical bytes. The only non-deterministic
|
||||
// field is __meta.generated_at, externalised to the filename.
|
||||
//
|
||||
// PII posture:
|
||||
//
|
||||
// - Column names matching (?i)secret|token|password|api[_-]?key|private[_-]?key
|
||||
// are dropped at column-discovery time and recorded in __meta.warnings.
|
||||
// - Specific column overrides (user_caldav_config.password_encrypted,
|
||||
// invitations.token where it exists, etc.) live in the sheet definitions
|
||||
// as explicit column-filter lists.
|
||||
// - paliadin_turns is OFF in org scope and ON in personal scope (it's
|
||||
// literally the caller's own data). Org-scope exclusion is structural
|
||||
// (sheet absent from the registry), not just column-level.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// Export scope discriminators. Stable strings — exposed in the audit row
|
||||
// and the __meta sheet.
|
||||
const (
|
||||
ExportScopePersonal = "personal"
|
||||
ExportScopeProject = "project"
|
||||
ExportScopeOrg = "org"
|
||||
)
|
||||
|
||||
// ExportSchemaVersion is bumped whenever the on-disk shape changes in a
|
||||
// way that requires importers to adapt. v1 is the slice-1/2/3 baseline.
|
||||
const ExportSchemaVersion = 1
|
||||
|
||||
// PII column-name deny regex. Any column whose name matches is dropped
|
||||
// during column discovery and recorded in __meta.warnings. The list of
|
||||
// known column names (e.g. user_caldav_config.password_encrypted) is
|
||||
// deliberately covered by the regex too — explicit + regex belt-and-braces.
|
||||
var piiColumnDenyRegex = regexp.MustCompile(`(?i)secret|token|password|api[_-]?key|private[_-]?key`)
|
||||
|
||||
// ExportService writes a scoped export bundle. Stateless except for the
|
||||
// DB handle + firm-name display string.
|
||||
type ExportService struct {
|
||||
db *sqlx.DB
|
||||
firmName string
|
||||
}
|
||||
|
||||
// NewExportService wires the service. firmName is read once at process
|
||||
// start from internal/branding.Name and embedded in every export's __meta.
|
||||
func NewExportService(db *sqlx.DB, firmName string) *ExportService {
|
||||
return &ExportService{db: db, firmName: firmName}
|
||||
}
|
||||
|
||||
// ExportMeta is the bundle metadata. Stored on the __meta sheet, in
|
||||
// __meta.json, and as part of the audit row.
|
||||
type ExportMeta struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
FirmName string `json:"firm_name"`
|
||||
Scope string `json:"scope"`
|
||||
ScopeRootID *uuid.UUID `json:"scope_root_id,omitempty"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
GeneratedByID uuid.UUID `json:"generated_by_user_id"`
|
||||
GeneratedByEml string `json:"generated_by_user_email"`
|
||||
GeneratedByLbl string `json:"generated_by_user_label"`
|
||||
RowCounts map[string]int `json:"row_counts"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
PaliadVersion string `json:"paliad_version,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// ExportSpec is the per-run inputs.
|
||||
type ExportSpec struct {
|
||||
Scope string
|
||||
ScopeRoot *uuid.UUID // project_id when Scope==ExportScopeProject; nil otherwise
|
||||
ActorID uuid.UUID
|
||||
ActorEmail string
|
||||
ActorLabel string // display_name for the audit + meta
|
||||
GeneratedAt time.Time
|
||||
}
|
||||
|
||||
// sheetQuery is one entity sheet's SQL recipe. Sheets emit in the order
|
||||
// they appear in the registry, which is fixed (alphabetical inside each
|
||||
// scope-prefix group). args are sqlx-positional.
|
||||
type sheetQuery struct {
|
||||
// SheetName lands in the workbook sheet, the JSON top-level key, and
|
||||
// the CSV filename stem. snake_case, ≤31 chars (Excel's hard limit).
|
||||
SheetName string
|
||||
// SQL runs as-is; should select rows in a deterministic order (ORDER
|
||||
// BY id ASC or a comparable stable tuple).
|
||||
SQL string
|
||||
// Args are sqlx-positional, bound 1:1 against the SQL's $1, $2, ….
|
||||
Args []any
|
||||
// DropColumns is an explicit list of column names to drop from the
|
||||
// result regardless of the regex deny-list. Used for jsonb columns
|
||||
// that contain credentials, or paliadin response bodies in org scope.
|
||||
DropColumns []string
|
||||
}
|
||||
|
||||
// WritePersonal streams the caller's personal-scope bundle into w. Returns
|
||||
// the meta (incl. row_counts) for audit-row patching.
|
||||
//
|
||||
// Order of operations:
|
||||
//
|
||||
// 1. Build the sheet-query registry for the caller's visible set.
|
||||
// 2. Execute each query, materialise rows + columns + types.
|
||||
// 3. Run column-discovery + PII filter, collect warnings.
|
||||
// 4. Write the xlsx (excelize streaming writer), JSON, and CSVs into a
|
||||
// memory buffer (small at personal-scope sizes — ≪ 10MB is normal).
|
||||
// 5. Bundle into the outer zip in deterministic file-list order.
|
||||
//
|
||||
// The handler is responsible for the audit-row INSERT before calling +
|
||||
// the UPDATE after the call returns. We do not write the audit row here
|
||||
// because the handler also needs to decide what to do on failure (the
|
||||
// audit row gets a separate event_type='data_export_failed' UPDATE in
|
||||
// that case).
|
||||
func (s *ExportService) WritePersonal(ctx context.Context, w io.Writer, spec ExportSpec) (ExportMeta, error) {
|
||||
if spec.Scope == "" {
|
||||
spec.Scope = ExportScopePersonal
|
||||
}
|
||||
if spec.GeneratedAt.IsZero() {
|
||||
spec.GeneratedAt = time.Now().UTC()
|
||||
}
|
||||
meta := ExportMeta{
|
||||
SchemaVersion: ExportSchemaVersion,
|
||||
FirmName: s.firmName,
|
||||
Scope: spec.Scope,
|
||||
GeneratedAt: spec.GeneratedAt,
|
||||
GeneratedByID: spec.ActorID,
|
||||
GeneratedByEml: spec.ActorEmail,
|
||||
GeneratedByLbl: spec.ActorLabel,
|
||||
RowCounts: map[string]int{},
|
||||
}
|
||||
|
||||
sheets := personalSheetQueries(spec.ActorID)
|
||||
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// collectedSheet holds one sheet's data after column-discovery + row
|
||||
// materialisation. Used to hand data from writeBundle to buildXLSX +
|
||||
// buildJSON + buildCSV.
|
||||
type collectedSheet struct {
|
||||
name string
|
||||
columns []string
|
||||
rows [][]string // pre-stringified for cell writes
|
||||
}
|
||||
|
||||
// writeBundle is the scope-agnostic core. Runs each query, writes one
|
||||
// xlsx sheet + one JSON branch + one CSV per sheet, packs everything into
|
||||
// the outer zip in sorted file-list order so two runs of the same row
|
||||
// state produce byte-identical bundles.
|
||||
func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []sheetQuery, meta *ExportMeta) error {
|
||||
collectedSheets := make([]collectedSheet, 0, len(sheets))
|
||||
jsonTables := make(map[string][]map[string]string, len(sheets))
|
||||
warnings := []string{}
|
||||
|
||||
for _, sq := range sheets {
|
||||
cols, rowMatrix, dropped, err := s.runSheetQuery(ctx, sq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export sheet %q: %w", sq.SheetName, err)
|
||||
}
|
||||
for _, c := range dropped {
|
||||
warnings = append(warnings, fmt.Sprintf("sheet=%s column=%s dropped (PII deny-list)", sq.SheetName, c))
|
||||
}
|
||||
collectedSheets = append(collectedSheets, collectedSheet{
|
||||
name: sq.SheetName,
|
||||
columns: cols,
|
||||
rows: rowMatrix,
|
||||
})
|
||||
// JSON twin: one object per row, keyed by column name. We accept
|
||||
// the value-as-string convention so JSON shape matches CSV shape
|
||||
// 1:1 — anyone re-ingesting can re-parse with the same rules.
|
||||
jsonRows := make([]map[string]string, 0, len(rowMatrix))
|
||||
for _, r := range rowMatrix {
|
||||
obj := make(map[string]string, len(cols))
|
||||
for i, c := range cols {
|
||||
if i < len(r) {
|
||||
obj[c] = r[i]
|
||||
}
|
||||
}
|
||||
jsonRows = append(jsonRows, obj)
|
||||
}
|
||||
jsonTables[sq.SheetName] = jsonRows
|
||||
meta.RowCounts[sq.SheetName] = len(rowMatrix)
|
||||
}
|
||||
sort.Strings(warnings)
|
||||
meta.Warnings = warnings
|
||||
|
||||
// --- build the xlsx in a memory buffer ---
|
||||
xlsxBytes, err := buildXLSX(collectedSheets, *meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export build xlsx: %w", err)
|
||||
}
|
||||
|
||||
// --- build the JSON twin ---
|
||||
jsonBytes, err := buildJSON(jsonTables, *meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export build json: %w", err)
|
||||
}
|
||||
|
||||
// --- build per-sheet CSVs (in-memory map, written in sorted order) ---
|
||||
csvBlobs := map[string][]byte{}
|
||||
for _, c := range collectedSheets {
|
||||
b, err := buildCSV(c.columns, c.rows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export build csv %q: %w", c.name, err)
|
||||
}
|
||||
csvBlobs[c.name] = b
|
||||
}
|
||||
|
||||
// --- build __meta.json + README.txt ---
|
||||
metaJSON, err := json.MarshalIndent(*meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("export marshal meta: %w", err)
|
||||
}
|
||||
readme := buildREADME(*meta)
|
||||
|
||||
// --- assemble outer zip in deterministic file order ---
|
||||
type zipEntry struct {
|
||||
name string
|
||||
body []byte
|
||||
}
|
||||
entries := []zipEntry{
|
||||
{"README.txt", []byte(readme)},
|
||||
{"__meta.json", metaJSON},
|
||||
{"paliad-export.json", jsonBytes},
|
||||
{"paliad-export.xlsx", xlsxBytes},
|
||||
}
|
||||
csvNames := make([]string, 0, len(csvBlobs))
|
||||
for name := range csvBlobs {
|
||||
csvNames = append(csvNames, name)
|
||||
}
|
||||
sort.Strings(csvNames)
|
||||
for _, name := range csvNames {
|
||||
entries = append(entries, zipEntry{"csv/" + name + ".csv", csvBlobs[name]})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name })
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
// Force a fixed Modified time on every entry so the zip header bytes
|
||||
// don't drift between runs. archive/zip otherwise stamps Modified
|
||||
// with time.Now() which would defeat the deterministic guarantee.
|
||||
fixedMod := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, e := range entries {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: e.name,
|
||||
Method: zip.Deflate,
|
||||
Modified: fixedMod,
|
||||
}
|
||||
fw, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export zip header %q: %w", e.name, err)
|
||||
}
|
||||
if _, err := fw.Write(e.body); err != nil {
|
||||
return fmt.Errorf("export zip write %q: %w", e.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return fmt.Errorf("export zip close: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSheetQuery executes one sheetQuery and returns the kept columns,
|
||||
// row matrix (pre-stringified per the design's value-as-string convention),
|
||||
// and the list of columns that were dropped by the PII filter.
|
||||
func (s *ExportService) runSheetQuery(ctx context.Context, sq sheetQuery) (cols []string, rows [][]string, dropped []string, err error) {
|
||||
rs, err := s.db.QueryxContext(ctx, sq.SQL, sq.Args...)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("query: %w", err)
|
||||
}
|
||||
defer rs.Close()
|
||||
|
||||
rawCols, err := rs.Columns()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("columns: %w", err)
|
||||
}
|
||||
|
||||
// Filter columns through the PII deny-list + the per-sheet drop set.
|
||||
keepIdx := make([]int, 0, len(rawCols))
|
||||
keepCols := make([]string, 0, len(rawCols))
|
||||
drops := map[string]bool{}
|
||||
for _, c := range sq.DropColumns {
|
||||
drops[c] = true
|
||||
}
|
||||
for i, c := range rawCols {
|
||||
if drops[c] || piiColumnDenyRegex.MatchString(c) {
|
||||
dropped = append(dropped, c)
|
||||
continue
|
||||
}
|
||||
keepIdx = append(keepIdx, i)
|
||||
keepCols = append(keepCols, c)
|
||||
}
|
||||
|
||||
for rs.Next() {
|
||||
// Read raw values; Postgres returns text/numeric/etc as []byte,
|
||||
// uuids as []byte, jsonb as []byte. The map-row helper picks the
|
||||
// right Go type per column via reflection.
|
||||
rawRow := make([]any, len(rawCols))
|
||||
ptrs := make([]any, len(rawCols))
|
||||
for i := range rawRow {
|
||||
ptrs[i] = &rawRow[i]
|
||||
}
|
||||
if err := rs.Scan(ptrs...); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
out := make([]string, len(keepIdx))
|
||||
for j, srcIdx := range keepIdx {
|
||||
out[j] = formatCellValue(rawRow[srcIdx])
|
||||
}
|
||||
rows = append(rows, out)
|
||||
}
|
||||
if err := rs.Err(); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("rows: %w", err)
|
||||
}
|
||||
return keepCols, rows, dropped, nil
|
||||
}
|
||||
|
||||
// formatCellValue renders a Postgres-driver value as the canonical export
|
||||
// string. Conventions per design §3.1:
|
||||
//
|
||||
// - timestamptz → RFC3339 UTC ("2026-05-19T14:23:00Z")
|
||||
// - date → ISO 8601 ("2026-05-19")
|
||||
// - booleans → "TRUE" / "FALSE"
|
||||
// - []byte that is valid JSON → compact JSON string (jsonb columns)
|
||||
// - []byte that looks like UUID/text → string
|
||||
// - nil → "" (the empty cell)
|
||||
// - arrays → semicolon-joined (Postgres returns text[] as "{a,b}" via lib/pq)
|
||||
//
|
||||
// Returning strings (vs typed Excel values) is intentional — see design
|
||||
// §3.1 (Q4 = ISO strings only).
|
||||
func formatCellValue(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
if x {
|
||||
return "TRUE"
|
||||
}
|
||||
return "FALSE"
|
||||
case time.Time:
|
||||
// Try date-only when the value is exactly midnight UTC (Postgres
|
||||
// returns DATE columns as time.Time with H/M/S/N all zero).
|
||||
if x.Hour() == 0 && x.Minute() == 0 && x.Second() == 0 && x.Nanosecond() == 0 && (x.Location() == time.UTC || x.Location() == time.Local) {
|
||||
// Heuristic: if year < 2 it's likely the zero value
|
||||
if x.Year() < 2 {
|
||||
return ""
|
||||
}
|
||||
return x.UTC().Format("2006-01-02")
|
||||
}
|
||||
return x.UTC().Format(time.RFC3339)
|
||||
case []byte:
|
||||
// jsonb columns come back as []byte holding valid JSON. Pass them
|
||||
// through verbatim (one-liner) so PowerQuery's Json.Document can
|
||||
// re-parse. Non-JSON []byte is treated as a UTF-8 string.
|
||||
s := string(x)
|
||||
trim := strings.TrimSpace(s)
|
||||
if strings.HasPrefix(trim, "{") || strings.HasPrefix(trim, "[") {
|
||||
// Compactify so the cell has no embedded newlines.
|
||||
var raw json.RawMessage = []byte(trim)
|
||||
if b, err := json.Marshal(raw); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return trim
|
||||
}
|
||||
return s
|
||||
case string:
|
||||
return x
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return fmt.Sprintf("%v", x)
|
||||
default:
|
||||
return fmt.Sprintf("%v", x)
|
||||
}
|
||||
}
|
||||
|
||||
// buildXLSX assembles the workbook from the collected sheets + meta. Uses
|
||||
// excelize's row-by-row writer; at personal/project scale the dataset
|
||||
// fits comfortably in memory. Returns the xlsx-file bytes.
|
||||
func buildXLSX(sheets []collectedSheet, meta ExportMeta) ([]byte, error) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
|
||||
// excelize creates a default "Sheet1" we want to rename to __meta.
|
||||
const metaName = "__meta"
|
||||
first := f.GetSheetName(0)
|
||||
if first != metaName {
|
||||
if err := f.SetSheetName(first, metaName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Write meta as key/value rows.
|
||||
metaRows := metaToKeyValueRows(meta)
|
||||
for i, kv := range metaRows {
|
||||
cellA, _ := excelize.CoordinatesToCellName(1, i+1)
|
||||
cellB, _ := excelize.CoordinatesToCellName(2, i+1)
|
||||
if err := f.SetCellValue(metaName, cellA, kv[0]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := f.SetCellValue(metaName, cellB, kv[1]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// One sheet per entity, columns in column-discovery order (= SELECT
|
||||
// order = stable across runs because the SQL is fixed).
|
||||
for _, sh := range sheets {
|
||||
// Excel sheet name limit is 31 chars; truncate defensively (none
|
||||
// of our names hit it today, but the personal-scope users_referenced
|
||||
// sheet is right at the edge).
|
||||
sheetName := sh.name
|
||||
if len(sheetName) > 31 {
|
||||
sheetName = sheetName[:31]
|
||||
}
|
||||
if _, err := f.NewSheet(sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Stream rows via the row-by-row API (NewStreamWriter is faster
|
||||
// but it forbids re-opening sheets and silently truncates writes
|
||||
// past the streamer's offset — at our scale the simple API is
|
||||
// safer and the perf cost is negligible).
|
||||
// Header row
|
||||
for ci, col := range sh.columns {
|
||||
cell, _ := excelize.CoordinatesToCellName(ci+1, 1)
|
||||
if err := f.SetCellValue(sheetName, cell, col); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for ri, row := range sh.rows {
|
||||
for ci, val := range row {
|
||||
cell, _ := excelize.CoordinatesToCellName(ci+1, ri+2)
|
||||
if err := f.SetCellValue(sheetName, cell, val); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Freeze the header row.
|
||||
_ = f.SetPanes(sheetName, &excelize.Panes{
|
||||
Freeze: true,
|
||||
YSplit: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// Write to buffer.
|
||||
var buf strings.Builder
|
||||
// excelize writes to an io.Writer via WriteTo
|
||||
bw := &byteBuf{}
|
||||
if _, err := f.WriteTo(bw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = buf // silence unused (kept for clarity that we considered a strings.Builder)
|
||||
return bw.Bytes(), nil
|
||||
}
|
||||
|
||||
// byteBuf is a tiny io.Writer that accumulates into a byte slice. We don't
|
||||
// use bytes.Buffer because we need WriteTo to round-trip the result and
|
||||
// bytes.Buffer's interface is wider than we need.
|
||||
type byteBuf struct{ b []byte }
|
||||
|
||||
func (b *byteBuf) Write(p []byte) (int, error) {
|
||||
b.b = append(b.b, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
func (b *byteBuf) Bytes() []byte { return b.b }
|
||||
|
||||
// metaToKeyValueRows flattens the meta into stable (key, value) tuples
|
||||
// in a fixed key order for the __meta sheet.
|
||||
func metaToKeyValueRows(m ExportMeta) [][2]string {
|
||||
rows := [][2]string{
|
||||
{"schema_version", fmt.Sprintf("%d", m.SchemaVersion)},
|
||||
{"firm_name", m.FirmName},
|
||||
{"scope", m.Scope},
|
||||
}
|
||||
if m.ScopeRootID != nil {
|
||||
rows = append(rows, [2]string{"scope_root_id", m.ScopeRootID.String()})
|
||||
} else {
|
||||
rows = append(rows, [2]string{"scope_root_id", ""})
|
||||
}
|
||||
rows = append(rows,
|
||||
[2]string{"generated_at", m.GeneratedAt.UTC().Format(time.RFC3339)},
|
||||
[2]string{"generated_by_user_id", m.GeneratedByID.String()},
|
||||
[2]string{"generated_by_user_email", m.GeneratedByEml},
|
||||
[2]string{"generated_by_user_label", m.GeneratedByLbl},
|
||||
[2]string{"paliad_version", m.PaliadVersion},
|
||||
[2]string{"notes", m.Notes},
|
||||
)
|
||||
// Row counts as one row per sheet (sorted).
|
||||
names := make([]string, 0, len(m.RowCounts))
|
||||
for k := range m.RowCounts {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
rows = append(rows, [2]string{"row_count." + n, fmt.Sprintf("%d", m.RowCounts[n])})
|
||||
}
|
||||
for _, w := range m.Warnings {
|
||||
rows = append(rows, [2]string{"warning", w})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildJSON produces the JSON twin. Top-level shape:
|
||||
//
|
||||
// {
|
||||
// "meta": { ... },
|
||||
// "tables": { "<sheet>": [ {"<col>": "<val>", ...}, ... ] }
|
||||
// }
|
||||
//
|
||||
// Keys in every map are alphabetically sorted (encoding/json does this by
|
||||
// default for map[string]X, which is what we use everywhere).
|
||||
func buildJSON(tables map[string][]map[string]string, meta ExportMeta) ([]byte, error) {
|
||||
payload := map[string]any{
|
||||
"meta": meta,
|
||||
"tables": tables,
|
||||
}
|
||||
return json.MarshalIndent(payload, "", " ")
|
||||
}
|
||||
|
||||
// buildCSV emits a UTF-8-BOM-prefixed CSV with RFC 4180 quoting. The BOM
|
||||
// makes Excel-DE open the file with the correct encoding instead of
|
||||
// guessing windows-1252 and corrupting umlauts.
|
||||
func buildCSV(cols []string, rows [][]string) ([]byte, error) {
|
||||
var buf byteBuf
|
||||
// UTF-8 BOM
|
||||
buf.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
w := csv.NewWriter(&buf)
|
||||
if err := w.Write(cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if err := w.Write(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
if err := w.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// buildREADME produces a short human-readable explainer embedded as the
|
||||
// first file in the bundle. Bilingual (DE primary, EN secondary).
|
||||
func buildREADME(m ExportMeta) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Paliad Datenexport (%s)\n", m.FirmName)
|
||||
fmt.Fprintf(&b, "============================\n\n")
|
||||
fmt.Fprintf(&b, "Erstellt am : %s\n", m.GeneratedAt.UTC().Format(time.RFC3339))
|
||||
fmt.Fprintf(&b, "Erstellt von : %s <%s>\n", m.GeneratedByLbl, m.GeneratedByEml)
|
||||
fmt.Fprintf(&b, "Umfang : %s\n", m.Scope)
|
||||
fmt.Fprintf(&b, "Schema-Version: %d\n", m.SchemaVersion)
|
||||
fmt.Fprintf(&b, "\n")
|
||||
fmt.Fprintf(&b, "Inhalt\n------\n")
|
||||
fmt.Fprintf(&b, "- paliad-export.xlsx — kanonische Excel-Mappe (eine Tabelle pro Entität)\n")
|
||||
fmt.Fprintf(&b, "- paliad-export.json — maschinenlesbare Kopie der gleichen Daten\n")
|
||||
fmt.Fprintf(&b, "- csv/<sheet>.csv — Tabellen einzeln als CSV (UTF-8 mit BOM)\n")
|
||||
fmt.Fprintf(&b, "- __meta.json — Metadaten dieses Exports (auch im __meta-Sheet)\n")
|
||||
fmt.Fprintf(&b, "\n")
|
||||
fmt.Fprintf(&b, "Zeilen pro Tabelle:\n")
|
||||
names := make([]string, 0, len(m.RowCounts))
|
||||
for k := range m.RowCounts {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
fmt.Fprintf(&b, " %-32s %d\n", n, m.RowCounts[n])
|
||||
}
|
||||
fmt.Fprintf(&b, "\n")
|
||||
fmt.Fprintf(&b, "Hinweise\n--------\n")
|
||||
fmt.Fprintf(&b, "Diese Datei enthält möglicherweise vertrauliche Mandantsdaten.\n")
|
||||
fmt.Fprintf(&b, "Sie wurde erzeugt am %s durch %s aus Paliad (%s).\n", m.GeneratedAt.UTC().Format(time.RFC3339), m.GeneratedByEml, m.FirmName)
|
||||
fmt.Fprintf(&b, "Die Weitergabe an Dritte erfolgt in eigener Verantwortung des Empfängers.\n")
|
||||
fmt.Fprintf(&b, "\n")
|
||||
fmt.Fprintf(&b, "Passwörter, CalDAV-Zugangsdaten, Einladungstoken und andere Geheimnisse\n")
|
||||
fmt.Fprintf(&b, "werden NIE exportiert (Spalten-Filter und allgemeine Deny-Regel).\n")
|
||||
fmt.Fprintf(&b, "\n")
|
||||
fmt.Fprintf(&b, "--- English ---\n\n")
|
||||
fmt.Fprintf(&b, "This Paliad export bundle contains structured data of the scope above.\n")
|
||||
fmt.Fprintf(&b, "Open paliad-export.xlsx in Excel/LibreOffice, or parse paliad-export.json\n")
|
||||
fmt.Fprintf(&b, "with any JSON-capable tool. CSVs are RFC 4180 with a UTF-8 BOM.\n")
|
||||
fmt.Fprintf(&b, "\n")
|
||||
fmt.Fprintf(&b, "Dates are ISO 8601 strings; timestamps are RFC 3339 UTC. Booleans are\n")
|
||||
fmt.Fprintf(&b, "the literal strings TRUE/FALSE. JSON-typed columns are stored as compact\n")
|
||||
fmt.Fprintf(&b, "one-line JSON in each cell.\n")
|
||||
fmt.Fprintf(&b, "\n")
|
||||
fmt.Fprintf(&b, "This bundle is byte-deterministic: two exports of the same row state\n")
|
||||
fmt.Fprintf(&b, "produce identical zip bytes (modulo the generated_at field stored on\n")
|
||||
fmt.Fprintf(&b, "the __meta sheet and in __meta.json).\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ExportFilename returns the canonical filename for a download. Slugify is
|
||||
// minimal — only the project-scope variant has a free-text component to
|
||||
// sanitise.
|
||||
func ExportFilename(scope string, scopeLabel string, generatedAt time.Time) string {
|
||||
ts := generatedAt.UTC().Format("2006-01-02T1504Z")
|
||||
switch scope {
|
||||
case ExportScopePersonal:
|
||||
return fmt.Sprintf("paliad-export-personal-%s.zip", ts)
|
||||
case ExportScopeOrg:
|
||||
return fmt.Sprintf("paliad-export-org-%s.zip", ts)
|
||||
case ExportScopeProject:
|
||||
slug := slugifyFilename(scopeLabel)
|
||||
if slug == "" {
|
||||
slug = randomSlug()
|
||||
}
|
||||
return fmt.Sprintf("paliad-export-project-%s-%s.zip", slug, ts)
|
||||
default:
|
||||
return fmt.Sprintf("paliad-export-%s.zip", ts)
|
||||
}
|
||||
}
|
||||
|
||||
var filenameSafeRegex = regexp.MustCompile(`[^A-Za-z0-9-]+`)
|
||||
|
||||
func slugifyFilename(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = filenameSafeRegex.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if len(s) > 40 {
|
||||
s = s[:40]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func randomSlug() string {
|
||||
var b [4]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personal-scope sheet registry.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Per design §2.3, "personal scope" is the RLS-visible projection plus
|
||||
// caller-personal sidecars. Every visible-projects query goes through
|
||||
// visibilityPredicatePositional so the gate is the same as runtime list
|
||||
// endpoints. The ?-positional binding takes the caller's user_id at $1.
|
||||
//
|
||||
// Ordering: every SELECT uses `ORDER BY id` (or the natural stable
|
||||
// sort-tuple for tables without an id PK) to keep two-runs-same-state
|
||||
// byte-deterministic.
|
||||
|
||||
func personalSheetQueries(actorID uuid.UUID) []sheetQuery {
|
||||
uid := actorID
|
||||
visiblePProj := visibilityPredicatePositional("p", 1)
|
||||
|
||||
// The visible-projects CTE is used by all entity sheets that scope by
|
||||
// project_id. Building it inline keeps each sheet's SQL self-contained
|
||||
// for readability + lets the query planner choose its own join order.
|
||||
visibleProjectsSubquery := `(SELECT p.id FROM paliad.projects p WHERE ` + visiblePProj + `)`
|
||||
|
||||
return []sheetQuery{
|
||||
// --- entity sheets (subtree-aware via visibility predicate) ---
|
||||
{
|
||||
SheetName: "projects",
|
||||
SQL: `SELECT * FROM paliad.projects p
|
||||
WHERE ` + visiblePProj + `
|
||||
ORDER BY p.id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "project_teams",
|
||||
SQL: `SELECT * FROM paliad.project_teams
|
||||
WHERE user_id = $1
|
||||
OR project_id IN ` + visibleProjectsSubquery + `
|
||||
ORDER BY project_id, user_id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "deadlines",
|
||||
SQL: `SELECT * FROM paliad.deadlines
|
||||
WHERE project_id IN ` + visibleProjectsSubquery + `
|
||||
ORDER BY id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "appointments",
|
||||
SQL: `SELECT * FROM paliad.appointments
|
||||
WHERE project_id IN ` + visibleProjectsSubquery + `
|
||||
ORDER BY id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "parties",
|
||||
SQL: `SELECT * FROM paliad.parties
|
||||
WHERE project_id IN ` + visibleProjectsSubquery + `
|
||||
ORDER BY id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "notes",
|
||||
SQL: `SELECT * FROM paliad.notes
|
||||
WHERE COALESCE(project_id,
|
||||
(SELECT d.project_id FROM paliad.deadlines d WHERE d.id = notes.deadline_id),
|
||||
(SELECT a.project_id FROM paliad.appointments a WHERE a.id = notes.appointment_id),
|
||||
(SELECT pe.project_id FROM paliad.project_events pe WHERE pe.id = notes.project_event_id)
|
||||
) IN ` + visibleProjectsSubquery + `
|
||||
ORDER BY id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "documents",
|
||||
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
|
||||
FROM paliad.documents
|
||||
WHERE project_id IN ` + visibleProjectsSubquery + `
|
||||
ORDER BY id`,
|
||||
Args: []any{uid},
|
||||
// ai_extracted jsonb is the only column omitted from the
|
||||
// personal projection because it can carry verbose AI prompts.
|
||||
},
|
||||
{
|
||||
SheetName: "project_events",
|
||||
SQL: `SELECT * FROM paliad.project_events
|
||||
WHERE project_id IN ` + visibleProjectsSubquery + `
|
||||
ORDER BY id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "approval_requests",
|
||||
SQL: `SELECT * FROM paliad.approval_requests
|
||||
WHERE requested_by = $1
|
||||
OR decided_by = $1
|
||||
OR project_id IN ` + visibleProjectsSubquery + `
|
||||
ORDER BY id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "checklist_instances",
|
||||
SQL: `SELECT * FROM paliad.checklist_instances
|
||||
WHERE project_id IN ` + visibleProjectsSubquery + `
|
||||
ORDER BY id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
|
||||
// --- personal sidecars (my_*) ---
|
||||
{
|
||||
SheetName: "me",
|
||||
SQL: `SELECT id, email, display_name, office, profession, job_title,
|
||||
practice_group, lang, reminder_morning_time, reminder_evening_time,
|
||||
reminder_timezone, reminder_warning_offset_days, escalation_contact_id,
|
||||
email_preferences, additional_offices, global_role, forum_pref,
|
||||
created_at, updated_at
|
||||
FROM paliad.users
|
||||
WHERE id = $1`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "my_caldav_config",
|
||||
SQL: `SELECT user_id, url, username, calendar_path, enabled,
|
||||
last_sync_at, last_sync_error, created_at, updated_at
|
||||
FROM paliad.user_caldav_config
|
||||
WHERE user_id = $1`,
|
||||
Args: []any{uid},
|
||||
DropColumns: []string{"password_encrypted"}, // belt-and-braces; the SELECT above already omits it
|
||||
},
|
||||
{
|
||||
SheetName: "my_views",
|
||||
SQL: `SELECT * FROM paliad.user_views
|
||||
WHERE user_id = $1
|
||||
ORDER BY id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "my_pinned_projects",
|
||||
SQL: `SELECT * FROM paliad.user_pinned_projects
|
||||
WHERE user_id = $1
|
||||
ORDER BY project_id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "my_card_layouts",
|
||||
SQL: `SELECT * FROM paliad.user_card_layouts
|
||||
WHERE user_id = $1
|
||||
ORDER BY id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
{
|
||||
SheetName: "my_paliadin_turns",
|
||||
SQL: `SELECT * FROM paliad.paliadin_turns
|
||||
WHERE user_id = $1
|
||||
ORDER BY started_at`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
|
||||
// --- restricted users-referenced sheet ---
|
||||
// Surfaces only id/email/display_name/office/profession for users
|
||||
// who appear as FKs anywhere in the export — avoids dumping all 47
|
||||
// users on a personal-scope handoff.
|
||||
{
|
||||
SheetName: "users_referenced",
|
||||
SQL: `SELECT id, email, display_name, office, profession
|
||||
FROM paliad.users u
|
||||
WHERE u.id IN (
|
||||
SELECT created_by FROM paliad.projects WHERE id IN ` + visibleProjectsSubquery + `
|
||||
UNION SELECT created_by FROM paliad.deadlines WHERE project_id IN ` + visibleProjectsSubquery + `
|
||||
UNION SELECT created_by FROM paliad.appointments WHERE project_id IN ` + visibleProjectsSubquery + `
|
||||
UNION SELECT created_by FROM paliad.project_events WHERE project_id IN ` + visibleProjectsSubquery + `
|
||||
UNION SELECT user_id FROM paliad.project_teams WHERE project_id IN ` + visibleProjectsSubquery + `
|
||||
UNION SELECT created_by FROM paliad.notes WHERE COALESCE(project_id,
|
||||
(SELECT d.project_id FROM paliad.deadlines d WHERE d.id = notes.deadline_id),
|
||||
(SELECT a.project_id FROM paliad.appointments a WHERE a.id = notes.appointment_id),
|
||||
(SELECT pe.project_id FROM paliad.project_events pe WHERE pe.id = notes.project_event_id)
|
||||
) IN ` + visibleProjectsSubquery + `
|
||||
UNION SELECT $1::uuid
|
||||
)
|
||||
ORDER BY id`,
|
||||
Args: []any{uid},
|
||||
},
|
||||
|
||||
// --- reference data (read-only, prefixed ref__) ---
|
||||
// Same set as project scope; included so the workbook is
|
||||
// interpretable standalone without paliad context.
|
||||
{
|
||||
SheetName: "ref__proceeding_types",
|
||||
SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`,
|
||||
},
|
||||
{
|
||||
SheetName: "ref__event_types",
|
||||
SQL: `SELECT * FROM paliad.event_types ORDER BY id`,
|
||||
},
|
||||
{
|
||||
SheetName: "ref__event_categories",
|
||||
SQL: `SELECT * FROM paliad.event_categories ORDER BY id`,
|
||||
},
|
||||
{
|
||||
SheetName: "ref__deadline_rules",
|
||||
SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`,
|
||||
},
|
||||
{
|
||||
SheetName: "ref__deadline_concepts",
|
||||
SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`,
|
||||
},
|
||||
{
|
||||
SheetName: "ref__courts",
|
||||
SQL: `SELECT * FROM paliad.courts ORDER BY id`,
|
||||
},
|
||||
{
|
||||
SheetName: "ref__countries",
|
||||
SQL: `SELECT * FROM paliad.countries ORDER BY code`,
|
||||
},
|
||||
{
|
||||
SheetName: "ref__holidays",
|
||||
SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit row helpers (used by the handler; here to keep all export-related
|
||||
// SQL in one file).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// WriteAuditRow inserts a system_audit_log row before the export runs and
|
||||
// returns the new row id. The handler PATCHes the row with file_size_bytes
|
||||
// + final row_counts on success or marks it failed on error.
|
||||
func (s *ExportService) WriteAuditRow(ctx context.Context, spec ExportSpec) (uuid.UUID, error) {
|
||||
meta := map[string]any{
|
||||
"requested_at": spec.GeneratedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
mb, _ := json.Marshal(meta)
|
||||
var id uuid.UUID
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`INSERT INTO paliad.system_audit_log
|
||||
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||
VALUES ('data_export', $1, $2, $3, $4, $5::jsonb)
|
||||
RETURNING id`,
|
||||
spec.ActorID, spec.ActorEmail, spec.Scope, spec.ScopeRoot, string(mb),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("audit insert: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// PatchAuditRowSuccess updates the audit row with final row counts and the
|
||||
// generated artifact size.
|
||||
func (s *ExportService) PatchAuditRowSuccess(ctx context.Context, id uuid.UUID, meta ExportMeta, fileSizeBytes int64) error {
|
||||
payload := map[string]any{
|
||||
"row_counts": meta.RowCounts,
|
||||
"file_size_bytes": fileSizeBytes,
|
||||
"warnings": meta.Warnings,
|
||||
"completed_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
mb, _ := json.Marshal(payload)
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.system_audit_log
|
||||
SET metadata = metadata || $2::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id, string(mb),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit patch success: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PatchAuditRowFailure marks the audit row as a failed export and stores
|
||||
// the error string. Uses a separate event_type so dashboards can count
|
||||
// failures distinctly.
|
||||
func (s *ExportService) PatchAuditRowFailure(ctx context.Context, id uuid.UUID, errStr string) {
|
||||
payload := map[string]any{
|
||||
"error": errStr,
|
||||
"failed_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
mb, _ := json.Marshal(payload)
|
||||
// Best-effort — never propagate audit-write errors back to the caller
|
||||
// because the original export error is the real one to bubble.
|
||||
_, _ = s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.system_audit_log
|
||||
SET event_type = 'data_export_failed',
|
||||
metadata = metadata || $2::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id, string(mb),
|
||||
)
|
||||
}
|
||||
460
internal/services/export_service_test.go
Normal file
460
internal/services/export_service_test.go
Normal file
@@ -0,0 +1,460 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for the ExportService writer plumbing.
|
||||
//
|
||||
// Live DB behaviour (the actual personal-scope query running against
|
||||
// Postgres) is covered by the integration test in
|
||||
// export_service_live_test.go (skipped without TEST_DATABASE_URL).
|
||||
//
|
||||
// What's pinned here:
|
||||
//
|
||||
// - formatCellValue value coercion (bool / time / []byte JSON / string / nil)
|
||||
// - piiColumnDenyRegex catches the canonical credential-shaped names
|
||||
// - buildCSV emits UTF-8 BOM + RFC 4180 quoting + survives umlauts
|
||||
// - buildJSON has the expected top-level shape
|
||||
// - metaToKeyValueRows keeps a stable key order (deterministic xlsx)
|
||||
// - ExportFilename + slugifyFilename produce safe filenames
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestFormatCellValue_Booleans(t *testing.T) {
|
||||
if got := formatCellValue(true); got != "TRUE" {
|
||||
t.Fatalf("true → %q, want TRUE", got)
|
||||
}
|
||||
if got := formatCellValue(false); got != "FALSE" {
|
||||
t.Fatalf("false → %q, want FALSE", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCellValue_NilEmpty(t *testing.T) {
|
||||
if got := formatCellValue(nil); got != "" {
|
||||
t.Fatalf("nil → %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCellValue_Time_RFC3339UTC(t *testing.T) {
|
||||
ts := time.Date(2026, 5, 19, 14, 23, 45, 0, time.UTC)
|
||||
got := formatCellValue(ts)
|
||||
if got != "2026-05-19T14:23:45Z" {
|
||||
t.Fatalf("timestamp → %q, want RFC 3339 UTC", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCellValue_Time_DateOnly_MidnightUTC(t *testing.T) {
|
||||
// A DATE column comes back as time.Time at midnight UTC.
|
||||
ts := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)
|
||||
got := formatCellValue(ts)
|
||||
if got != "2026-05-19" {
|
||||
t.Fatalf("date → %q, want ISO YYYY-MM-DD", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCellValue_Time_ZeroValue(t *testing.T) {
|
||||
got := formatCellValue(time.Time{})
|
||||
if got != "" {
|
||||
t.Fatalf("zero time → %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCellValue_JSONBytes_CompactedOneLine(t *testing.T) {
|
||||
// jsonb columns come back as []byte holding pretty JSON. The writer
|
||||
// must compact it onto one line so cells don't wrap.
|
||||
pretty := []byte("{\n \"a\": 1,\n \"b\": [\n 2,\n 3\n ]\n}")
|
||||
got := formatCellValue(pretty)
|
||||
if strings.ContainsRune(got, '\n') {
|
||||
t.Fatalf("compacted JSON has newline: %q", got)
|
||||
}
|
||||
// Must still be valid JSON.
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &m); err != nil {
|
||||
t.Fatalf("compacted JSON is no longer valid: %v (input=%q)", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCellValue_PlainBytes_AsString(t *testing.T) {
|
||||
// Postgres returns text/uuid columns as []byte. Non-JSON-shaped
|
||||
// payload must be returned verbatim (preserves umlauts).
|
||||
got := formatCellValue([]byte("Müller & Söhne"))
|
||||
if got != "Müller & Söhne" {
|
||||
t.Fatalf("bytes → %q, want UTF-8 string preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCellValue_String(t *testing.T) {
|
||||
if got := formatCellValue("Hügelmäßig"); got != "Hügelmäßig" {
|
||||
t.Fatalf("string → %q, want passthrough", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCellValue_Numbers(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{int(42), "42"},
|
||||
{int64(-7), "-7"},
|
||||
{uint32(99), "99"},
|
||||
{float64(3.14), "3.14"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := formatCellValue(c.in); got != c.want {
|
||||
t.Errorf("%v → %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPIIColumnDenyRegex_MatchesKnownSecrets(t *testing.T) {
|
||||
must := []string{
|
||||
"password",
|
||||
"password_encrypted",
|
||||
"PASSWORD_HASH",
|
||||
"api_key",
|
||||
"apiKey",
|
||||
"api-key",
|
||||
"private_key",
|
||||
"some_secret",
|
||||
"jwt_token",
|
||||
"access_token",
|
||||
}
|
||||
for _, name := range must {
|
||||
if !piiColumnDenyRegex.MatchString(name) {
|
||||
t.Errorf("deny regex should match %q but did not", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPIIColumnDenyRegex_DoesNotMatchInnocuousNames(t *testing.T) {
|
||||
// Sanity: common business columns must NOT trip the deny regex.
|
||||
innocuous := []string{
|
||||
"id",
|
||||
"title",
|
||||
"created_at",
|
||||
"event_type",
|
||||
"project_id",
|
||||
"email",
|
||||
"display_name",
|
||||
"office",
|
||||
"profession",
|
||||
}
|
||||
for _, name := range innocuous {
|
||||
if piiColumnDenyRegex.MatchString(name) {
|
||||
t.Errorf("deny regex should NOT match %q but did", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCSV_BOM_AndUmlauts(t *testing.T) {
|
||||
cols := []string{"id", "title"}
|
||||
rows := [][]string{
|
||||
{"1", "Mündliche Verhandlung"},
|
||||
{"2", "Süßmäßig"},
|
||||
}
|
||||
got, err := buildCSV(cols, rows)
|
||||
if err != nil {
|
||||
t.Fatalf("buildCSV: %v", err)
|
||||
}
|
||||
// BOM
|
||||
if len(got) < 3 || got[0] != 0xEF || got[1] != 0xBB || got[2] != 0xBF {
|
||||
t.Fatalf("missing UTF-8 BOM: % x", got[:3])
|
||||
}
|
||||
// Body is valid UTF-8 with umlauts preserved
|
||||
body := string(got[3:])
|
||||
if !strings.Contains(body, "Mündliche Verhandlung") {
|
||||
t.Errorf("umlaut text missing from CSV body: %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "Süßmäßig") {
|
||||
t.Errorf("ß / umlaut text missing from CSV body: %q", body)
|
||||
}
|
||||
// Header row first
|
||||
lines := strings.SplitN(body, "\n", 3)
|
||||
if !strings.HasPrefix(lines[0], "id,title") {
|
||||
t.Errorf("first line should be CSV header, got %q", lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCSV_QuotingForCommaAndQuote(t *testing.T) {
|
||||
cols := []string{"id", "label"}
|
||||
rows := [][]string{
|
||||
{"1", `Müller, Schulze "Krause" & Co`},
|
||||
}
|
||||
got, err := buildCSV(cols, rows)
|
||||
if err != nil {
|
||||
t.Fatalf("buildCSV: %v", err)
|
||||
}
|
||||
body := string(got[3:])
|
||||
// RFC 4180: comma + double-quote in field → wrap in quotes, escape "
|
||||
if !strings.Contains(body, `"Müller, Schulze ""Krause"" & Co"`) {
|
||||
t.Errorf("RFC 4180 quoting wrong: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildJSON_TopLevelShape(t *testing.T) {
|
||||
tables := map[string][]map[string]string{
|
||||
"projects": {{"id": "u1", "title": "Acme"}},
|
||||
}
|
||||
meta := ExportMeta{
|
||||
SchemaVersion: 1,
|
||||
FirmName: "HLC",
|
||||
Scope: ExportScopePersonal,
|
||||
GeneratedAt: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
|
||||
RowCounts: map[string]int{"projects": 1},
|
||||
}
|
||||
got, err := buildJSON(tables, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("buildJSON: %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(got, &payload); err != nil {
|
||||
t.Fatalf("buildJSON not valid JSON: %v", err)
|
||||
}
|
||||
if _, ok := payload["meta"]; !ok {
|
||||
t.Errorf("payload missing meta key")
|
||||
}
|
||||
if _, ok := payload["tables"]; !ok {
|
||||
t.Errorf("payload missing tables key")
|
||||
}
|
||||
if !bytes.Contains(got, []byte(`"Acme"`)) {
|
||||
t.Errorf("payload missing project title: %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaToKeyValueRows_StableOrder(t *testing.T) {
|
||||
m := ExportMeta{
|
||||
SchemaVersion: 1,
|
||||
FirmName: "HLC",
|
||||
Scope: ExportScopePersonal,
|
||||
GeneratedAt: time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC),
|
||||
GeneratedByID: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
|
||||
GeneratedByEml: "m@hlc.de",
|
||||
GeneratedByLbl: "m",
|
||||
RowCounts: map[string]int{"projects": 11, "deadlines": 26, "appointments": 5},
|
||||
Warnings: []string{"sheet=foo column=token dropped"},
|
||||
}
|
||||
rows1 := metaToKeyValueRows(m)
|
||||
rows2 := metaToKeyValueRows(m)
|
||||
if len(rows1) != len(rows2) {
|
||||
t.Fatalf("row count differs between runs")
|
||||
}
|
||||
for i := range rows1 {
|
||||
if rows1[i] != rows2[i] {
|
||||
t.Fatalf("row %d differs between runs: %v vs %v", i, rows1[i], rows2[i])
|
||||
}
|
||||
}
|
||||
// row_count rows must be sorted (deadlines < projects < appointments? no: alpha)
|
||||
// → row_count.appointments < row_count.deadlines < row_count.projects
|
||||
wantOrder := []string{"row_count.appointments", "row_count.deadlines", "row_count.projects"}
|
||||
gotKeys := []string{}
|
||||
for _, r := range rows1 {
|
||||
if strings.HasPrefix(r[0], "row_count.") {
|
||||
gotKeys = append(gotKeys, r[0])
|
||||
}
|
||||
}
|
||||
for i, k := range wantOrder {
|
||||
if i >= len(gotKeys) || gotKeys[i] != k {
|
||||
t.Errorf("row_count order wrong at %d: got %v, want %v", i, gotKeys, wantOrder)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportFilename_PerScope(t *testing.T) {
|
||||
ts := time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
scope, label, want string
|
||||
}{
|
||||
{ExportScopePersonal, "", "paliad-export-personal-2026-05-19T1423Z.zip"},
|
||||
{ExportScopeOrg, "", "paliad-export-org-2026-05-19T1423Z.zip"},
|
||||
{ExportScopeProject, "Siemens AG", "paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip"},
|
||||
{ExportScopeProject, "Hügel & Söhne", "paliad-export-project-H-gel-S-hne-2026-05-19T1423Z.zip"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := ExportFilename(c.scope, c.label, ts)
|
||||
if got != c.want {
|
||||
t.Errorf("ExportFilename(%q, %q) → %q, want %q", c.scope, c.label, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugifyFilename_StripsUnsafe(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"Siemens AG", "Siemens-AG"},
|
||||
{"Müller & Söhne", "M-ller-S-hne"},
|
||||
{" /etc/passwd ", "etc-passwd"},
|
||||
{"", ""},
|
||||
{"this-is-already-fine", "this-is-already-fine"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := slugifyFilename(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("slugifyFilename(%q) → %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZipDeterminism verifies that two bundle assemblies of the same
|
||||
// sheet data + same meta produce byte-identical output. This is the core
|
||||
// guarantee m signed off on (Q6=yes deterministic).
|
||||
//
|
||||
// We can't go through writeBundle here (it needs a DB), so we exercise
|
||||
// the deterministic path at the layer where it matters: the outer zip's
|
||||
// file order + each entry's deterministic content + fixed Modified time.
|
||||
func TestZipDeterminism_TwoRunsSameBytes(t *testing.T) {
|
||||
meta := ExportMeta{
|
||||
SchemaVersion: 1,
|
||||
FirmName: "HLC",
|
||||
Scope: ExportScopePersonal,
|
||||
GeneratedAt: time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC),
|
||||
RowCounts: map[string]int{"projects": 1, "deadlines": 0},
|
||||
}
|
||||
sheets := []collectedSheet{
|
||||
{name: "projects", columns: []string{"id", "title"}, rows: [][]string{{"u1", "Acme"}}},
|
||||
{name: "deadlines", columns: []string{"id", "due_date"}, rows: nil},
|
||||
}
|
||||
|
||||
first := assembleBundleForTest(t, sheets, meta)
|
||||
second := assembleBundleForTest(t, sheets, meta)
|
||||
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatalf("two assemblies of same data produced different bytes (%d vs %d)", len(first), len(second))
|
||||
}
|
||||
// Sanity: the bundle is a valid zip and contains the expected files.
|
||||
zr, err := zip.NewReader(bytes.NewReader(first), int64(len(first)))
|
||||
if err != nil {
|
||||
t.Fatalf("bundle is not a valid zip: %v", err)
|
||||
}
|
||||
wantFiles := []string{"README.txt", "__meta.json", "csv/deadlines.csv", "csv/projects.csv", "paliad-export.json", "paliad-export.xlsx"}
|
||||
gotFiles := []string{}
|
||||
for _, f := range zr.File {
|
||||
gotFiles = append(gotFiles, f.Name)
|
||||
}
|
||||
for _, want := range wantFiles {
|
||||
found := false
|
||||
for _, got := range gotFiles {
|
||||
if got == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("missing %q in bundle (got %v)", want, gotFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assembleBundleForTest mirrors writeBundle's assembly step without
|
||||
// hitting the DB. Exposed as a test helper here to keep production code
|
||||
// strictly DB-coupled while still pinning the deterministic-zip contract.
|
||||
func assembleBundleForTest(t *testing.T, sheets []collectedSheet, meta ExportMeta) []byte {
|
||||
t.Helper()
|
||||
xlsxBytes, err := buildXLSX(sheets, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("buildXLSX: %v", err)
|
||||
}
|
||||
tables := map[string][]map[string]string{}
|
||||
for _, sh := range sheets {
|
||||
rs := make([]map[string]string, 0, len(sh.rows))
|
||||
for _, r := range sh.rows {
|
||||
obj := map[string]string{}
|
||||
for i, c := range sh.columns {
|
||||
if i < len(r) {
|
||||
obj[c] = r[i]
|
||||
}
|
||||
}
|
||||
rs = append(rs, obj)
|
||||
}
|
||||
tables[sh.name] = rs
|
||||
}
|
||||
jsonBytes, err := buildJSON(tables, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("buildJSON: %v", err)
|
||||
}
|
||||
csvBlobs := map[string][]byte{}
|
||||
for _, sh := range sheets {
|
||||
b, err := buildCSV(sh.columns, sh.rows)
|
||||
if err != nil {
|
||||
t.Fatalf("buildCSV %q: %v", sh.name, err)
|
||||
}
|
||||
csvBlobs[sh.name] = b
|
||||
}
|
||||
metaJSON, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("meta marshal: %v", err)
|
||||
}
|
||||
readme := buildREADME(meta)
|
||||
|
||||
// Mirror writeBundle's zip-assembly: sort entries, fixed mod time.
|
||||
type ent struct {
|
||||
name string
|
||||
body []byte
|
||||
}
|
||||
entries := []ent{
|
||||
{"README.txt", []byte(readme)},
|
||||
{"__meta.json", metaJSON},
|
||||
{"paliad-export.json", jsonBytes},
|
||||
{"paliad-export.xlsx", xlsxBytes},
|
||||
}
|
||||
// CSV names sorted.
|
||||
for _, sh := range sheets {
|
||||
entries = append(entries, ent{"csv/" + sh.name + ".csv", csvBlobs[sh.name]})
|
||||
}
|
||||
// Outer sort to mirror writeBundle.
|
||||
for i := 1; i < len(entries); i++ {
|
||||
for j := i; j > 0 && entries[j-1].name > entries[j].name; j-- {
|
||||
entries[j-1], entries[j] = entries[j], entries[j-1]
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
fixedMod := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, e := range entries {
|
||||
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate, Modified: fixedMod}
|
||||
fw, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %q: %v", e.name, err)
|
||||
}
|
||||
if _, err := fw.Write(e.body); err != nil {
|
||||
t.Fatalf("zip write %q: %v", e.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestExportScopeConstants ensures the scope discriminator strings are
|
||||
// the stable contract — the audit row, __meta sheet, and external
|
||||
// importers depend on them not drifting.
|
||||
func TestExportScopeConstants(t *testing.T) {
|
||||
if ExportScopePersonal != "personal" {
|
||||
t.Errorf("ExportScopePersonal drifted: %q", ExportScopePersonal)
|
||||
}
|
||||
if ExportScopeProject != "project" {
|
||||
t.Errorf("ExportScopeProject drifted: %q", ExportScopeProject)
|
||||
}
|
||||
if ExportScopeOrg != "org" {
|
||||
t.Errorf("ExportScopeOrg drifted: %q", ExportScopeOrg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPIIRegex_IsExported makes sure the deny regex stays a compiled
|
||||
// regexp (catches accidental nil if someone refactors).
|
||||
func TestPIIRegex_IsExported(t *testing.T) {
|
||||
if piiColumnDenyRegex == nil {
|
||||
t.Fatal("piiColumnDenyRegex is nil")
|
||||
}
|
||||
if _, ok := any(piiColumnDenyRegex).(*regexp.Regexp); !ok {
|
||||
t.Fatal("piiColumnDenyRegex is not *regexp.Regexp")
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,15 @@ type UIDeadline struct {
|
||||
Priority string `json:"priority"`
|
||||
RuleRef string `json:"ruleRef"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
// LegalSourceDisplay is the pretty form (e.g. "UPC RoP R.220(1)")
|
||||
// of LegalSource, produced by FormatLegalSourceDisplay. Frontend
|
||||
// renders this in the deadline card meta line; falls back to
|
||||
// RuleRef when LegalSource is empty.
|
||||
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
||||
// 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 `json:"legalSourceURL,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
NotesEN string `json:"notesEN,omitempty"`
|
||||
DueDate string `json:"dueDate"`
|
||||
@@ -283,6 +292,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
}
|
||||
if r.LegalSource != nil {
|
||||
d.LegalSource = *r.LegalSource
|
||||
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
|
||||
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
|
||||
}
|
||||
if r.DeadlineNotes != nil {
|
||||
d.Notes = *r.DeadlineNotes
|
||||
@@ -599,6 +610,7 @@ type RuleCalculationRule struct {
|
||||
RuleRef string `json:"ruleRef,omitempty"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
||||
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
||||
DurationValue int `json:"durationValue"`
|
||||
DurationUnit string `json:"durationUnit"`
|
||||
Party string `json:"party,omitempty"`
|
||||
@@ -670,6 +682,7 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
|
||||
if rule.LegalSource != nil {
|
||||
out.Rule.LegalSource = *rule.LegalSource
|
||||
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
|
||||
out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource)
|
||||
}
|
||||
if rule.PrimaryParty != nil {
|
||||
out.Rule.Party = *rule.PrimaryParty
|
||||
@@ -1217,6 +1230,8 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
|
||||
}
|
||||
if r.LegalSource != nil {
|
||||
d.LegalSource = *r.LegalSource
|
||||
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
|
||||
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
|
||||
}
|
||||
if r.DeadlineNotes != nil {
|
||||
d.Notes = *r.DeadlineNotes
|
||||
|
||||
@@ -14,13 +14,15 @@ import (
|
||||
|
||||
// submissionCodeShapeRegex is the proceeding-code-prefixed shape
|
||||
// installed by mig 098 (t-paliad-209): the proceeding's 3-segment code
|
||||
// (`^[a-z_]+\.[a-z_]+\.[a-z_]+\.`) followed by at least one suffix
|
||||
// segment (and optional further dot-separated segments). The regex
|
||||
// allows underscores so the legacy archived bucket (`_archived_…`) and
|
||||
// hand-seeded test rules (e.g. `s11a.initial`) match alongside the
|
||||
// canonical taxonomy. Mirrors the assertion in mig 098 §6.1.
|
||||
// (`^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.`) followed by at least one
|
||||
// suffix segment (and optional further dot-separated segments). The
|
||||
// regex allows digits so EPA suffixes like `r106` / `r71_3` / `r116`
|
||||
// (statutory rule numbers in the suffix) pass alongside canonical
|
||||
// dotted-word codes. Underscores cover the legacy archived bucket
|
||||
// (`_archived_…`) and hand-seeded test rules. Mirrors the assertion in
|
||||
// mig 098 §6.1.
|
||||
var submissionCodeShapeRegex = regexp.MustCompile(
|
||||
`^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$`)
|
||||
`^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+(\..*)?$`)
|
||||
|
||||
// TestSubmissionCodeShape walks every active+published row in
|
||||
// paliad.deadline_rules and asserts that submission_code matches the
|
||||
|
||||
Reference in New Issue
Block a user