Compare commits
55 Commits
mai/mendel
...
mai/copern
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ba0ba15e6 | |||
| 0817c04609 | |||
| 1a8eee2a10 | |||
| 4472faf224 | |||
| 2504e50f29 | |||
| d244ff5158 | |||
| 741cab4d25 | |||
| 0263a0e932 | |||
| 0fd02bf033 | |||
| dce98e273b | |||
| c1c5532d52 | |||
| ee837815e1 | |||
| e035512e70 | |||
| 6401a8198d | |||
| 6a202411f6 | |||
| d924ab9743 | |||
| fb2896c836 | |||
| 705e1a2e79 | |||
| d8acbd613c | |||
| c01f3f2db8 | |||
| 2fa47278ce | |||
| 6c7e9ef44d | |||
| 17cd5b3b0c | |||
| d127c768f7 | |||
| dab06e068f | |||
| defa516e4f | |||
| 6ff26e8a6e | |||
| 2c94420a4b | |||
| 3677c81fbe | |||
| 8ea3509b98 | |||
| 5ff637ab70 | |||
| 265f240151 | |||
| 1039680878 | |||
| 773654523e | |||
| f7585376df | |||
| f9ff7b93e8 | |||
| 86d20ed6d4 | |||
| 1639b3919a | |||
| bf31935767 | |||
| aee177a303 | |||
| 28c7215458 | |||
| 9aebe5780b | |||
| 8a43aed100 | |||
| 52b3feb9d2 | |||
| 586ba29b86 | |||
| 0b57ec5257 | |||
| 2007ad39bb | |||
| b7c4de9ac9 | |||
| 8e0e4c9dcc | |||
| 023f32d4f2 | |||
| 139c4a6406 | |||
| 6e8e2e7653 | |||
| de20356cec | |||
| 1e1c84b0f6 | |||
| e1b91a9481 |
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,8 +177,26 @@ 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),
|
||||
}
|
||||
|
||||
// t-paliad-215 Slice 1 — submission generator. Three services
|
||||
// stitched together by handlers/submissions.go: registry pulls
|
||||
// templates from Gitea (reuses GITEA_TOKEN env), vars builds
|
||||
// the placeholder map from project + parties + rule, renderer
|
||||
// merges {{placeholder}} tokens into the .docx.
|
||||
svcBundle.SubmissionRegistry = services.NewTemplateRegistry(giteaToken, branding.Name)
|
||||
svcBundle.SubmissionVars = services.NewSubmissionVarsService(
|
||||
pool,
|
||||
svcBundle.Project,
|
||||
svcBundle.Party,
|
||||
svcBundle.Users,
|
||||
)
|
||||
svcBundle.SubmissionRenderer = services.NewSubmissionRenderer()
|
||||
|
||||
// Paliadin backend selection.
|
||||
//
|
||||
// PALIADIN_BACKEND (t-paliad-194 / m/paliad#38):
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
332
docs/design-approval-suggest-changes-2026-05-19.md
Normal file
332
docs/design-approval-suggest-changes-2026-05-19.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# Design — "Suggest changes" action on approval flow
|
||||
|
||||
**Author:** hertz (inventor)
|
||||
**Date:** 2026-05-19
|
||||
**Task:** t-paliad-216 (m/paliad in-flight)
|
||||
**Branch:** `mai/hertz/inventor-suggest-changes`
|
||||
**Status:** DESIGN — open questions await m before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
Add a fourth action **"Änderungen vorschlagen"** ("Suggest changes") to the approval flow, alongside Approve / Reject / Revoke. Use case: the approver doesn't want to accept the proposed change as-is, but doesn't want to reject outright — they edit the proposed values into a counter-proposal and submit it back into the same approval flow.
|
||||
|
||||
**Mental model (m, 2026-05-19):** suggest-changes is not "ping the requester to fix it" — it's the approver **authoring a counter-proposal** that gets re-injected into the approval flow as a fresh `pending` row. The original requester (now potentially an eligible approver of the counter, since they're no longer the requested_by) sees:
|
||||
- the **old row** in their /inbox as `changes_requested` ("Abgelehnt mit Vorschlag" / "Declined with changes") — historical record of their original attempt;
|
||||
- the **new row** in /inbox as `pending` — the counter, which they can approve, reject, revoke (n/a, not theirs), or suggest changes back on. Everyone else eligible sees the new row too. 4-Augen still holds: the counter's requested_by (the approver who suggested it) cannot self-approve.
|
||||
|
||||
Click flow:
|
||||
1. Approver opens an editable modal on the pending row showing the requester's proposed values. Edits any field. Writes a free-text note ("Bitte den Termin um 9:00 statt 8:00, weil der Raum sonst kollidiert").
|
||||
2. POST `/api/approval-requests/{id}/suggest-changes` with `{note, counter_payload}`.
|
||||
3. Server, in one tx: closes the old row (`changes_requested`, `decision_note=note`), reverts the entity from `pre_image`, then immediately inserts a **new** `pending` approval_requests row authored by the approver with `payload=counter_payload`, re-applies the counter to the entity, marks `pending_request_id` to the new row, emits two events (`*_approval_changes_suggested` + `*_approval_requested`). `previous_request_id` FK links new → old for chain traversal.
|
||||
|
||||
The pending audience for the new row is the same as any fresh `Submit*` — the existing notification + visibility plumbing handles it without special-casing.
|
||||
|
||||
---
|
||||
|
||||
## 0a. m's decisions (2026-05-19)
|
||||
|
||||
| # | Header | m picked | Reasoning note (when different from recommendation) |
|
||||
|---|---|---|---|
|
||||
| Q1 | State machine | **(a) New status `changes_requested`.** | As recommended. |
|
||||
| Q2 | Entity state | **(a) Reverts to pre_image, same as Reject.** | As recommended. The counter is then re-applied in the same tx by the new approval row's write-then-approve cycle. |
|
||||
| Q3 | Chain depth | **(a) Yes, across chained rows.** | As recommended. |
|
||||
| Q4 | Note shape | **Hybrid: approver can edit the proposed values (counter-proposal) AND/OR leave free-text in `decision_note`.** | Differs from (a). Inventor picked free-text-only; m's twist: the suggestion should ALSO carry concrete edits. This adds a `counter_payload jsonb` column on `approval_requests` and turns "suggest-changes" into an action that authors a real counter-proposal, not just a hint. |
|
||||
| Q5 | Surface | **(a) /inbox only — v1.** | As recommended. Email + entity-detail badge are Phase 2. |
|
||||
| Q6 | Requester actions | **Different model: the counter is a NEW pending approval_request row, not an "edit + resubmit" CTA on the requester side.** | Differs from (a). m's reframing: instead of routing back to the requester to act on, the suggestion IS the next request. Original requester sees the old row as `changes_requested` (status pill "Abgelehnt mit Vorschlag" or similar). Original requester then sees the NEW row in /inbox like any pending — and **may approve it themselves**, because they are no longer the row's requested_by (the suggesting approver is). Everyone else eligible sees it too. Cleaner workflow, removes the "edit-and-resubmit CTA" from the requester role entirely. |
|
||||
| Q7 | Notifications | **(b) Notify all eligible approvers + the original requester for the NEW pending row.** | Consistent with Q6. The counter is a fresh `pending` request, so the existing Submit*-notification audience applies. The original requester needs the ping because they're now an eligible approver of the counter — no special-case path. |
|
||||
| Q8 | Audit shape | **(a) New event_type `*_approval_changes_suggested` per entity.** | As recommended. The new row also emits a normal `*_approval_requested` event, so the Verlauf chronology naturally captures the chain. |
|
||||
|
||||
The decisions above lock the design. §3 has been rewritten to reflect them; §2 (open questions) is retained as the historical record of what was open before the decisions.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — what's already in the code (verified 2026-05-19)
|
||||
|
||||
- **State machine** in `internal/services/approval_service.go`:
|
||||
- `paliad.approval_requests.status` CHECK is already `('pending', 'approved', 'rejected', 'revoked', 'superseded')` — the `superseded` value is defined as a Go constant `RequestStatusSuperseded` but never written by the live service (reserved).
|
||||
- `paliad.{deadlines,appointments}.approval_status` CHECK is `('approved', 'pending', 'legacy')` — three values only.
|
||||
- Shared kernel `decide(requestID, callerID, finalStatus, note)` powers Approve / Reject / Revoke. Approve invokes `applyApproved`; Reject + Revoke invoke `applyRevert` (restores entity from `pre_image`).
|
||||
- Self-approval blocked at 3 layers: `canApprove` Go gate, `approval_requests_no_self_approval` DB CHECK, deadlock-check excludes requester from pool.
|
||||
- **Handlers** in `internal/handlers/approvals.go`:
|
||||
- `POST /api/approval-requests/{id}/approve`
|
||||
- `POST /api/approval-requests/{id}/reject`
|
||||
- `POST /api/approval-requests/{id}/revoke`
|
||||
- `GET /api/approval-requests/{id}` — single hydrated request
|
||||
- **Per-viewer flags** (t-paliad-202, shipped): every row carries `viewer_can_approve` + `viewer_is_requester` resolved server-side so the UI can grey out buttons the server would reject. Server still enforces — the flags are a UX hint.
|
||||
- **Frontend**:
|
||||
- `frontend/src/client/inbox.ts` wires three buttons per pending row (approve/reject/revoke). Reject opens `window.prompt()` for the note; approve+revoke don't.
|
||||
- `frontend/src/client/views/shape-list.ts` (row_action="approve") stamps the row with action buttons + diff + `decision_note` display if present.
|
||||
- **Audit**: event types `*_approval_requested`, `*_approval_approved`, `*_approval_rejected`, `*_approval_revoked` emitted to `paliad.project_events` (one per entity_type prefix).
|
||||
- **Decision note**: `paliad.approval_requests.decision_note text` — a single free-text column, last-write-wins. Already populated on Reject (Approve also accepts an optional note).
|
||||
|
||||
---
|
||||
|
||||
## 2. Design questions (the open list — see §6 for answered)
|
||||
|
||||
Pre-recommendations from inventor. m will pick via AskUserQuestion.
|
||||
|
||||
### State machine
|
||||
|
||||
**Q1 — Where does "suggest changes" sit on the lifecycle?**
|
||||
- **(a) New status `changes_requested` (RECOMMENDED).** The approval_requests row transitions pending → changes_requested. Sibling of approved/rejected/revoked/superseded. The row is terminal in that status; a re-submit creates a fresh row (linked via `previous_request_id`).
|
||||
- (b) Reuse `rejected` with `is_revisable=true` flag. Cheap, but conflates two semantically distinct outcomes ("we'll never want this" vs. "tweak X and try again").
|
||||
- (c) Auto-revoke the current row, mark the entity for edit, requester creates a new approval row when ready. Reuses existing plumbing — but loses the approver's note as a first-class thing (it'd just be a comment on the project_events row).
|
||||
- (d) Other (you'll tell us).
|
||||
|
||||
Recommend (a) — keeps the audit lifecycle clear, gives us a clean place to hang the suggestion note, and is the smallest schema change (one new value in a CHECK constraint).
|
||||
|
||||
**Q2 — What happens to the entity (deadline/appointment) while in "changes requested"?**
|
||||
- **(a) Entity reverts to pre_image — same as Reject (RECOMMENDED).** approval_status flips back to `approved`. The requester edits the entity in the normal flow; saving fires a fresh `Submit*` cycle.
|
||||
- (b) Entity stays at `approval_status=pending` carrying the proposed values; requester edits "in place" through a new "amend the pending request" endpoint that mutates the same approval_request row + entity fields.
|
||||
- (c) Entity goes to a new `approval_status=draft` (would require a new value on the entity-level CHECK + UI work to handle a third entity state).
|
||||
|
||||
Recommend (a) — minimum schema change, reuses every existing path (entity edit, Submit*, applyRevert, project_events emission). The trade-off is one extra approval_requests row per cycle; we link via `previous_request_id` so the chain stays inspectable.
|
||||
|
||||
**Q3 — Can the approver suggest changes multiple times (across a chain)?**
|
||||
- **(a) Yes, across chained rows (RECOMMENDED).** Each row is terminal after suggest-changes; the requester resubmits → new pending row → approver can suggest changes again. Chain depth unbounded.
|
||||
- (b) No — one chance per entity-lifecycle; if the requester comes back, the only options are approve or reject (the suggest-changes button is hidden for the second submission).
|
||||
|
||||
Recommend (a) — bounded by the requester's patience, not by the system. Multi-round review is the norm in legal-doc workflows.
|
||||
|
||||
**Q4 — Note shape on the suggestion**
|
||||
- **(a) Free-text — reuse `decision_note` (RECOMMENDED).** Same column the existing Reject path already populates. Last-write-wins per row (but rows are terminal after suggest-changes, so there's no real "last write").
|
||||
- (b) Thread of notes — new `paliad.approval_notes` table, ordered, multi-author. Lets the requester respond inline, the approver clarify, etc.
|
||||
- (c) Structured per-field suggestions (`[{"field": "due_date", "current": "...", "suggested": "..."}]`) — a "diff-style" view.
|
||||
|
||||
Recommend (a) — matches the existing Reject UX, no new schema. (b) is right if the team wants to discuss; (c) is over-engineered for v1.
|
||||
|
||||
### UX
|
||||
|
||||
**Q5 — Where does the requester see the suggestion?**
|
||||
- **(a) /inbox under `a_role=self_requested` (RECOMMENDED for v1).** Same surface they already use to see rejected. New status pill "Änderungen vorgeschlagen" + the note + a CTA "Bearbeiten und erneut einreichen".
|
||||
- (b) A new badge on the entity's detail page (e.g. on the deadline detail page itself).
|
||||
- (c) Email + push notification.
|
||||
- (d) All of the above.
|
||||
|
||||
Recommend (a) for v1. Email reminder is a natural Phase-2 add-on (it'd reuse the existing reminder-mail plumbing). The entity-detail badge is nice but the user is already seeing the row in /inbox.
|
||||
|
||||
**Q6 — What action(s) does the requester have on a `changes_requested` row?**
|
||||
- **(a) Edit and resubmit (RECOMMENDED).** Primary action. Opens the entity's edit form pre-populated with the original `payload`. Saving fires `Submit*` → new pending request with `previous_request_id` linking back.
|
||||
- (b) Withdraw (= dismiss the row from inbox, no DB change). Mostly UI-only — the row is already terminal; "withdraw" would just be a "mark as not-pursuing" toggle.
|
||||
- (c) Both.
|
||||
|
||||
Recommend (a). The row is already terminal once status=`changes_requested`; the requester either acts on the suggestion (a) or lets the row sit in their inbox history (no action needed). Adding a "dismiss" button is a UI nice-to-have but doesn't change the data model; can defer.
|
||||
|
||||
### Notifications
|
||||
|
||||
**Q7 — Who gets notified when "suggest changes" fires?**
|
||||
- **(a) Just the requester (RECOMMENDED for v1).** Email-reminder path is reused: requester gets a mail "X hat Änderungen vorgeschlagen für …" with the note inline + a link to /inbox.
|
||||
- (b) Requester + any other potential approvers (they need to know the request is closed, not pending).
|
||||
- (c) Requester + approval-policy-defined watchers (would require a new `approval_policies.watchers` column).
|
||||
|
||||
Recommend (a). The request is terminal so other approvers don't need a "this is now your problem" ping — they wouldn't have anything to act on. They see it in /inbox under "Alle sichtbaren" anyway if curious.
|
||||
|
||||
### Audit
|
||||
|
||||
**Q8 — Audit row shape on `project_events`**
|
||||
- **(a) New event_type `*_approval_changes_suggested` per entity (RECOMMENDED).** Parallel to the existing 4 (requested/approved/rejected/revoked). Two new event types: `deadline_approval_changes_suggested`, `appointment_approval_changes_suggested`. Note text goes in metadata.
|
||||
- (b) Bundle with the resubmission — single composite event "approved-with-revisions" when the chain eventually approves.
|
||||
|
||||
Recommend (a). Each transition gets its own event row — that's how the existing audit chain already works (one event per state change). It also gives the Verlauf timeline a row to render the approver's note.
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation sketch (decisions-locked, see §0a)
|
||||
|
||||
### 3.1 Migration `103_approval_suggest_changes.up.sql`
|
||||
|
||||
```sql
|
||||
-- 1. Extend approval_requests.status CHECK to allow 'changes_requested'.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD CONSTRAINT approval_requests_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded', 'changes_requested'));
|
||||
|
||||
-- 2. Add counter_payload — the approver's edited values, becomes the
|
||||
-- `payload` of the NEW pending row spawned in the same tx as the
|
||||
-- suggest-changes call. Stored on the OLD (now changes_requested) row
|
||||
-- too so the audit chain can show "approver edited X, Y, Z" without
|
||||
-- joining to the next row.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN counter_payload jsonb NULL;
|
||||
|
||||
-- 3. Add previous_request_id FK so the new row links back to its origin.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN previous_request_id uuid NULL
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX approval_requests_previous_idx
|
||||
ON paliad.approval_requests (previous_request_id)
|
||||
WHERE previous_request_id IS NOT NULL;
|
||||
```
|
||||
|
||||
`.down.sql`: drop the index + columns, restore the original CHECK (would reject existing `changes_requested` rows — that's normal for a breaking-change down).
|
||||
|
||||
### 3.2 Service layer
|
||||
|
||||
`SuggestChanges` is the only new public method on `ApprovalService`. It runs in **one transaction** and does five things:
|
||||
|
||||
```go
|
||||
const RequestStatusChangesRequested = "changes_requested"
|
||||
|
||||
var ErrSuggestionRequiresChange = errors.New("suggestion_requires_change")
|
||||
|
||||
// SuggestChanges closes the pending request as `changes_requested`,
|
||||
// reverts the entity, then immediately inserts a new pending
|
||||
// approval_request authored by the caller carrying `counterPayload` as
|
||||
// its new payload. The new row enters the standard pending flow — anyone
|
||||
// eligible (including the original requester) can approve, reject,
|
||||
// suggest-changes-again, etc.
|
||||
//
|
||||
// Authorization: caller satisfies canApprove on the OLD row (same gate
|
||||
// as Approve / Reject). For the NEW row, the caller is the requested_by
|
||||
// — self-approval is blocked by the standard 3-layer guard. Deadlock
|
||||
// check (qualified-approver-exists-other-than-caller) runs on the new
|
||||
// row to avoid spawning an unapprovable request.
|
||||
//
|
||||
// counterPayload must differ from the old row's payload OR a non-empty
|
||||
// note must be present. A no-op suggest (same values, no note) is
|
||||
// indistinguishable from "I have no opinion" and gets rejected with
|
||||
// ErrSuggestionRequiresChange.
|
||||
func (s *ApprovalService) SuggestChanges(
|
||||
ctx context.Context,
|
||||
requestID, callerID uuid.UUID,
|
||||
counterPayload []byte, // jsonb-marshaled
|
||||
note string,
|
||||
) (newRequestID *uuid.UUID, err error) {
|
||||
// 1. Begin tx, lock old row, validate status=pending + canApprove.
|
||||
// 2. Validate: counterPayload differs from old payload OR note != "".
|
||||
// 3. Update old row: status='changes_requested', decided_by=callerID,
|
||||
// decision_note=note, counter_payload=counterPayload.
|
||||
// 4. applyRevert on the entity (uses old row's pre_image).
|
||||
// 5. Deadlock-check on the new row's required_role + projectID,
|
||||
// excluding callerID.
|
||||
// 6. INSERT new approval_requests row: requested_by=callerID,
|
||||
// pre_image=<entity-state-as-just-reverted> (= old.pre_image),
|
||||
// payload=counterPayload, required_role=old.required_role,
|
||||
// lifecycle_event=old.lifecycle_event, entity_type=old.entity_type,
|
||||
// entity_id=old.entity_id, status='pending',
|
||||
// previous_request_id=requestID.
|
||||
// 7. Re-apply the new payload to the entity (write-then-approve):
|
||||
// apply the counter_payload's field updates + mark
|
||||
// approval_status='pending' + pending_request_id=newRequestID.
|
||||
// 8. Emit *_approval_changes_suggested project_events row
|
||||
// (metadata: note, counter_payload diff vs original).
|
||||
// 9. Emit *_approval_requested project_events row for the new
|
||||
// request (same shape Submit* normally emits).
|
||||
// 10. Commit.
|
||||
}
|
||||
```
|
||||
|
||||
Steps 6 + 7 reuse the existing `Submit*` plumbing structurally — the cleanest implementation factors out an "insert approval row + apply payload to entity" helper that both `Submit*` and `SuggestChanges` call. **decide()** does not need to know about `changes_requested` because suggest-changes is not a decision-kernel transition — it's its own end-to-end action.
|
||||
|
||||
### 3.3 HTTP layer
|
||||
|
||||
```
|
||||
POST /api/approval-requests/{id}/suggest-changes
|
||||
Body: {
|
||||
"counter_payload": { ...same shape as Submit*'s payload... },
|
||||
"note": "free-text explanation, optional iff counter_payload differs from original"
|
||||
}
|
||||
Returns: 200 { "new_request_id": "uuid" }
|
||||
Errors:
|
||||
400 "suggestion_requires_change" — counter_payload == old payload AND note empty
|
||||
400 "invalid_counter_payload" — schema validation failure
|
||||
403 "self_approval_blocked" — caller == old row's requested_by
|
||||
403 "not_authorized" — caller doesn't satisfy canApprove
|
||||
404 — request not found / not visible
|
||||
409 "request_not_pending" — old row already decided
|
||||
409 "no_qualified_approver" — deadlock on the new row (only caller is eligible)
|
||||
```
|
||||
|
||||
Register in `internal/handlers/handlers.go` alongside the existing three:
|
||||
|
||||
```go
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
|
||||
```
|
||||
|
||||
### 3.4 Frontend
|
||||
|
||||
`frontend/src/client/views/shape-list.ts` — extend the pending-row action group to four buttons:
|
||||
|
||||
```ts
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
```
|
||||
|
||||
The `action` union type gains `"suggest_changes"`. Disabled-reason logic is identical to approve/reject (`viewer_can_approve` gate). i18n: `approvals.action.suggest_changes` → DE "Änderungen vorschlagen" / EN "Suggest changes".
|
||||
|
||||
`frontend/src/client/inbox.ts` — clicking the suggest-changes button opens a **modal**, not a `window.prompt` (the existing reject prompt is OK because reject only needs a note; suggest-changes needs an editable form). The modal:
|
||||
- Renders the same fields the entity edit form would show, pre-populated from `detail.payload` (the requester's proposed values).
|
||||
- Adds a free-text "Vorschlagskommentar" textarea at the bottom (the note).
|
||||
- On submit: POST `/api/approval-requests/{id}/suggest-changes` with `{counter_payload: {...editedFields}, note}`.
|
||||
- On success: refresh the bar — the old row flips to `changes_requested`, the new row appears as `pending`.
|
||||
|
||||
Where the modal's field-editor lives: a new `client/components/approval-edit-modal.ts` that takes `entity_type` + `payload` + `pre_image` and returns the edited payload. For v1 it can be a thin wrapper over the existing entity-edit form components (Frist date picker, Termin start/end pickers). Don't build a generic field-editor framework — just deadlines + appointments, hard-coded fields per entity_type.
|
||||
|
||||
**Status pill for `changes_requested`** — i18n keys + colour:
|
||||
- `approvals.status.changes_requested` → DE "Abgelehnt mit Vorschlag" / EN "Declined with changes"
|
||||
- Reuse the existing `approval-pill--historic` style; no new colour token needed for v1.
|
||||
|
||||
**The "Edit and resubmit" CTA on the requester's row is NOT needed** (m's Q6 reframing) — the requester just sees the new pending row in /inbox, same as any other.
|
||||
|
||||
### 3.5 Inbox filter
|
||||
|
||||
The /inbox `approval_status` filter chip cluster gains `changes_requested`. The `self_requested` viewer-role default already includes terminal statuses, so the original requester sees their `changes_requested` row without changing the default filter.
|
||||
|
||||
### 3.6 Linkage from old row to new row in /inbox
|
||||
|
||||
When showing a `changes_requested` row in /inbox, add a small "→ Neuer Vorschlag von {approver}" link below the note that scrolls / filters to the new pending row (it'll be visible to anyone eligible, including the original requester). The new row has `previous_request_id` pointing at the old one — so the API response for the old row can hydrate `next_request_id` (computed: `SELECT id FROM approval_requests WHERE previous_request_id = $1 LIMIT 1`).
|
||||
|
||||
### 3.7 Email notification (Phase 2 — defer until v1 ships)
|
||||
|
||||
The new row triggers the existing `*_approval_requested` notification path (whatever that is for Submit*) — same audience, same template. No new code. The old row's transition to `changes_requested` doesn't need its own mail; the new-row mail already tells the audience "X suggested changes to your earlier submission" through the body.
|
||||
|
||||
Out of scope for v1: a bespoke "your submission was declined with a counter-proposal" email aimed at the original requester. The new-row mail covers it functionally.
|
||||
|
||||
---
|
||||
|
||||
## 4. Slice plan
|
||||
|
||||
Three reviewable slices, each one PR. Combined scope is small/medium.
|
||||
|
||||
1. **Slice A — backend.** Migration 103 (CHECK extension + `counter_payload jsonb` + `previous_request_id` FK + index) + `SuggestChanges` service method + HTTP handler + service tests (happy path, no-op-suggestion guard, deadlock on new row, self-approval block, request_not_pending). Migration is non-blocking on Postgres; safe for live deploy.
|
||||
2. **Slice B — frontend.** 4th button on /inbox + the edit modal (deadline-fields variant + appointment-fields variant) + status pill `changes_requested` ("Abgelehnt mit Vorschlag") + i18n keys (DE + EN) + the "→ Neuer Vorschlag" link from old row to new row. End-to-end browser smoke test via Playwright.
|
||||
3. **Slice C — Verlauf integration.** Make sure the `*_approval_changes_suggested` event renders on the project / deadline / appointment Verlauf timeline alongside the existing 4 approval event types. May or may not need code change depending on how generic the Verlauf row renderer is — likely just an i18n key + an icon mapping.
|
||||
|
||||
Don't ship a chain-traversal UI in v1. The `previous_request_id` FK is captured so the data is there; surfacing the full chain history (n hops back) is a Phase-2 polish.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risks / open considerations
|
||||
|
||||
- **Chain depth runaway.** Nothing stops an "I keep suggesting / they keep counter-suggesting" loop. Same risk as comment threads on GitHub PRs. Out of scope to cap; the social pressure (each round is a 4-Augen action with a name attached) is the natural brake.
|
||||
- **Concurrent suggestions on the same pending row.** Two approvers click "suggest changes" at the same time? The existing `getRequestForUpdate` row-lock serialises them; the second caller gets `ErrRequestNotPending` (the first already flipped it). Same guarantee as Approve/Reject today.
|
||||
- **Deadlock on the new row.** If the suggesting approver is the only qualified approver other than the original requester, the new row's deadlock check returns "no qualified approver" — because the original requester IS now eligible (they're no longer the requested_by), but might not have a high-enough role. The check needs to recognise: caller's pool = "anyone other than the new requester who can canApprove". Original requester counts if they hit the required-role bar. This is just the existing deadlock predicate run against the new (requester, role) tuple; no special-case logic. Surfaced as `409 "no_qualified_approver"` to the suggesting approver, with the standard global_admin override path still available.
|
||||
- **Counter-payload schema validation.** Server must validate `counter_payload` against the same schema as a normal `Submit*` for that entity_type + lifecycle_event. Otherwise a malicious approver could write garbage values via the suggestion path that wouldn't fly through `Submit*`. Reuse the existing payload-schema validator from the entity services; don't write a parallel.
|
||||
- **No-op suggestion guard.** Approver clicks suggest-changes but doesn't actually edit anything AND leaves the note empty? Server rejects with `ErrSuggestionRequiresChange`. UI guards too (the submit button stays disabled until either the form is dirty OR the note has text).
|
||||
- **Migration safety.** Non-blocking. Adding a value to a CHECK constraint is a metadata-only change; adding a NULLable column + a NULLable FK is also metadata-only.
|
||||
- **What about a structured per-field suggestion (Q4c)?** The `counter_payload` jsonb IS structured — each entity_type has fixed fields. There's no need for a separate "{field, current, suggested}" shape because the diff is computable from `pre_image → counter_payload` on the new row.
|
||||
- **What about thread-of-notes (Q4b)?** Implicit in the chain — each row's `decision_note` is one "note" by one author; following `previous_request_id` backwards reconstructs the full back-and-forth. A future "thread view" UI is layered on top of this without schema change.
|
||||
|
||||
---
|
||||
|
||||
## 6. m's decisions
|
||||
|
||||
See §0a (decisions table) — filled in after the AskUserQuestion phase on 2026-05-19.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope for this design
|
||||
|
||||
- Email + push notifications (Phase 2; see §3.7).
|
||||
- Structured per-field suggestion shape (Phase 2 enhancement).
|
||||
- Approval-policy `watchers` column for notification fan-out.
|
||||
- "Dismiss this row from my inbox" UI toggle (UX-only, not a data-model change).
|
||||
- Cross-entity suggest-changes (e.g. project, party). Same as the original approval scope — deadlines + appointments only.
|
||||
|
||||
597
docs/design-caldav-multi-calendar-2026-05-19.md
Normal file
597
docs/design-caldav-multi-calendar-2026-05-19.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# CalDAV multi-calendar sync — design
|
||||
|
||||
**Task:** t-paliad-212
|
||||
**Inventor:** leibniz (2026-05-19)
|
||||
**Branch:** mai/leibniz/inventor-caldav-multi
|
||||
**Status:** READY FOR REVIEW — m's decisions on the §8 open questions captured in the addendum below (2026-05-19).
|
||||
|
||||
---
|
||||
|
||||
## §0 — One-paragraph summary
|
||||
|
||||
Paliad's CalDAV sync today is a single-target push: every user has one
|
||||
`paliad.user_caldav_config` row, and every Appointment they can see gets
|
||||
PUT into that one calendar. m wants users to pick their own organization —
|
||||
one cal with everything, one cal per project (or per client / litigation /
|
||||
patent / case), or any hybrid. This design splits the model in two:
|
||||
**credentials stay per user** (one CalDAV server, one auth blob) and
|
||||
**bindings become first-class rows** (a join table `paliad.user_calendar_bindings`
|
||||
that points an Appointment-filter scope at a specific `calendar_path`).
|
||||
Push/pull state migrates from scalar `appointments.caldav_uid`/`caldav_etag`
|
||||
columns to a per-(appointment, binding) join table
|
||||
`paliad.appointment_caldav_targets`, so the same Appointment can live in
|
||||
N external calendars at once. The 60-second per-user sync goroutine survives
|
||||
unchanged in shape; inside it the inner loop iterates bindings instead of
|
||||
hard-coding `cfg.CalendarPath`. Sliced for safe rollout: Slice 1 introduces
|
||||
the new tables behind a backfill that auto-creates one binding per
|
||||
existing config row (zero behaviour change); Slice 2 ships the
|
||||
binding-picker UI; Slice 3 wires scope-aware filtering (one cal per project).
|
||||
Bidirectional sync stays exactly as it works today (last-write-wins on ETag,
|
||||
Paliad-owned UIDs only) — multi-calendar does not change the conflict
|
||||
model.
|
||||
|
||||
---
|
||||
|
||||
## §1 — What's already built (verified live, 2026-05-19)
|
||||
|
||||
Verified against the codebase, not the project's CLAUDE.md.
|
||||
|
||||
- **Schema** — `paliad.user_caldav_config` is one row per user with
|
||||
`(user_id PK, url, username, password_encrypted bytea, calendar_path,
|
||||
enabled, last_sync_at, last_sync_error, created_at, updated_at)`. The
|
||||
scalar `calendar_path` is the only handle on which external calendar
|
||||
receives events. Per direct `information_schema` query.
|
||||
- **Appointment binding** — `paliad.appointments` carries scalar
|
||||
`caldav_uid text` and `caldav_etag text` (nullable). Set once after a
|
||||
successful PUT via `AppointmentService.SetCalDAVMeta`. This is the
|
||||
single-target assumption baked into the row itself.
|
||||
- **Sync engine** — `internal/services/caldav_service.go:298–502`. One
|
||||
goroutine per enabled user, 60s ticker, `runSyncOnce` → `syncOnce` →
|
||||
`pushAll` (`AppointmentService.AllForUser` × `cli.PutEvent`) +
|
||||
`pullAll` (`cli.PropfindCalendar` → `cli.GetEvent` → reconcile by UID).
|
||||
`AllForUser` returns *every* personal-or-visible-project appointment
|
||||
for the user; today they all funnel into the single `calendar_path`.
|
||||
- **UID convention** — `paliad-appointment-<uuid>@paliad.de`
|
||||
(`caldav_ical.go:31–34`). Foreign UIDs are intentionally skipped on
|
||||
pull (`caldav_service.go:436–442`).
|
||||
- **Hooks** — `OnAppointmentCreated/Updated/Deleted` push directly to
|
||||
the configured `cfg.CalendarPath` on a 30s-timeout background goroutine
|
||||
so user requests don't block (`caldav_service.go:510–558`).
|
||||
- **Approval flow (t-138)** — project-attached appointments may be
|
||||
`approval_status = 'pending'`. CalDAV push already runs after approval
|
||||
in `AppointmentService.Update` paths; `ApplyRemoteUpdate` from a remote
|
||||
edit currently bypasses the approval gate. That's a pre-existing hole
|
||||
flagged here only because multi-calendar makes "which calendar's edit
|
||||
wins" more visible — fix belongs in t-138 follow-ups, not in this
|
||||
design.
|
||||
- **CalDAV verbs supported** — PUT / DELETE / GET / PROPFIND (depth 0
|
||||
and 1). No MKCALENDAR, no REPORT, no calendar-multiget. Tested
|
||||
against Nextcloud, Radicale, Baikal, mailcow SOGo per
|
||||
`caldav_client.go:22–24`.
|
||||
|
||||
**What is _not_ baked in and is therefore free to extend:**
|
||||
|
||||
- The 60s ticker is per-*user*, not per-*calendar*. Adding bindings does
|
||||
not multiply tickers.
|
||||
- `cfg.CalendarPath` is referenced in exactly two places (`pushAll`,
|
||||
`pullAll`) plus the three hooks. Replacing it with a binding loop is
|
||||
a contained edit.
|
||||
- Credentials are server-scoped, not calendar-scoped — every binding
|
||||
for the same user shares the existing decrypted credential, so the
|
||||
encryption layer (`caldav_crypto.go`) is untouched.
|
||||
|
||||
---
|
||||
|
||||
## §2 — Per-provider calendar-count limits (verified 2026-05-19)
|
||||
|
||||
Real numbers, from current docs, so the design knows its envelope.
|
||||
|
||||
| Provider | Per-account / per-user limit | Source |
|
||||
|---|---|---|
|
||||
| **iCloud** | **100** calendars + reminder-lists combined | [Apple Support 103188](https://support.apple.com/en-us/103188) |
|
||||
| **Google Calendar** | **~100 owned** (soft recommendation, post-Nov-2025 ownership model) | [Workspace Updates 2026-01](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html), [usecarly.com summary](https://www.usecarly.com/blog/how-many-calendars-google-account/) |
|
||||
| **Fastmail** | **No documented cap on calendars.** 100 000 events/user. | [Fastmail account-limits page](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) |
|
||||
| **Nextcloud** | **30 per user** default; admin-configurable, `-1` = unlimited. Rate limit: 10 calendar-creations/hour. | [Nextcloud admin manual — Calendar](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) |
|
||||
| **Radicale / Baikal / mailcow SOGo** | No published per-account cap (file-system / DB bound). | server defaults |
|
||||
|
||||
**Implications for the design:**
|
||||
|
||||
- "One calendar per project" is comfortably within all providers'
|
||||
envelopes for typical HLC caseloads. A senior PA who tracks 40
|
||||
litigations would land 40+ calendars, still inside iCloud's 100 and
|
||||
Nextcloud's default 30 (would need an admin bump on Nextcloud — flag
|
||||
in onboarding).
|
||||
- "One calendar per case" can blow past Nextcloud's default 30 fast and
|
||||
is a real risk on iCloud at the 60+ mark when combined with the
|
||||
user's existing personal calendars + reminder lists. We should
|
||||
**soft-cap** scope choices at the UI layer (warn at 20 bindings, hard
|
||||
block at 80) rather than discover the limit by 5xx on PUT.
|
||||
- Google Calendar's CalDAV endpoint does **not** support `MKCALENDAR`
|
||||
reliably — calendars must be pre-created in the Google UI. iCloud,
|
||||
Fastmail, Nextcloud, Radicale, Baikal, SOGo all accept `MKCALENDAR`.
|
||||
So the "auto-create a calendar per project" affordance is provider-
|
||||
dependent and must degrade gracefully ("we couldn't create it for
|
||||
you — please make `Project X` in your calendar app and paste its
|
||||
URL").
|
||||
|
||||
---
|
||||
|
||||
## §3 — Proposed data model
|
||||
|
||||
Three schema changes, no destructive migrations. The scalar
|
||||
`appointments.caldav_uid` / `caldav_etag` columns survive as a
|
||||
denormalised "default-binding" pointer through Slice 1 and 2; Slice 4
|
||||
drops them after telemetry confirms no path still reads them.
|
||||
|
||||
### §3.1 New table: `paliad.user_calendar_bindings`
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.user_calendar_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
calendar_path text NOT NULL, -- absolute URL or path under user_caldav_config.url
|
||||
display_name text NOT NULL DEFAULT '', -- the label discovered via PROPFIND <displayname/>; what we show in the UI
|
||||
|
||||
scope_kind text NOT NULL, -- 'all_visible' | 'personal_only' | 'project' | 'client' | 'litigation' | 'patent' | 'case'
|
||||
scope_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE, -- NULL for 'all_visible' / 'personal_only'
|
||||
include_personal boolean NOT NULL DEFAULT false, -- only meaningful when scope_kind <> 'all_visible'/'personal_only'
|
||||
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
last_sync_at timestamptz,
|
||||
last_sync_error text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (user_id, calendar_path), -- can't bind one calendar twice for the same user
|
||||
UNIQUE (user_id, scope_kind, scope_id), -- one binding per scope per user — but a project can also be covered by 'all_visible'
|
||||
CHECK ((scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
|
||||
OR (scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX user_calendar_bindings_user_idx ON paliad.user_calendar_bindings(user_id) WHERE enabled;
|
||||
-- RLS: row visible/writable only when auth.uid() = user_id (mirrors user_caldav_config).
|
||||
```
|
||||
|
||||
**Why per-scope unique but not per-appointment unique:** an Appointment in
|
||||
project P is allowed to land in both the user's `all_visible` calendar
|
||||
AND their `project=P` calendar — that's the explicit "master + per-project"
|
||||
hybrid m asked about. What we forbid is two different `project=P` bindings
|
||||
for the same user, which would have no useful semantics.
|
||||
|
||||
**`scope_kind = 'personal_only'`** is a separate scope from `'all_visible'`
|
||||
because the existing pushAll already covers both personal and visible-project
|
||||
appointments; users may want a "personal only" calendar that does *not*
|
||||
get the noisy team events. Without this, every binding either includes
|
||||
personal events or doesn't, and there's no way to say "the master
|
||||
calendar = everything except personal".
|
||||
|
||||
### §3.2 New table: `paliad.appointment_caldav_targets`
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.appointment_caldav_targets (
|
||||
appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
|
||||
binding_id uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,
|
||||
caldav_uid text NOT NULL, -- still 'paliad-appointment-<uuid>@paliad.de' — same for all bindings of one appointment
|
||||
caldav_etag text NOT NULL,
|
||||
last_pushed_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (appointment_id, binding_id)
|
||||
);
|
||||
CREATE INDEX appointment_caldav_targets_binding_idx ON paliad.appointment_caldav_targets(binding_id);
|
||||
-- RLS: visible/writable when the underlying binding's user_id = auth.uid().
|
||||
```
|
||||
|
||||
**UID stays per-appointment, not per-binding.** That keeps the iCal UID
|
||||
canonical (still `paliad-appointment-<uuid>@paliad.de`), so when a user
|
||||
removes a binding and re-adds it later, the same UID rebinds without
|
||||
spurious duplicates. The `.ics` filename in the calendar — `<uid>.ics`
|
||||
— is also identical across bindings, which means the same UUID
|
||||
shows up in different calendars on the same server but never collides
|
||||
because they're under different `calendar_path` collections.
|
||||
|
||||
### §3.3 Row examples for the four common organisations
|
||||
|
||||
| Organisation | Rows in `user_calendar_bindings` |
|
||||
|---|---|
|
||||
| **A — one cal, everything** | 1 row: `scope_kind='all_visible'`, `calendar_path='/cal/work'` |
|
||||
| **B — one cal per project** | N rows, all `scope_kind='project'`, distinct `(scope_id, calendar_path)` |
|
||||
| **C — master + per-project hybrid** | 1 row `scope_kind='all_visible'` + N rows `scope_kind='project'`. Each project event appears in both. |
|
||||
| **D — personal split from work** | 1 row `scope_kind='personal_only'` → `/cal/personal` + 1 row `scope_kind='all_visible'` (which will include the same personal events, so the user will more commonly pair `personal_only` with a `scope_kind='client'` per-client work view instead). |
|
||||
|
||||
### §3.4 What stays unchanged
|
||||
|
||||
- `paliad.user_caldav_config` — still holds the server URL, username,
|
||||
encrypted password, and a per-user `enabled` flag. The existing
|
||||
`calendar_path` column becomes a hint for the **default binding** we
|
||||
auto-create on migration and is no longer read by sync logic after
|
||||
Slice 1 ships. We keep it nullable-on-read for forwards-compat then
|
||||
drop in Slice 4.
|
||||
- `paliad.caldav_sync_log` — still per-user; sync entries gain a
|
||||
`binding_id` column (nullable for legacy rows) so the UI can show
|
||||
per-calendar last-sync state.
|
||||
- iCal serialisation (`caldav_ical.go`) — unchanged. Same VEVENT
|
||||
formatter feeds every binding.
|
||||
- AES-GCM credential encryption (`caldav_crypto.go`) — unchanged.
|
||||
|
||||
---
|
||||
|
||||
## §4 — Sync engine implications
|
||||
|
||||
The shape of the per-user goroutine stays. The body of `syncOnce`
|
||||
moves from "push to one path / pull from one path" to "for each
|
||||
enabled binding, push the scope-filtered slice / pull from that path".
|
||||
|
||||
### §4.1 Push fan-out
|
||||
|
||||
```go
|
||||
// pseudocode for the new pushAll body
|
||||
bindings := s.bindings.ListEnabled(ctx, userID) // 1..N rows
|
||||
for _, b := range bindings {
|
||||
appts := s.appointments.ForBinding(ctx, userID, b) // scope-filtered
|
||||
for _, a := range appts {
|
||||
body := formatAppointment(&a)
|
||||
etag, err := cli.PutEvent(ctx, b.CalendarPath, terminUID(a.ID), body)
|
||||
if err != nil { continue } // best-effort, per-binding error
|
||||
s.targets.Upsert(ctx, a.ID, b.ID, terminUID(a.ID), etag)
|
||||
}
|
||||
// Remove events from this calendar that no longer belong to the scope.
|
||||
for _, stale := range s.targets.DanglingForBinding(ctx, b.ID, currentIDs(appts)) {
|
||||
cli.DeleteEvent(ctx, b.CalendarPath, stale.CalDAVUID)
|
||||
s.targets.Delete(ctx, stale.AppointmentID, b.ID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ForBinding(userID, b)` is the scope filter:
|
||||
|
||||
- `all_visible` → existing `AllForUser(userID)`
|
||||
- `personal_only` → appointments with `project_id IS NULL AND created_by = userID`
|
||||
- `project` → appointments where `project_id = scope_id` AND visible to user
|
||||
- `client` / `litigation` / `patent` / `case` → appointments where the
|
||||
ancestor at the relevant hierarchy level = `scope_id` AND visible to user
|
||||
- when `include_personal = true`, union with personal events on top of the above (only for non-`all_visible`/`personal_only` scopes)
|
||||
|
||||
This reuses the existing `can_see_project()` predicate (per project
|
||||
CLAUDE.md, team-based RLS), so visibility shrinkage on a project unshare
|
||||
falls out naturally: next push sees the appointment is no longer in
|
||||
`ForBinding(...)`, sees a dangling target row, issues `DeleteEvent`.
|
||||
|
||||
### §4.2 Pull reconciliation
|
||||
|
||||
Each binding has its own pull pass against `b.CalendarPath`. The
|
||||
matching key is still `caldav_uid` — same UID across all bindings, so
|
||||
`appointments.FindByCalDAVUID(uid)` resolves the local row. The
|
||||
**ETag check is per-target row** now, not per-appointment: a remote
|
||||
edit in calendar X bumps the etag in `appointment_caldav_targets` for
|
||||
binding X only. The local Appointment is updated once (last-write-wins
|
||||
on Appointment.updated_at), the next push tick re-syncs the other
|
||||
bindings with the new payload (they see their stored etag is older
|
||||
than the appointment's `updated_at` and re-PUT).
|
||||
|
||||
**One subtle change:** the foreign-UID skip (`extractAppointmentID == ""`)
|
||||
still applies per-binding pull. That preserves the v1 "Paliad owns its
|
||||
UIDs" property — multi-calendar does not open the door to importing
|
||||
events the user creates in their calendar app. (If/when that becomes
|
||||
in-scope, it's a separate t-paliad-* design.)
|
||||
|
||||
### §4.3 Hooks (instant push)
|
||||
|
||||
`OnAppointmentCreated/Updated/Deleted` fan out across all the user's
|
||||
enabled bindings that match the appointment's scope. Same 30s-timeout
|
||||
background goroutine. The user-facing request still returns
|
||||
immediately; the failure mode is identical (best-effort per binding,
|
||||
logged on `slog.Warn`).
|
||||
|
||||
### §4.4 Bandwidth & rate limits
|
||||
|
||||
- Per user per tick: **N bindings × 1 PROPFIND + per-event GETs**.
|
||||
The pull GET is the dominant cost; a 50-binding user with 20 events
|
||||
per calendar is ~1 000 GETs/min, which is fine over HTTP/1.1 to a
|
||||
decent CalDAV server but **does** put us inside iCloud's
|
||||
~throttle-friendly band and risks Google's quota model.
|
||||
- Mitigation: switch pull to **`REPORT` `calendar-multiget`** so each
|
||||
binding's events come back in one round-trip. That's a single
|
||||
iteration on `caldav_client.go` (the same multistatus parser
|
||||
already handles the body) and pays for itself the moment a user
|
||||
has >10 events per binding. We deliberately deferred this in
|
||||
Phase F (one calendar, low volume) — multi-calendar makes it
|
||||
table-stakes. Plan to land it in **Slice 2** alongside the picker.
|
||||
- Rate limiting on the Paliad side: keep the 60s ticker, but stagger
|
||||
per-binding pulls so we never fire N concurrent PROPFINDs against
|
||||
the same provider. Sequential per binding is fine; we already do
|
||||
this implicitly with the per-user goroutine.
|
||||
|
||||
### §4.5 Server-side cleanup on binding delete
|
||||
|
||||
User deletes a binding → service:
|
||||
|
||||
1. Lists every (appointment, binding) target row for that binding.
|
||||
2. Issues `DELETE` per `.ics` on the remote calendar (best effort).
|
||||
3. Deletes the target rows.
|
||||
4. Deletes the binding row (or relies on `ON DELETE CASCADE` from
|
||||
target FK — cleaner to delete remotely first, then drop the row,
|
||||
so a half-failed cleanup leaves rows we can retry on next tick).
|
||||
|
||||
A "leave events behind in the external calendar" toggle is a real
|
||||
ask (users sometimes archive bindings without wanting their calendar
|
||||
app to suddenly empty). Plumb it as `binding.cleanup_on_delete bool`
|
||||
in Slice 2 if there's demand; default `true` (delete).
|
||||
|
||||
---
|
||||
|
||||
## §5 — Bidirectional vs one-way
|
||||
|
||||
**Recommendation: stay bidirectional, identical to today's semantics,
|
||||
per-binding.** Reasons:
|
||||
|
||||
1. **m's stated workflow expects round-trip.** Drag a deadline in
|
||||
Outlook → Paliad sees the new date → approval flow triggers
|
||||
(t-138). One-way push breaks that. Multi-calendar doesn't change
|
||||
this expectation; if anything, it strengthens it (the user picked
|
||||
the project-cal binding *because* they intend to edit there).
|
||||
2. **The conflict model is already in place.** Last-write-wins on
|
||||
ETag, foreign-UID skip, `LogConflict` audit append. Multi-calendar
|
||||
adds one new question: "if the user edits the same event in two
|
||||
different bindings between ticks, which wins?" Answer: the one
|
||||
that lands first in our pull pass. Bindings are iterated in
|
||||
`created_at` order so the behaviour is deterministic, and the
|
||||
second edit gets overwritten on the next tick when we re-push the
|
||||
resolved appointment to it. Acceptable trade-off; would only show
|
||||
up if a user actually edits the same event in two of their own
|
||||
calendars within 60s, which is vanishingly rare.
|
||||
3. **Approval-flow integration is unchanged.** Pending-approval
|
||||
events have the `[PENDING APPROVAL]` marker baked into the iCal
|
||||
summary by `caldav_ical.go:76+`. That marker survives multi-binding
|
||||
fan-out untouched; an external edit on a pending event still has
|
||||
the pre-existing bypass-the-gate hole (flagged §1, not in scope).
|
||||
|
||||
**Tee-up for m's call:** if multi-calendar is the wrong moment to
|
||||
keep bidirectional (e.g. because per-project calendars are about
|
||||
**read-only visibility for partners**, not editing), we'd add a
|
||||
`binding.read_only bool` column and skip the pull pass for that
|
||||
binding. Cheap to add now or later. **I recommend defaulting
|
||||
`read_only = false` (bidirectional like today) and only making it
|
||||
optional if m's first session with the UI surfaces the need.**
|
||||
|
||||
---
|
||||
|
||||
## §6 — User-facing config model
|
||||
|
||||
Surface on `/einstellungen/caldav` (already exists for Phase F creds).
|
||||
Two sections, in this order:
|
||||
|
||||
1. **Server** (existing) — URL, username, password, "test connection".
|
||||
Unchanged.
|
||||
2. **Calendars** (new) — list of bindings as cards / rows. For each:
|
||||
`display_name`, `calendar_path`, `scope_kind` chip (master /
|
||||
personal / project / …), `enabled` toggle, last-sync status, action
|
||||
buttons "Edit scope" / "Remove".
|
||||
3. **Add a calendar** — flow:
|
||||
- **a)** click "Add". Modal opens. We do a `PROPFIND
|
||||
<calendar-home-set>` against the user's server to discover their
|
||||
existing calendars; show as a picker. (RFC 6638 / 4791 calendar
|
||||
home set discovery — supported by iCloud, Fastmail, Nextcloud,
|
||||
Radicale, Baikal, SOGo. Google CalDAV does not expose this
|
||||
reliably; for Google users we degrade to a manual path entry box.)
|
||||
- **b)** user picks an existing calendar, or chooses "Create new
|
||||
calendar". Create-new attempts `MKCALENDAR` (works on iCloud,
|
||||
Fastmail, Nextcloud, Radicale, Baikal, SOGo; fails on Google →
|
||||
friendly error with copy-paste instruction).
|
||||
- **c)** user picks the **scope**: a radio between "Everything I can
|
||||
see", "Personal only", "One project", and (later) "One client /
|
||||
litigation / patent / case". Project picker uses the existing
|
||||
`/api/projects?…` autocomplete.
|
||||
- **d)** "Save" → POST `/api/caldav-bindings`. The next 60s tick
|
||||
starts pushing into the new calendar; the UI shows "Initial
|
||||
sync running…" with a live last-sync indicator (already polled
|
||||
by the existing `caldav-config` page).
|
||||
|
||||
4. **Quick-add affordances** (Slice 3 polish, not v1):
|
||||
- On a project's `/projects/<id>` page: "Open in calendar app" link
|
||||
if a binding already exists for that project, "Pin to a new
|
||||
calendar" if none does (deep-links to the Add-a-calendar modal
|
||||
pre-filled).
|
||||
- Bulk action "Create one calendar per active litigation" on
|
||||
`/einstellungen/caldav` (requires `MKCALENDAR` support; gated
|
||||
behind a server-capability probe at first PROPFIND).
|
||||
|
||||
5. **Soft limits in the UI:**
|
||||
- At **20 bindings**: yellow info banner "Most users keep ≤ 20
|
||||
calendars; review your list before adding more."
|
||||
- At **80 bindings**: red error, block adding new (we don't know
|
||||
the user's provider for sure; 80 is a safe ceiling for iCloud
|
||||
and Nextcloud-default).
|
||||
- Provider hint surfaced under the Server form: parsed from the
|
||||
URL host, with a "your provider's documented limit" line —
|
||||
pure courtesy, not enforced.
|
||||
|
||||
### §6.1 What the API contract looks like
|
||||
|
||||
| Verb + Path | Body / Returns | Notes |
|
||||
|---|---|---|
|
||||
| `GET /api/caldav-bindings` | array of binding rows + sync status | replaces having to interpret `user_caldav_config.calendar_path` |
|
||||
| `POST /api/caldav-bindings` | `{calendar_path, display_name, scope_kind, scope_id?, include_personal?}` → created binding | triggers immediate sync goroutine wake-up |
|
||||
| `PATCH /api/caldav-bindings/{id}` | partial; toggle `enabled` or change `scope_*` | re-runs `pushAll` for this binding |
|
||||
| `DELETE /api/caldav-bindings/{id}` | — | deletes external events first, then row |
|
||||
| `GET /api/caldav-discover` | array of `{href, displayname}` from server `<calendar-home-set>` | populates the picker; cached 5 min |
|
||||
| `POST /api/caldav-mkcalendar` | `{display_name, color?}` → `{calendar_path}` | issues `MKCALENDAR`; returns 501 on Google |
|
||||
|
||||
`GET /api/caldav-config` still works (back-compat for the server-creds
|
||||
section); its `calendar_path` field is documented as "deprecated, see
|
||||
/api/caldav-bindings".
|
||||
|
||||
---
|
||||
|
||||
## §7 — Slice plan
|
||||
|
||||
Tracer-bullet slices so each is independently shippable, safe to
|
||||
revert, and gives the user something they can see.
|
||||
|
||||
**Slice 1 — Schema + backfill (no UI change).**
|
||||
- Migration: create `user_calendar_bindings`, `appointment_caldav_targets`.
|
||||
- Backfill: for every existing `user_caldav_config` row, insert one
|
||||
`bindings` row `(user_id, calendar_path, display_name='', scope_kind='all_visible', enabled)`.
|
||||
For every Appointment with non-null `caldav_uid`, insert one
|
||||
`appointment_caldav_targets` row pointing at the user's new default
|
||||
binding.
|
||||
- Refactor `CalDAVService.syncOnce` / `pushAll` / `pullAll` to drive
|
||||
off bindings (loop of length 1 per existing user). Behaviour
|
||||
observably identical: same calendars, same events, same logs.
|
||||
- `appointments.caldav_uid` / `caldav_etag` columns still exist and
|
||||
are written for compatibility (treat them as denormalised pointers
|
||||
to the default binding's target row). UI unchanged.
|
||||
- **Exit criterion:** existing users see no change in their calendar;
|
||||
`caldav_sync_log.binding_id` is populated for all new rows; manually
|
||||
inserted second binding via SQL syncs correctly end-to-end on a
|
||||
staging account.
|
||||
|
||||
**Slice 2 — Binding-picker UI + multi-binding support.**
|
||||
- `/api/caldav-bindings` CRUD + `/api/caldav-discover` (PROPFIND
|
||||
`calendar-home-set`) + `/api/caldav-mkcalendar`.
|
||||
- New "Calendars" section on `/einstellungen/caldav` with the modal
|
||||
flow from §6.
|
||||
- **Land `REPORT calendar-multiget` pull** alongside (per §4.4).
|
||||
Required, not optional, for the bandwidth profile multi-binding
|
||||
introduces.
|
||||
- Scope kinds enabled in v1: `all_visible`, `personal_only`, `project`.
|
||||
Hierarchy scopes (`client`, `litigation`, `patent`, `case`) parked
|
||||
for Slice 3.
|
||||
- **Exit criterion:** m can pin a second calendar via the UI on
|
||||
staging; events for project X appear only in the X-bound calendar
|
||||
if his master binding is disabled, and in both if it's enabled.
|
||||
|
||||
**Slice 3 — Hierarchy scopes + project-page quick-adds.**
|
||||
- Enable `scope_kind ∈ {client, litigation, patent, case}` — pure
|
||||
filter-predicate change in `ForBinding(...)` using the existing
|
||||
project-tree walker.
|
||||
- "Pin to a new calendar" button on `/projects/<id>` and on the
|
||||
/einstellungen page.
|
||||
- Bulk "calendar-per-active-litigation" provisioner (with
|
||||
`MKCALENDAR` capability probe).
|
||||
- **Exit criterion:** real HLC PA can set up "one cal per
|
||||
litigation" in <5 min on first try without inventor help.
|
||||
|
||||
**Slice 4 — Polish + cleanup.**
|
||||
- Drop `appointments.caldav_uid` / `caldav_etag` after instrumentation
|
||||
shows zero readers outside `CalDAVService` (`grep` + a one-week
|
||||
query-log audit on the read replica).
|
||||
- Soft-limit banners (20 / 80).
|
||||
- `binding.read_only` and `binding.cleanup_on_delete` toggles if
|
||||
asked for by then.
|
||||
- **Exit criterion:** schema is final; no legacy paths remain in
|
||||
`caldav_service.go`.
|
||||
|
||||
**(Out of scope across all four slices:** foreign-UID import, custom
|
||||
event types per binding, per-binding colour mapping, MKCALENDAR for
|
||||
Google. These are easy to add later if the data says so.)
|
||||
|
||||
---
|
||||
|
||||
## §8 — Open questions for m
|
||||
|
||||
1. **Bidirectional default for new bindings: yes/no?** I recommend
|
||||
**yes** (matches today's single-cal behaviour and the round-trip
|
||||
workflow expectation). A `read_only` per-binding flag is cheap to
|
||||
add later if a real use case shows up. Decide now → Slice 1; decide
|
||||
later → Slice 4.
|
||||
2. **`personal_only` scope — keep or drop?** It's useful for users
|
||||
who want a "noisy team master + clean personal" split, but it's
|
||||
redundant for users who only use the master calendar. I'd keep
|
||||
it; trivial to remove if m disagrees.
|
||||
3. **`MKCALENDAR` (auto-create calendar) — ship in Slice 2 or defer
|
||||
to Slice 3?** Shipping it in Slice 2 means we need the
|
||||
capability-probe + Google-degrade UX up-front. Deferring means
|
||||
Slice 2 users have to pre-create the calendar in their app and
|
||||
paste the URL — workable but clunky. Default plan: **Slice 2,
|
||||
with a clean Google-degrade message**.
|
||||
4. **Soft cap numbers (20 / 80) — sensible?** Picked from §2
|
||||
provider limits + "most paliad users will pick 1–5". m may
|
||||
want different numbers — easy to tune.
|
||||
5. **`/admin/caldav-bindings` view for support debugging?** Not in
|
||||
the slice plan; useful if a user calls confused about which
|
||||
calendar holds which event. Add if m wants it.
|
||||
6. **Approval-flow + remote-edit gap (§1, the bypass) — fix scope?**
|
||||
Pre-existing in single-cal Phase F. Multi-cal makes it more
|
||||
visible. Should this be a follow-up under t-138, or folded into
|
||||
Slice 3? I'd file as a separate task.
|
||||
|
||||
---
|
||||
|
||||
## §9 — Why this is the right shape
|
||||
|
||||
- **Single CalDAV server per user, N bindings.** Matches every real
|
||||
provider's auth model (one auth blob covers all the user's
|
||||
calendars) and keeps `caldav_crypto.go` and `user_caldav_config`
|
||||
untouched.
|
||||
- **Binding scope is a row, not a static config.** Users compose
|
||||
the organisation they want without us guessing; defaults (one
|
||||
master binding on migration) preserve current behaviour.
|
||||
- **UID stays per-appointment.** Means an event re-binding (move
|
||||
from project-cal to master-cal) is just shuffling target rows,
|
||||
not minting new UIDs. Re-importing into the same calendar later
|
||||
rebinds cleanly.
|
||||
- **Sync engine shape is unchanged.** Same per-user goroutine, same
|
||||
60s tick, same hooks. The blast radius of multi-binding is one
|
||||
inner loop, gated behind a feature that backfills to a no-op for
|
||||
every existing user.
|
||||
- **Slices give m a vertical demo at each step.** Slice 1 is
|
||||
invisible-but-shippable; Slice 2 is the first user-facing change
|
||||
("you can pin a second calendar"); Slice 3 is "now organise by
|
||||
project tree"; Slice 4 is cleanup.
|
||||
- **No new external dependencies.** Same hand-rolled CalDAV client.
|
||||
Adds one new verb (`MKCALENDAR`) and one new report
|
||||
(`calendar-multiget`) — both small, both already half-tested
|
||||
against `caldav_client.go`'s patterns.
|
||||
|
||||
---
|
||||
|
||||
## §10 — Sources
|
||||
|
||||
- [Apple Support — Limits for iCloud Contacts, Calendars, Reminders, Bookmarks, and Maps](https://support.apple.com/en-us/103188) — iCloud 100 combined calendars + reminder lists.
|
||||
- [Google Workspace Updates — Automatic addition of owned secondary calendars, Jan 2026](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html) — Google ~100 owned recommendation.
|
||||
- [Fastmail — Account limits](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) — 100k events/user, no documented calendar count cap.
|
||||
- [Nextcloud admin manual — Calendar / CalDAV](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) — default 30, configurable, 10/hr rate limit.
|
||||
- Live verification against `internal/services/caldav_*.go` and `paliad.user_caldav_config` / `paliad.appointments` schema on the youpc Supabase instance.
|
||||
|
||||
---
|
||||
|
||||
## Addendum — m's decisions (2026-05-19)
|
||||
|
||||
Walked through §8.1–§8.6 with m via AskUserQuestion. Decisions are
|
||||
locked in for the coder shift; revisit only on Slice-3 feedback.
|
||||
|
||||
| Q | Decision | Implication for the slice plan |
|
||||
|---|---|---|
|
||||
| **§8.1 — Bidirectional default** | **Yes — bidirectional by default** | No `read_only` flag in Slice 1–3. Multi-cal inherits Phase F's last-write-wins / foreign-UID-skip semantics unchanged. Per-binding `read_only` only added later if a real use case shows up. |
|
||||
| **§8.2 — `personal_only` scope** | **Keep — first-class scope** | Ships in Slice 2 as one of the picker's radio options (`Everything I can see` / `Personal only` / `One project`). One enum value, one `ForBinding()` branch. |
|
||||
| **§8.3 — MKCALENDAR timing** | **Slice 2 with Google-degrade UX** | Slice 2 includes `POST /api/caldav-mkcalendar` + capability probe. Google users get a friendly "create the calendar in your Google UI, paste the URL" fallback. iCloud / Fastmail / Nextcloud / Radicale / Baikal / SOGo get one-click "Create new calendar". |
|
||||
| **§8.4 — Soft caps** | **No caps in v1, add later if data warrants** | Drop the 20-warn / 80-block UI guards from §6. Instrument `count(*)` on `user_calendar_bindings` per user as a Slice 2 telemetry add. Revisit if/when real distributions land. |
|
||||
| **§8.5 — `/admin/caldav-bindings` view** | **Don't ship in v1** | Stays out of the slice plan. Support debugging goes via Supabase SQL until a real ticket lands. Frees Slice 4 polish for the legacy-column drop only. |
|
||||
| **§8.6 — Approval-flow remote-edit gap** | **Separate task under t-138** | Out of scope for all four multi-cal slices. File the gap as a new `t-paliad-*` follow-up under t-138 so multi-cal stays clean and reverter-friendly. Pre-existing hole, surfaced not fixed. |
|
||||
|
||||
### Net effect on §7 slice plan
|
||||
|
||||
- **Slice 1** unchanged — schema + backfill, behaviour-equivalent.
|
||||
- **Slice 2** = picker UI + `REPORT calendar-multiget` + **MKCALENDAR
|
||||
with capability probe + Google-degrade message** + binding-count
|
||||
telemetry. No `read_only` flag, no soft caps, no admin view.
|
||||
Scopes enabled: `all_visible`, `personal_only`, `project`.
|
||||
- **Slice 3** = hierarchy scopes (`client` / `litigation` / `patent` / `case`)
|
||||
+ per-project quick-adds. **No** approval-gap fix folded in.
|
||||
- **Slice 4** = drop legacy `appointments.caldav_uid` / `caldav_etag`.
|
||||
Soft-cap banners only if Slice 2 telemetry says we need them.
|
||||
|
||||
### Net effect on §3 schema
|
||||
|
||||
No change. `user_calendar_bindings` still ships with the full
|
||||
`scope_kind` enum (including `personal_only`). `appointment_caldav_targets`
|
||||
unchanged. No `read_only` column in v1.
|
||||
|
||||
### Follow-ups to file as separate tasks
|
||||
|
||||
1. **`t-paliad-*` (under t-138):** approval-flow + CalDAV remote-edit
|
||||
gap. `ApplyRemoteUpdate` bypasses the approval gate when an external
|
||||
client edits a pending-approval event. Pre-existing in single-cal
|
||||
Phase F. Owner: t-138 maintainer.
|
||||
2. **(maybe) `t-paliad-*`:** soft-cap UI if Slice 2 telemetry shows
|
||||
any user near the iCloud-100 / Nextcloud-30 envelope. Not pre-filed
|
||||
— only opens if data warrants.
|
||||
603
docs/design-paliad-data-export-2026-05-19.md
Normal file
603
docs/design-paliad-data-export-2026-05-19.md
Normal file
@@ -0,0 +1,603 @@
|
||||
# Paliad data export — Excel-first, scoped (org / project-subtree / personal)
|
||||
|
||||
Design: archimedes (inventor), 2026-05-19.
|
||||
Task: **t-paliad-214**.
|
||||
Branch: `mai/archimedes/inventor-excel-data`.
|
||||
Status: READY FOR REVIEW — no code yet, awaiting m go/no-go on §11 open questions.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premise check (live state, 2026-05-19)
|
||||
|
||||
Verified directly against the youpc Postgres `paliad` schema rather than against memory or older design docs.
|
||||
|
||||
**Migration tracker.** Latest applied is `100_ccr_visible_rule`; next is **101**.
|
||||
|
||||
**Row counts (org-wide today):**
|
||||
|
||||
| table | rows |
|
||||
|------------------------|-----:|
|
||||
| users | 47 |
|
||||
| projects | 11 |
|
||||
| deadlines | 26 |
|
||||
| appointments | 5 |
|
||||
| parties | 0 |
|
||||
| notes | 4 |
|
||||
| documents | 0 |
|
||||
| project_events (audit) | 93 |
|
||||
| project_teams | 3 |
|
||||
| approval_requests | 8 |
|
||||
| approval_policies | 160 |
|
||||
| checklist_instances | 4 |
|
||||
| deadline_rules | 254 |
|
||||
| user_views | 2 |
|
||||
| partner_units | 11 |
|
||||
|
||||
A full org export today is **< 600 rows of user content** plus reference data — synchronous streamed download is plausible for every scope. We design for an order-of-magnitude head-room.
|
||||
|
||||
**Auth.** Passwords live in Supabase Auth (separate `auth` schema, not `paliad`). The `paliad.users` table has **no `password_hash` column** — so the "don't export credentials" rule from the brief is enforced by absence, not by a column-deny list. Good.
|
||||
|
||||
**Visibility.** Row-level via `paliad.can_see_project(project_id)` (subtree-aware through ltree path). Already used as the predicate that gates every list endpoint. We reuse it for the **personal** and **project** scopes; the **org** scope bypasses it under `global_admin`.
|
||||
|
||||
**Documents.** Table exists, 0 rows. Phase H (AI Frist-Extraktion) is deferred per m's 2026-04-16 call. No `ANTHROPIC_API_KEY` on Dokploy. Therefore **this design does not concern itself with binary attachments** — only with the metadata row when documents start landing.
|
||||
|
||||
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
|
||||
|
||||
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) — admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
|
||||
|
||||
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
Two motivations, both load-bearing:
|
||||
|
||||
1. **Safety / backup.** A workbook on disk is a portable artifact independent of the running app. If paliad.de is down, a partner needs the matter file. If the Dokploy compose corrupts, IT needs a recent dump. If a deadline gets accidentally deleted, we want a recoverable snapshot.
|
||||
|
||||
2. **No lock-in.** A team or an entire org choosing to leave paliad must be able to walk away with their entire dataset in a format anyone can open. We promise this in writing as a trust signal — exactly because the alternative (silently locking customers in) is what we built paliad to *not* be.
|
||||
|
||||
The export is therefore not a "nice analytics feature" — it is **a contractual guarantee that the data is yours**. That framing shapes the design: completeness > convenience, portability > polish, every export auditable.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope definitions (precise)
|
||||
|
||||
Three scopes. The boundary is **what the caller is allowed to see**, joined with **what makes the artifact interpretable standalone**.
|
||||
|
||||
### 2.1 `org` scope
|
||||
|
||||
**Caller:** `global_role='global_admin'` only. There is no firm-admin role distinct from global_admin in paliad today (see §4).
|
||||
|
||||
**Content:** literally everything in the `paliad` schema that is user content or reference data the workbook needs to be readable. Specifically:
|
||||
|
||||
| sheet | source table(s) | notes |
|
||||
|------------------------|-------------------------------------------------------------------|-------|
|
||||
| `projects` | `paliad.projects` (all rows) | Full project tree including soft-deleted (status='deleted' / 'closed' if any). |
|
||||
| `project_teams` | `paliad.project_teams` | profession + responsibility (post-t-148). |
|
||||
| `project_partner_units`| `paliad.project_partner_units` | Derivation grants. |
|
||||
| `deadlines` | `paliad.deadlines` | Including completed, cancelled. |
|
||||
| `appointments` | `paliad.appointments` | Including completed. |
|
||||
| `parties` | `paliad.parties` | All client / opposing-party data. |
|
||||
| `notes` | `paliad.notes` | All four polymorphic targets resolved into the `target_kind`/`target_id` columns. |
|
||||
| `documents` | `paliad.documents` metadata (file_path, file_size, mime_type, ai_extracted) | Binaries excluded (open Q1). |
|
||||
| `audit_events` | `paliad.project_events` | Full audit trail per project. |
|
||||
| `approval_requests` | `paliad.approval_requests` | Including completed / rejected, with `requester_kind` + `agent_turn_id`. |
|
||||
| `approval_policies` | `paliad.approval_policies` | Both project-scoped and partner-unit-defaults. |
|
||||
| `policy_audit_log` | `paliad.policy_audit_log` | Source #5 of the audit union. |
|
||||
| `partner_units` | `paliad.partner_units` | Org chart. |
|
||||
| `partner_unit_members` | `paliad.partner_unit_members` | Including unit_role. |
|
||||
| `partner_unit_events` | `paliad.partner_unit_events` | Org-chart audit. |
|
||||
| `checklist_instances` | `paliad.checklist_instances` | Per-project completion state. |
|
||||
| `invitations` | `paliad.invitations` (status, role, expires_at) | Without raw tokens (open Q7). |
|
||||
| `users` | `paliad.users` (id, email, display_name, office, profession, …) | Excludes `email_preferences` jsonb only if it carries channel secrets — none do today, but checked at export time. |
|
||||
| `user_views` | `paliad.user_views` | Saved filters / custom layouts. |
|
||||
| `user_card_layouts` | `paliad.user_card_layouts` | Project-card layouts. |
|
||||
| `user_pinned_projects` | `paliad.user_pinned_projects` | Per-user pins. |
|
||||
| `user_caldav_config` | `paliad.user_caldav_config` **without** the ciphertext column | URL + calendar IDs + last_sync; passwords NEVER exported. |
|
||||
| `reminder_log` | `paliad.reminder_log` | Outbound digest history. |
|
||||
| `caldav_sync_log` | `paliad.caldav_sync_log` | Per-user sync runs. |
|
||||
| `paliadin_turns` | `paliad.paliadin_turns` | **Excluded by default** in org export (privacy — see §6) — admins opt in per Q5. |
|
||||
| `email_broadcasts` | `paliad.email_broadcasts` | Outbound broadcast history. |
|
||||
| `email_templates` + `_versions` | both | Custom firm templates. |
|
||||
| **reference (read-only):** | `proceeding_types`, `event_types`, `event_categories`, `deadline_rules`, `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_category_concepts`, `trigger_events`, `holidays`, `courts`, `countries` | One sheet per table, prefixed `ref__`. Embedded so the workbook is interpretable without paliad context. |
|
||||
| **deferred audit (admin opt-in):** | `deadline_rule_audit`, `policy_audit_log`, `partner_unit_events`, `caldav_sync_log`, `paliadin_turns` | Behaviour per Q5/Q6. |
|
||||
|
||||
**Excluded unconditionally:**
|
||||
- `auth.*` (Supabase Auth schema — not ours; the user can request their auth record from Supabase directly).
|
||||
- `paliad_schema_migrations` (operational, no business meaning).
|
||||
- `*_pre_NNN` shadow / pre-migration backup tables (rows are duplicates; the live table is canonical).
|
||||
- Any future `*_secret` / `*_token` columns (see §6 deny-list mechanism).
|
||||
|
||||
**Edge cases:**
|
||||
- **Soft-deleted rows:** paliad currently has no soft-delete columns (`deleted_at` etc.). When that lands, the org export includes them by default with a `deleted_at` column populated. Until then, this is a no-op.
|
||||
- **Archived projects:** `projects.status` can be `'closed'` or future `'archived'` — export includes them (the whole point of backup is recoverability of closed matters).
|
||||
- **Counterclaims:** `projects.counterclaim_of` is a self-FK. Export carries the column as-is; the relationship is reconstructable via the `id` column.
|
||||
|
||||
### 2.2 `project` scope
|
||||
|
||||
**Caller:** any team member of the project who passes the §4 profession-tier gate.
|
||||
|
||||
**Content:** one project + **all descendants** along the ltree path. The descendant walk is `WHERE path <@ root.path` (subtree-inclusive of root). Every entity gets filtered through `WHERE project_id IN (subtree_ids)`.
|
||||
|
||||
Per-sheet inclusion:
|
||||
|
||||
- `projects` (root + descendants, one row each)
|
||||
- `project_teams` (membership for those projects)
|
||||
- `project_partner_units` (derivation attachments)
|
||||
- `deadlines`, `appointments`, `parties`, `notes`, `documents` (metadata), `audit_events`, `approval_requests`, `checklist_instances` — all scoped to subtree
|
||||
- **users sheet — restricted columns:** only `id, email, display_name, office, profession` for users referenced by any FK in the export (created_by, assigned, etc.). Don't dump all 47 users when you only need 4. (Avoids accidental org-chart leak in a project-scope export shared externally.)
|
||||
- **reference data:** `ref__proceeding_types`, `ref__event_types`, `ref__deadline_rules`, `ref__deadline_concepts`, `ref__courts`, `ref__countries`, `ref__holidays`. Same as org but a smaller universe is acceptable too — the v1 ships the full reference tables for simplicity (every row count is ≤ 300; size is moot).
|
||||
- **Cross-project references** (e.g., a party referenced by a project outside the subtree): out of scope by the predicate. The export carries the foreign UUID so a re-import or merge could re-link, but the foreign row itself is not in the workbook. Edge case is rare — `counterclaim_of` is the only known cross-project pointer today.
|
||||
|
||||
**Edge cases:**
|
||||
- **Partner-unit data:** `partner_units` is org-wide; project export carries only the unit ids attached via `project_partner_units`. The unit name + membership are loaded into the workbook on `partner_units` and `partner_unit_members` sheets (filtered to the attached units only).
|
||||
- **Policies:** `approval_policies` rows include both project-scoped (the project + ancestors) **and** partner-unit-defaults attached to this project. Same MAX-of-sources logic as runtime.
|
||||
- **Audit:** `project_events` for the subtree + (admin opt-in only) `deadline_rule_audit` rows whose rule was used by any deadline in the subtree. Default off — these are firm-wide curation logs and don't belong in a per-project handoff.
|
||||
|
||||
### 2.3 `personal` scope
|
||||
|
||||
**Semantics:** "everything I can see right now in paliad, framed as my data."
|
||||
|
||||
That definition resolves the ambiguity in the brief: personal scope is **not** "rows where I am `created_by`" — that misses everything I see by being on a team. It is **the RLS-visible projection of the schema for caller=me**, plus a handful of explicitly-personal sidecars (caldav config, my pins, my views).
|
||||
|
||||
Per-sheet inclusion:
|
||||
|
||||
| sheet | rows |
|
||||
|---|---|
|
||||
| `projects` | `WHERE paliad.can_see_project(id)` for the caller. |
|
||||
| `project_teams` | Rows where `user_id = me` OR the row's project is in my visible set. |
|
||||
| `deadlines` | Same project-visibility filter. |
|
||||
| `appointments` | Same. |
|
||||
| `parties`, `notes`, `documents` metadata, `audit_events`, `checklist_instances` | Same. |
|
||||
| `approval_requests` | Rows where `requested_by = me` OR `decided_by = me` OR project ∈ visible set. |
|
||||
| `me` (single-row sheet) | Caller's `users` row (id, email, display_name, office, profession, reminder_*, lang, escalation_contact_id). |
|
||||
| `my_caldav_config` | The caller's `user_caldav_config` row **without** the encrypted password column — sync URL, calendar IDs, last_sync_at. |
|
||||
| `my_views` | Caller's `user_views` rows. |
|
||||
| `my_pinned_projects` | Caller's `user_pinned_projects` rows. |
|
||||
| `my_card_layouts` | Caller's `user_card_layouts` rows. |
|
||||
| `my_paliadin_turns` | Caller's `paliadin_turns` rows (currently restricted to `PaliadinOwnerEmail` = m, so this sheet is empty for everyone else). Sensitive: AI prompts + responses. **Default on for personal scope** — it's literally the caller's data. |
|
||||
| `users_referenced` | Restricted: id + display_name + email for users referenced as FKs in the export. |
|
||||
| reference tables | Same set as project scope. |
|
||||
|
||||
**Edge cases:**
|
||||
- **Caller leaves a team:** the export reflects the moment-in-time visibility. A `generated_at` timestamp in the workbook header (`__meta` sheet) anchors this.
|
||||
- **Caller is a global_admin:** their personal export is the entire org (because their visible set = all projects). This is by design — but we surface a banner ("Sie sehen alles, weil Sie global_admin sind. Ein org-scope-Export wäre identisch.") so they don't get confused thinking the personal-scope endpoint is broken.
|
||||
- **Caller has no team memberships:** export contains the empty workbook + the `me` row + their caldav config + views/pins. Still useful — they can save their preferences.
|
||||
|
||||
### 2.4 Common columns across all scopes
|
||||
|
||||
Every export workbook contains a `__meta` sheet:
|
||||
|
||||
```
|
||||
schema_version: 1
|
||||
firm_name: HLC # from internal/branding.Name
|
||||
scope: org | project | personal
|
||||
scope_root_id: uuid or NULL # the project id for project-scope, NULL otherwise
|
||||
generated_at: 2026-05-19T14:23:00Z
|
||||
generated_by_user: <uuid> <email> # the caller
|
||||
generated_by_label: archimedes / m / ... # display_name
|
||||
row_counts: JSON {"projects": 11, ...}
|
||||
paliad_version: <git sha at server build>
|
||||
notes: free-form, e.g., "documents binaries excluded by design"
|
||||
```
|
||||
|
||||
This pins provenance + reproducibility + diffability.
|
||||
|
||||
---
|
||||
|
||||
## 3. Format choices
|
||||
|
||||
### 3.1 xlsx as the primary format
|
||||
|
||||
**Library: `github.com/xuri/excelize/v2`.** De-facto Go xlsx library, pure-Go (no cgo, no external libreoffice), MIT, streaming writer for large workbooks, broad format-feature support (number formats, freeze panes, hyperlinks, sheet hide). The streaming writer (`NewStreamWriter`) is what we use — it writes rows one at a time without holding the whole sheet in memory. At 11-projects scale this is unnecessary; at 11k-projects scale it's essential, so we set the pattern now.
|
||||
|
||||
**Why not the alternatives:**
|
||||
- `tealeg/xlsx` — older, unmaintained, no streaming.
|
||||
- `qax-os/excelize` — same project as xuri/excelize (the github org renamed); xuri is the upstream.
|
||||
- `360EntSecGroup-Skylar/excelize` — defunct fork.
|
||||
|
||||
**Workbook structure:** one **sheet per entity type**, *never* a mixed-type sheet with conditional columns. Reasons:
|
||||
- Excel users sort + filter by column; a column that means "deadline due_date" on row 4 and "appointment start_at" on row 12 is unusable.
|
||||
- The "self-describing" promise (no-lock-in) is satisfied by a workbook where every sheet is a flat table with stable column headers, not by a polymorphic blob.
|
||||
- Cross-sheet relationships are represented by **UUIDs in foreign-key columns** + a `__lookup` sheet pairing UUID → display label (project title, user email) for the workbook's lifetime. This makes the workbook self-joining in Power Query / pivot tables.
|
||||
|
||||
**Sheet conventions:**
|
||||
- Sheet names use `snake_case` matching SQL table names (`deadlines`, not `Fristen`). Reference tables prefixed `ref__`. Personal sidecars prefixed `my_`. Meta sheet `__meta`. The `__lookup` sheet sits last.
|
||||
- Row 1 = column headers; frozen.
|
||||
- Column 1 of every entity sheet is `id` (uuid).
|
||||
- Dates: ISO 8601 UTC for timestamptz; `YYYY-MM-DD` for `date`. Always as Excel strings (not Excel date types) — Excel-date interpretation differs by locale (DE: `Tag.Monat.Jahr`, EN: `Month/Day/Year`) and silently corrupts on round-trip. A pinned ISO string is unambiguous and re-importable. Open Q4 covers whether to *also* mirror to native Excel dates for human convenience.
|
||||
- Booleans: literal `TRUE` / `FALSE` strings, same reason.
|
||||
- `jsonb` columns: serialised as compact JSON one-liners in the cell. Cell type = string. Power Query can `Json.Document` them.
|
||||
- Arrays (e.g., `additional_offices text[]`): semicolon-joined string. Excel's CSV-array convention is the comma but our office codes use commas; semicolon avoids the collision.
|
||||
- `text[uuid[]]` paths (the projects.path ltree): exported as the canonical dotted-uuid string.
|
||||
|
||||
**Encoding:** UTF-8 always. Excelize handles the xlsx packaging which is unicode-native. Umlaute round-trip correctly (verified pattern with tesla's CSV export in t-paliad-177).
|
||||
|
||||
### 3.2 CSV + JSON siblings
|
||||
|
||||
Per the no-lock-in promise, **xlsx is not enough on its own** — Excel is a proprietary format owned by Microsoft, and a workbook is opaque without a tool that understands it. For genuine portability we also produce:
|
||||
|
||||
- **CSV:** one file per entity sheet (no reference sheets — those go as JSON), UTF-8 with BOM (`\xEF\xBB\xBF`) for Excel-DE compat, RFC 4180 quoting, headers row 1. Identical column shape to the xlsx sheet.
|
||||
- **JSON:** a single `paliad-export.json` per scope, top-level `{"meta": {...}, "tables": {"projects": [...], "deadlines": [...], ...}}`. Easiest for programmatic re-ingest. Reference tables included.
|
||||
|
||||
**Delivery shape:** all three formats live inside one `.zip` per export:
|
||||
```
|
||||
paliad-export-<scope>-<timestamp>.zip
|
||||
├── README.txt # human-readable: what this is, how to read it
|
||||
├── paliad-export.xlsx # canonical workbook
|
||||
├── paliad-export.json # JSON twin (machine-readable)
|
||||
├── csv/
|
||||
│ ├── projects.csv
|
||||
│ ├── deadlines.csv
|
||||
│ ├── ...
|
||||
│ └── ref/
|
||||
│ ├── proceeding_types.csv
|
||||
│ └── ...
|
||||
└── __meta.json # standalone meta (same content as __meta sheet)
|
||||
```
|
||||
|
||||
The `.zip` is the artifact users download. Default content is "all three" — there's no UI knob to pick (open Q1: should there be? Inventor pick = no, zip-only).
|
||||
|
||||
**Filename convention:**
|
||||
```
|
||||
paliad-export-{scope}-{timestamp}.zip
|
||||
scope = org | project-<root-short> | personal
|
||||
timestamp = YYYY-MM-DDTHHMMZ # UTC, no colons (Windows-safe)
|
||||
```
|
||||
Examples: `paliad-export-org-2026-05-19T1423Z.zip`, `paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip`, `paliad-export-personal-2026-05-19T1423Z.zip`. The project-short is `slugify(root.title)` capped 40 chars.
|
||||
|
||||
**Determinism (Q6 question).** Two exports of the same scope at the same row state must produce **byte-identical** workbooks. xlsx is internally a zip of XML — file order in the zip is significant; excelize's default zip writer is non-deterministic. We can make this deterministic by sorting the file list before writing. JSON: keys sorted alphabetically. CSV: rows ordered by `id ASC` (stable). The only inherently non-deterministic field is `generated_at`; we externalise it to the filename and the `__meta` sheet, but the rest of the workbook is byte-stable. **Inventor pick: yes, deterministic.** Lets users diff exports and prove "nothing changed between Tuesday and Thursday."
|
||||
|
||||
### 3.3 Future-proofing — schema_version
|
||||
|
||||
`__meta.schema_version = 1`. When we add columns (e.g., projects.archived_at lands), we bump to 2 and note the additions in a `docs/export-schema-changelog.md`. Importers (us in the future, or a re-importer at a different firm) read schema_version first.
|
||||
|
||||
---
|
||||
|
||||
## 4. Authorization model
|
||||
|
||||
**Tightly mirrored to existing paliad role surfaces.** No new roles introduced.
|
||||
|
||||
| Scope | Required auth |
|
||||
|---|---|
|
||||
| `org` | `paliad.users.global_role = 'global_admin'`. Same gate as `/admin/*` pages (`auth.RequireAdminFunc` in `handlers.go:417`). |
|
||||
| `project` | Caller must (a) pass `can_see_project(root_id)`, AND (b) have effective project profession ≥ **associate** on the root. The associate floor mirrors the conservative seed in `approval_policies` (t-154); paralegals + PA can see data but not extract it. m-tunable per Q2. |
|
||||
| `personal` | Any authenticated user. No additional gate. |
|
||||
|
||||
**Profession ladder check** for project scope uses the existing `DerivationService.EffectiveProjectRole` (t-139 phase 2) — direct membership > ancestor > derived via partner-unit. Same surface that gates approvals; same surface gates extracts.
|
||||
|
||||
**Audit row written on every export run.** A new event_type into `paliad.project_events` for project-scope (so it appears on the project's Verlauf), `partner_unit_events` for org-scope (so it appears on the partner-unit audit log of the firm-admin's home unit), and `policy_audit_log` is too narrow — we likely want a **new** audit table for org-wide actions, OR we widen `project_events` to allow `project_id = NULL` org-wide rows. **Inventor pick: new table `paliad.system_audit_log`** — clean separation, integrates into the existing 5-source AuditService union as source #6. Migration 101 adds it.
|
||||
|
||||
`system_audit_log` columns:
|
||||
```sql
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_type text NOT NULL, -- 'data_export'
|
||||
actor_id uuid REFERENCES paliad.users(id),
|
||||
actor_email text NOT NULL, -- captured at write time, survives user deletion
|
||||
scope text NOT NULL, -- 'org' | 'project' | 'personal'
|
||||
scope_root uuid, -- project_id for project scope, NULL otherwise
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"formats":["xlsx","json","csv"], "row_counts":{...}, "file_size_bytes":12345, "filename":"..."}
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
```
|
||||
|
||||
The audit row is written **before** the export runs (so failed exports are still recorded) and **updated** with `file_size_bytes` + final `row_counts` on success. Failure case: separate `event_type='data_export_failed'` row with the error string in metadata. **The audit chain is the trust signal** — m sees who exfiltrated what, when, and how much.
|
||||
|
||||
**Headers on the response:**
|
||||
- `Content-Disposition: attachment; filename="paliad-export-<scope>-<ts>.zip"`
|
||||
- `X-Paliad-Export-Audit-Id: <system_audit_log.id>` — so an automated client can reference the audit row.
|
||||
|
||||
---
|
||||
|
||||
## 5. Trigger model
|
||||
|
||||
Three trigger surfaces:
|
||||
|
||||
### 5.1 On-demand button
|
||||
|
||||
- **Personal:** `/settings` → "Daten exportieren" card → button. POST `/api/me/export` → 200 with `Content-Type: application/zip`. Synchronous.
|
||||
- **Project:** `/projects/{id}` → settings/cog menu → "Daten dieses Projekts exportieren". POST `/api/projects/{id}/export` → 200 zip. Synchronous. Includes a "Inkl. Unterprojekte" toggle hint (it's always subtree-inclusive — the toggle is purely informational, no off switch).
|
||||
- **Org:** `/admin/data-export` (new page, card on `/admin`) → "Org-Export erstellen" button. POST `/api/admin/export/org` → **async** by default (see §6.1). Returns 202 + `job_id`. UI polls `/api/admin/export/org/jobs/{id}` for status.
|
||||
|
||||
**Why org is async even at today's scale:** the principle isn't "is it slow now" — it's "the trigger model should not change as the firm grows." If the partner with the firm-wide button gets a different UX from the associate with the project button, we'd retrofit later. Sync at 600 rows works fine; the wrapping is `goroutine + channel + Server-Sent Events for live progress`, no new infra needed. See §6.1.
|
||||
|
||||
### 5.2 Scheduled exports
|
||||
|
||||
**Inventor pick — defer to slice 4.** Out of v1 scope. The reasoning: scheduling sits on storage + delivery + retention, all of which are *also* deferred to slice 3+. Building the scheduler before we know how + where the artifact lives is premature.
|
||||
|
||||
When it lands (slice 4), the model is:
|
||||
- A new `paliad.scheduled_exports` table: `(id, scope, scope_root_id, owner_user_id, cadence, last_run_at, next_run_at, delivery)` where `delivery` is `{kind: 'email-link' | 'caldav-style-webdav', config: jsonb}`.
|
||||
- A daily cron (mai cron or a `time.Ticker` goroutine) checks `next_run_at < now()`, runs the export, posts the link via the configured delivery channel.
|
||||
- Cadence: weekly + monthly + on-status-change (e.g., "export when project closes" — a webhook from `projects.status` triggers).
|
||||
|
||||
For now (slice 1-2), users can right-click the on-demand button and bookmark the URL — that's the **only** scheduled-export-y thing we offer, and it's intentional: get the manual flow rock-solid before adding cadence.
|
||||
|
||||
### 5.3 API endpoint
|
||||
|
||||
Same endpoints as §5.1, callable directly with the standard cookie / bearer auth. We don't add a separate "API key" surface in v1 — paliad doesn't have personal access tokens today. If a user wants to script their personal export weekly, they can use cookie auth from `m/paliad` automation; that's enough until power-user volume justifies a real PAT surface.
|
||||
|
||||
For machine ergonomics: the `/api/...export` endpoints accept `?format=zip` (default), `?format=xlsx`, `?format=json`, `?format=csv-zip` query params. Only `zip` is documented; the others are internal but reachable for automation.
|
||||
|
||||
---
|
||||
|
||||
## 6. Storage + delivery
|
||||
|
||||
### 6.1 Synchronous vs async — per-scope picks
|
||||
|
||||
**Personal, project:** **Synchronous, streamed.** The handler holds the HTTP connection open, writes the zip directly to `http.ResponseWriter`. For 1MB-class exports (today's reality at every scale up to thousands of rows per entity) this is the right call — no persistence, nothing to garbage-collect, nothing leaking onto disk. Excelize's `NewStreamWriter` flushes rows as they're written so RAM stays bounded.
|
||||
|
||||
**Org:** **Asynchronous, in-process queue, on-disk artifact.**
|
||||
- Submit (`POST /api/admin/export/org`) writes a `system_audit_log` row with status `pending` and dispatches a goroutine.
|
||||
- The goroutine writes the zip to `/var/lib/paliad/exports/{audit_id}.zip` (configurable via `PALIAD_EXPORT_DIR`; on Dokploy this is a mounted volume).
|
||||
- The goroutine updates the audit row's metadata with progress, then status `done` with `file_size_bytes` on success.
|
||||
- The user polls `GET /api/admin/export/org/jobs/{audit_id}` (SSE or simple JSON) — when ready, a download link `GET /api/admin/export/org/jobs/{audit_id}/download` serves the file.
|
||||
- Download deletes the file by default (one-shot link), or keeps it per Q3.
|
||||
|
||||
**Why not S3-style bucket?** Paliad already has a `documents` table that *will* need a binary store, eventually. Coupling export storage to that future store is right — but the future store doesn't exist yet, and we don't want to provision MinIO on mlake purely for exports. **Inventor pick: local disk in `PALIAD_EXPORT_DIR`** until/unless we provision a real object store; at that point the export storage moves there transparently.
|
||||
|
||||
### 6.2 Retention (Q3)
|
||||
|
||||
**Inventor pick: 7 days, then auto-delete.** Justifications:
|
||||
1. Exports contain sensitive client data — minimising the retention window minimises blast radius if the Dokploy host is compromised.
|
||||
2. 7 days covers a holiday-week round-trip ("I exported Friday, want to look at it Monday next week, missed the day-1 link").
|
||||
3. The audit row in `system_audit_log` persists forever — you can always tell that an export happened, even after the artifact is deleted.
|
||||
|
||||
A cleanup goroutine runs daily, lists `system_audit_log` rows older than 7 days with non-NULL `file_path`, deletes the file, sets `metadata.deleted_at`. Audit row stays.
|
||||
|
||||
The `PALIAD_EXPORT_RETENTION_DAYS` env var is the knob (default `7`). m-tunable per firm.
|
||||
|
||||
### 6.3 PII / GDPR
|
||||
|
||||
This is where the design gets serious.
|
||||
|
||||
**At-rest encryption.** Files in `PALIAD_EXPORT_DIR` are plaintext on the Dokploy volume. The volume itself is encrypted at the host layer (Hostinger VPS disk encryption). We **do not** layer additional file-level encryption on the artifact — that would require a per-user key, key escrow, key rotation, all of which is over-engineered for a 7-day-retention exfil where the link is single-use behind cookie auth. The disk encryption + 7-day TTL + audit log is the trust boundary.
|
||||
|
||||
**In-transit encryption.** TLS via Dokploy + Traefik — paliad.de is Let's Encrypt-served. No raw HTTP path.
|
||||
|
||||
**Download authentication.** The download link `/api/admin/export/org/jobs/{audit_id}/download` requires the same cookie auth as the submit. No public signed URLs in v1 (deferred per Q8). When we add scheduled exports + email delivery (slice 4), we'll need expiring signed URLs — that design is captured then, not now.
|
||||
|
||||
**Data-subject requests.** A user invoking `/api/me/export` is, in effect, performing a self-serve GDPR Art. 15 data-portability request. Audit row records the request. If the firm receives a *third-party* DSR ("export the data my client Mr. Müller asked for"), a global_admin can run a project-scope export filtered to projects involving that client; this is a manual workflow we don't automate in v1 (open Q9).
|
||||
|
||||
**Right-to-erasure.** Out of scope. Erasure is a write path; export is read-only. They share no code.
|
||||
|
||||
**External sharing of export files.** A user who downloads an export and emails it to an external party has done so on their own authority and outside paliad's protection. We don't watermark the file (debated and rejected: watermarking introduces non-determinism, breaks diffability, and gives false security — anyone reading the zip can strip metadata). What we *do* document in the embedded `README.txt`:
|
||||
|
||||
> Diese Datei enthält möglicherweise vertrauliche Mandantsdaten. Sie wurde
|
||||
> erzeugt am {generated_at} durch {actor_email} aus Paliad ({firm_name}).
|
||||
> Die Weitergabe an Dritte erfolgt in eigener Verantwortung des Empfängers.
|
||||
|
||||
A simple "you broke the seal" notice is what we offer. It's a contract, not a control.
|
||||
|
||||
**PII column deny-list.** Hard-coded in `internal/services/export_service.go`:
|
||||
- `paliad.users.password_hash` — doesn't exist, but the deny-list is the safety net if it ever does.
|
||||
- `paliad.user_caldav_config.encrypted_password` — explicit drop.
|
||||
- Any column whose name matches `(?i)secret|token|password|api[_-]?key|private[_-]?key` — caught at column-discovery time, errors loudly into `system_audit_log.metadata.warnings`.
|
||||
- `paliadin_turns.assistant_response` — present in personal export of caller's own data; **off** in org export by default (m's call per Q5).
|
||||
|
||||
### 6.4 GDPR-completeness note
|
||||
|
||||
The export of one user's personal scope is **a partial Art. 15 disclosure** — it contains what's *in paliad's* control. Other systems (Supabase Auth row, mlake logs, CalDAV provider) are out of paliad's scope and not in the export. The embedded README states this explicitly so the user knows the workbook is the paliad-side answer, not a complete personal-data dump from "the firm."
|
||||
|
||||
---
|
||||
|
||||
## 7. Slice plan
|
||||
|
||||
Tracer-bullet shipping. Each slice is independently shippable and reviewable. The first slice closes the no-lock-in promise for the smallest, lowest-risk scope; later slices widen.
|
||||
|
||||
### Slice 1 — personal export, synchronous, xlsx + JSON
|
||||
|
||||
- Adds `excelize/v2` to `go.mod`.
|
||||
- New `internal/services/export_service.go` with the column-discovery + writer plumbing for xlsx + JSON.
|
||||
- New `internal/handlers/export.go` with `POST /api/me/export`.
|
||||
- New `/settings` UI: "Daten exportieren" card + button.
|
||||
- Migration 101: `paliad.system_audit_log` + `AuditService.ListEntries` 6th union branch.
|
||||
- i18n keys (`settings.export.*`, `__meta.*`).
|
||||
- Tests: `export_service_test.go` covers xlsx structure (one row each kind), JSON shape, PII deny-list.
|
||||
|
||||
Ships the no-lock-in promise for every user immediately. ~600-800 LoC + ~25 i18n keys.
|
||||
|
||||
### Slice 2 — project export, synchronous, xlsx + JSON + CSV-zip
|
||||
|
||||
- Generalises the export_service to scope-aware queries (the visibility predicate gets injected per scope).
|
||||
- New `POST /api/projects/{id}/export`, gated by §4.
|
||||
- Adds CSV writer alongside xlsx + JSON; bundles all three into `.zip`.
|
||||
- Project-detail UI gets the export menu entry.
|
||||
- README.txt template embedded.
|
||||
- Tests + e2e (Playwright) on the project page button.
|
||||
|
||||
~800-1000 LoC. The CSV path generalises the xlsx column-discovery so the marginal cost is low. After this slice, two of three scopes are shipped and synchronous serves both.
|
||||
|
||||
### Slice 3 — org export, async with job tracking
|
||||
|
||||
- Adds the goroutine + on-disk artifact path + `PALIAD_EXPORT_DIR` env.
|
||||
- `POST /api/admin/export/org` + job status + download endpoints.
|
||||
- New `/admin/data-export` page (card on `/admin/`).
|
||||
- Cleanup goroutine (daily, deletes artifacts > `PALIAD_EXPORT_RETENTION_DAYS`).
|
||||
- Refactor: extract the now-common "writeExportToWriter" core from the synchronous path so async re-uses it.
|
||||
|
||||
~600-800 LoC. After this slice, all three scopes ship + audit trail is complete.
|
||||
|
||||
### Slice 4 — scheduled exports (deferred, not v1)
|
||||
|
||||
Designed in §5.2; building deferred until at least 2 firms ask. The contract surface is the `scheduled_exports` table + cadence + delivery channel.
|
||||
|
||||
### Slice 5 — API ergonomics (deferred)
|
||||
|
||||
Personal Access Tokens (the "I want to cron my own export" surface). Until there's a customer, we don't build the PAT issuer + revocation + audit.
|
||||
|
||||
### Slice 6 — GDPR DSR helpers (deferred)
|
||||
|
||||
A `/admin/data-subject-request` workflow to assemble a per-natural-person export across projects. Built on Slice 1-3 primitives; not blocked by them.
|
||||
|
||||
### Slice 7 — document binary inclusion (deferred until documents have rows)
|
||||
|
||||
When the `documents` table starts holding real files, the export adds a `documents/` subdir in the zip with the actual files, keyed by filename = `{document_id}.{ext}`. The metadata sheet links by id. Adds ~150 LoC + an env var for the file backend.
|
||||
|
||||
**Critical-path slices for v1: 1 + 2 + 3.** Everything after is layered, optional, m-prioritised when there's a real customer pull.
|
||||
|
||||
---
|
||||
|
||||
## 8. Trade-offs flagged
|
||||
|
||||
1. **xlsx-first means we own the `excelize` dependency forever.** Mitigation: excelize is the canonical Go xlsx — replacing it would be a multi-thousand-LoC migration, but the upstream is healthy (MIT, 17k+ stars, monthly releases). Acceptable lock-in.
|
||||
|
||||
2. **Determinism (sorted file order, sorted JSON keys, row-id-ordered CSV) is implementation discipline, not a library default.** Test that breaks if any future change introduces non-determinism is essential (helps reviewers + prevents regressions).
|
||||
|
||||
3. **Synchronous personal + project means a runaway export can block a request goroutine for seconds.** At today's data shape this is sub-second. Watchdog: a 30s context deadline on synchronous exports; over that, return 503 with "export too large — contact admin for async." Triggers slice 3 → slice 4 of the user's mental model.
|
||||
|
||||
4. **Per-scope endpoints triplicate similar code paths.** Mitigated by the shared `ExportSpec` struct + scope-aware predicate injection. Read carefully in code review — this is the place subtle scope leaks creep in.
|
||||
|
||||
5. **JSON twin is genuinely redundant for human users.** It's there for the no-lock-in promise (a Python script can re-ingest without Excel). The cost is one extra file in the zip + one extra serialisation pass. Acceptable.
|
||||
|
||||
6. **No diff tooling — yet.** Determinism enables `diff -r` between two extracted zips, but no in-app surface. Slice 4+ may layer "show me what changed between Monday's and Friday's export" once exports are scheduled and stored.
|
||||
|
||||
7. **`paliadin_turns` privacy default.** Currently restricted to `PaliadinOwnerEmail` so the table is empty for every other user. Personal export carries them by default ("your AI history"); org export by default does NOT (admin opt-in via `?include=paliadin_turns`). When Paliadin opens past owner-only (post-API cutover), revisit.
|
||||
|
||||
8. **Reference-data inclusion bloats every export.** 254 deadline_rules + 102 trigger_events + 56 concepts + … = ~1000 reference rows in every workbook regardless of scope. At zip-compressed sizes this is < 100KB and worth the standalone-interpretability. If the workbook gets too large later, ship reference data as a separate "paliad-reference-snapshot.zip" once + reference it from each export's README.
|
||||
|
||||
9. **Org export volume at firm-scale.** A 10k-project firm has ~50k deadlines and ~200k audit events. Even at 200 bytes/row average that's < 100MB — comfortable for the async path with 4GB Dokploy RAM. Threshold concerns kick in at 1M+ rows, which is firm-class-of-100-attorneys territory. Designed for, not blocked on.
|
||||
|
||||
10. **Audit-log explosion.** A nightly cron + 47 users self-exporting = 50 audit rows / day. At a year that's 18k rows. Still trivial. No retention on the audit chain (the artifact retention does NOT touch audit-log retention — the audit chain is the trust signal, see §4).
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommended implementer
|
||||
|
||||
**Single PR, layered slices 1 → 2 → 3 as separate commits.** No DB-heavy migrations; the only schema add is `system_audit_log` (one table, one trigger if any). The hard work is in the writer abstraction.
|
||||
|
||||
- **Slice 1:** pattern-fluent Sonnet coder. ~600-800 LoC, mostly bookkeeping. Tests pin the shape.
|
||||
- **Slice 2:** same hands as slice 1 (continuity matters here — the writer abstraction is set in slice 1 and the project scope generalises it).
|
||||
- **Slice 3:** same hands again. The async path is its own subsystem but the writer is unchanged.
|
||||
|
||||
**NOT cronus** per memory directive 2026-05-06 (retired from paliad).
|
||||
**NOT m** — this is a coder task end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## 10. Inventor → coder transition (GATED per project CLAUDE.md)
|
||||
|
||||
Per `.claude/CLAUDE.md`: design phase ends here. No code touches the tree from inventor. Head's `mai-head` skill gates the coder shift after m's go on §11 open questions.
|
||||
|
||||
When approved, the coder shift opens on `mai/<coder-name>/data-export-slice-1` (fresh branch off main, NOT off the design branch — design doc commit is the only artifact this branch carries forward via cherry-pick).
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for m
|
||||
|
||||
The brief lists 8 candidate questions. After live-state verification I've collapsed + sharpened to 9, each with an inventor pick + reasoning. Will be asked sequentially via AskUserQuestion (paliad dogma — no `## §X.Y` markdown dump on m, per t-paliad-154 lesson).
|
||||
|
||||
### Q1 — Bundle xlsx + CSV + JSON in one zip, or let user pick format?
|
||||
|
||||
**Inventor pick: bundle all three in one zip, no UI knob.**
|
||||
|
||||
Reasoning: the no-lock-in promise *requires* the JSON twin (Excel-independent re-ingest); the xlsx is the human-readable default; CSV is the universal lingua franca. Picking only one breaks the promise for some user. Bundle size at today's scale is < 1MB; even at firm-scale it's well under the email-attachment limit. The cost of a checkbox UI is more than the cost of three extra files.
|
||||
|
||||
Alternative: offer `?format=xlsx-only|json-only|csv-only` query params for the API surface, default to bundle. Documented in README only. We do this in v1 anyway since multi-format is what generates the zip in the first place.
|
||||
|
||||
### Q2 — Project-scope profession floor: associate (inventor pick) or member?
|
||||
|
||||
**Inventor pick: associate floor.**
|
||||
|
||||
A project export carries party names, addresses, decision-history, draft strategy notes. That's "I can write a paper for the partner" data, not "I can see the deadline calendar" data. Member is the bare-visibility tier (you got added to the team). Export is exfiltration — needs the next tier up.
|
||||
|
||||
Alternative: gate by `responsibility ∈ {lead, member}` (no profession floor, only the project-team responsibility check). Cleaner architecturally — separates the "can see" axis from the "can extract" axis using the same fields. Less restrictive in practice.
|
||||
|
||||
Worth choosing now because the gate text in the audit row mentions the tier.
|
||||
|
||||
### Q3 — Org-export artifact retention: 7 days (pick) or 30 / 90?
|
||||
|
||||
**Inventor pick: 7 days.**
|
||||
|
||||
Default conservative. m-tunable per firm via env var.
|
||||
|
||||
### Q4 — Excel dates: ISO strings only (pick) or also a mirrored native-Excel-date column?
|
||||
|
||||
**Inventor pick: ISO strings only.**
|
||||
|
||||
Native Excel dates are locale-poisoned (DE vs EN epoch interpretation flips, round-trip corruption when re-saved). ISO is the universal answer. Power users who want a sortable native-date column can derive it once in their workbook — but the canonical export stays unambiguous.
|
||||
|
||||
### Q5 — `paliadin_turns` in org export: opt-in only (pick), or include by default?
|
||||
|
||||
**Inventor pick: opt-in via `?include=paliadin_turns` query.**
|
||||
|
||||
Today it's m-only data (`PaliadinOwnerEmail` gate), so the privacy stakes are low — but the *moment* Paliadin opens beyond owner-only, the AI conversation history per user is the most sensitive personal data we carry. Setting the off-by-default precedent now means we don't accidentally start dumping it later.
|
||||
|
||||
### Q6 — Deterministic byte-for-byte exports: yes (pick) or accept timestamp drift in zip metadata?
|
||||
|
||||
**Inventor pick: yes, deterministic.**
|
||||
|
||||
Lets users diff exports across time. Cost: ~50 lines of `sort.Strings` + a custom zip writer with stable ordering. Worth it.
|
||||
|
||||
### Q7 — Invitation tokens in org export: drop them entirely (pick) or include as hash?
|
||||
|
||||
**Inventor pick: drop entirely.**
|
||||
|
||||
Tokens grant signup access. Including them in a backup creates a vulnerability surface — an exfiltrated backup could be used to sign up as someone-else with their pending invite. Hashing doesn't help because the hash is what the URL contains. The invitation **row** (recipient, role, expiry, sent_at) is in the export; the token is not. If you need to re-issue, you do so from paliad's invite UI.
|
||||
|
||||
### Q8 — Public signed-URL downloads (for scheduled/email delivery): yes / not in v1 (pick)?
|
||||
|
||||
**Inventor pick: not in v1.**
|
||||
|
||||
Defer to slice 4. v1's download is cookie-authenticated only. Signed URLs are useful when the recipient is asynchronously notified (email link), which is the scheduled-export model — and that whole subsystem ships later.
|
||||
|
||||
### Q9 — GDPR Art. 15 DSR helper UI: not in v1 (pick)?
|
||||
|
||||
**Inventor pick: not in v1.**
|
||||
|
||||
A global_admin can already assemble a DSR manually using project-scope exports filtered by client. v1 ships the primitives; v2 ships the workflow.
|
||||
|
||||
### Closing question for m: implementer
|
||||
|
||||
> Recommend pattern-fluent Sonnet for all three slices, same hands across (continuity matters for the writer abstraction). Specific name = your call.
|
||||
|
||||
---
|
||||
|
||||
## 12. m's decisions (addendum, 2026-05-19)
|
||||
|
||||
m walked the §11 questions live via AskUserQuestion. Results below — these supersede the inventor picks where they differ.
|
||||
|
||||
- **Q1 — Bundle format:** Bundle xlsx + JSON + CSV in one `.zip` per export. ✓ matches pick.
|
||||
- **Q2 — Project-scope floor:** **Any team member** (`responsibility ∈ {lead, member}`). ⚠ **Deviation** from associate-floor pick — m chose the looser axis-split gate. **Implementation update for §4:** project-scope auth becomes `(a) can_see_project(root_id) AND (b) caller is on project_teams for the root with responsibility ∈ {lead, member}`. The DerivationService profession check is dropped from the export gate; observers + externals + derived-only members still cannot extract. `system_audit_log.metadata` records the responsibility value the caller held at export time.
|
||||
- **Q3 — Org-export retention:** **90 days**. ⚠ **Deviation** from 7-day pick. **Implementation update for §6.2:** `PALIAD_EXPORT_RETENTION_DAYS` default flips from `7` to `90`. The cleanup goroutine still runs daily; the threshold is just longer. Audit row unaffected (still persists forever).
|
||||
- **Q4 — Date format:** ISO 8601 strings only. ✓ matches pick.
|
||||
- **Q5 — paliadin_turns in org export:** **Never include in org export.** ⚠ **Tighter** than opt-in pick. **Implementation update for §2.1 + §6.3:** the `paliadin_turns` row drops from the org-scope sheet table entirely — no `?include=paliadin_turns` query param. Personal scope still carries the caller's own paliadin_turns (it's literally their data). The hard exclusion is enforced in `export_service.go`'s scope-aware sheet registry, not just in column-discovery, so a future schema addition can't accidentally re-include it.
|
||||
- **Q6 — Deterministic exports:** Yes. ✓ matches pick. (m answered freeform "1" alongside the batching request — first option = deterministic.)
|
||||
- **Q7 — Invitation tokens:** Drop entirely. ✓ matches pick.
|
||||
- **Q8 — Signed URLs in v1:** Not in v1. ✓ matches pick.
|
||||
- **Q9 — GDPR DSR helper UI in v1:** Not in v1. ✓ matches pick.
|
||||
|
||||
**Net effect on slice plan:** unchanged shape, three modifications:
|
||||
- Slice 2 gate logic uses `project_teams.responsibility` only (no profession lookup).
|
||||
- Slice 3 default retention is 90 days (one env-var value change).
|
||||
- Slice 1 + 3 sheet registry omits `paliadin_turns` from org scope entirely.
|
||||
|
||||
No other slice deltas. v1 still ships slices 1+2+3.
|
||||
|
||||
**Coder shift gating:** head still gates the implementation handoff; m's decisions here close §11 but don't auto-trigger coder work.
|
||||
|
||||
---
|
||||
|
||||
## 13. Adjacent / out-of-scope
|
||||
|
||||
- **Import path** — explicitly out per brief. A round-trip "export then re-import" is appealing but is its own design (rebinding UUIDs, conflict resolution, schema_version migrations). Don't conflate.
|
||||
- **Postgres replacement** — the Excel workbook is a *backup* + *portability artifact*, not a data-model alternative. Postgres stays canonical.
|
||||
- **t-paliad-212 (leibniz, CalDAV multi-calendar):** personal export already carries the caller's caldav config (minus ciphertext). When leibniz designs multi-calendar, the personal export's `my_caldav_config` sheet becomes a list rather than a single row — handled by column-discovery automatically. No design conflict; flagged for confirmation when leibniz's design lands.
|
||||
- **t-paliad-213 (mendel, test strategy):** export service warrants pure-function tests for column discovery, deny-list, scope predicate, plus one e2e (Playwright) per scope endpoint. Slice tests pin the contract; mendel's overall strategy decides framework choice.
|
||||
|
||||
---
|
||||
|
||||
## 14. References
|
||||
|
||||
- `docs/design-data-model-v2.md` — projects + mandanten + ltree path + can_see_project predicate.
|
||||
- `docs/design-approval-policy-ui-2026-05-07.md` — 5-source audit union (this design adds the 6th source).
|
||||
- `docs/design-profession-vs-project-role-2026-05-07.md` — profession ladder for the §4 project gate.
|
||||
- `internal/handlers/admin_rules.go:303` — `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
|
||||
- `internal/services/project_service.go:15` — visibility predicate.
|
||||
- `internal/services/derivation_service.go` — `EffectiveProjectRole` for the project gate.
|
||||
- `github.com/xuri/excelize/v2` — chosen xlsx library.
|
||||
|
||||
---
|
||||
|
||||
**END OF DESIGN. Status: READY FOR REVIEW.**
|
||||
|
||||
Inventor parks until m's go/no-go on §11. No code touches the tree from this branch.
|
||||
1146
docs/design-submission-generator-2026-05-19.md
Normal file
1146
docs/design-submission-generator-2026-05-19.md
Normal file
File diff suppressed because it is too large
Load Diff
52
docs/t-paliad-207-followup-scope.md
Normal file
52
docs/t-paliad-207-followup-scope.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# t-paliad-207 follow-up scope — close-out assessment
|
||||
|
||||
**Author:** fermi (inventor)
|
||||
**Date:** 2026-05-20
|
||||
**Verdict:** **(A) DONE** — interactive session scope is shipped; remaining tail is filed-or-fileable as discrete issues, not a fresh fermi slice.
|
||||
|
||||
---
|
||||
|
||||
## 0. What shipped under t-paliad-207
|
||||
|
||||
Six substantive deliveries on `mai/fermi/interactive-session`, all merged to main as of 2026-05-20 morning:
|
||||
|
||||
1. **Verfahrensablauf + Fristenrechner polish** — jurisdiction prefix on the picked proceeding, trigger-event label derived from the root rule, flag rows lifted to `/tools/verfahrensablauf`, rule references rendered as `youpc.org/laws#…` links via new `BuildLegalSourceURL`, `Vorab-Einrede → Einspruch` rename (DE i18n).
|
||||
2. **DE proceeding picker — sub-group headers** (`Verletzungsverfahren` / `Nichtigkeitsverfahren`) + parallel labels (`LG (1. Instanz)` / `OLG (Berufung)` / …).
|
||||
3. **mig 099** — drop the `with_po` flag from the two RoP 19 rules (Einspruch is always-available, not flag-gated).
|
||||
4. **mig 100** — `upc.inf.cfi.ccr` visible rule (`Nichtigkeitswiderklage`) so the CCR filing event surfaces when `with_ccr` is set; later corrected to `priority='optional'` via mig 101.
|
||||
5. **mig 101** — strip rule-cite brackets from the two Einspruch names + flip the CCR priority `informational → optional`.
|
||||
6. **mig 102** — track-aware sequence reshuffle on `upc.inf.cfi` so at any tied date the order is infringement (Replik) → revocation (Erwiderung Nichtigkeitswiderklage) → amendment.
|
||||
7. **Notes toggle** — `Hinweise anzeigen` checkbox in the view-toggle bar; compact ⓘ hover hint when off (default), inline `timeline-notes` block when on. `localStorage` shared across both tool pages.
|
||||
|
||||
Filed two follow-up issues during the session:
|
||||
|
||||
- **m/paliad#39** — link DE + EPA + EU rule references to `youpc.org/laws` (depends on youpc.org ingesting the corpus).
|
||||
- **m/paliad#41** — DE proceedings as one combined timeline per type (LG→OLG→BGH, BPatG→BGH) — corpus + spawn + de-duplication + multi-instance UI.
|
||||
|
||||
## 1. Why (A) DONE
|
||||
|
||||
Every concrete thing m surfaced in the session was addressed and merged. The two larger unaddressed asks — combined-timeline behaviour for DE proceedings, and DE/EPA rule-link coverage — are already captured in #39 and #41 with concrete scope notes. Neither belongs as a fermi "next slice" because:
|
||||
|
||||
- **#41** is a corpus + UI design pass of its own (3 new spawn rules, de-duplication of the existing `de.inf.lg.berufung ↔ de.inf.olg.berufung` pair, multi-court picker shape, instance markers in the timeline body). That's its own design ticket, not a fermi follow-up.
|
||||
- **#39** is primarily a youpc.org-side ingest task; the paliad-side change is a 5-line `switch` extension once youpc serves the URLs. Wait for the dependency, then small.
|
||||
|
||||
Everything else I surfaced in the read-only audit is either pre-existing (not introduced by this session) or speculative (no user complaint behind it).
|
||||
|
||||
## 2. Optional tail — would file as discrete issues, not a fermi slice
|
||||
|
||||
Surfacing these for completeness; none are blocking, and most would be small enough to either roll into the existing tickets or land as one-off polish:
|
||||
|
||||
| # | Candidate | Size | Already covered? |
|
||||
|---|---|---|---|
|
||||
| 1 | **`legal_source` backfill on 47 unsourced active rules** — query: 4 of `upc.inf.cfi`, 4 of `upc.pi.cfi` (100% gap), 6 of `upc.rev.cfi`, others. Pre-condition for #39's links to bite. | Medium — corpus research per rule | Partially: huygens did the broader citation backfill in t-paliad-208 / mig 097. This is the remaining tail. |
|
||||
| 2 | **`upc.pi.cfi` corpus completeness audit** — all 4 of its rules lack `legal_source`; likely also missing the analogous track-of-decision spawn rules to `upc.apl.merits`. | Small audit, medium fix | No — would be a fresh task. |
|
||||
| 3 | **Touch-device fallback for the ⓘ hover hint** — `title=` attribute degrades poorly on phones (no hover, no tap-to-show). Either a click-to-popover variant, or accept the gap. | Tiny | No, but no user complaint yet. |
|
||||
| 4 | **R.46 mutatis-mutandis distinction in `upc.rev.cfi.prelim` description** — when mig 101 stripped the `(R. 19 i.V.m. R. 46)` cite, the legal nuance dropped from the user-visible name. Could be surfaced in the description text where it doesn't crowd the timeline cell. | Tiny (one row update) | No. |
|
||||
| 5 | **Save-modal warning on SoD + CCR double-check** — with mig 100's new `upc.inf.cfi.ccr` rule, a user can save both `sod` and `ccr` from the same modal and get two `paliad.deadlines` rows on the same date. Today's pre-uncheck behaviour for optional priority mitigates accidental double-write but doesn't surface the duplication actively. | Small | No. |
|
||||
| 6 | **Deferred slices from earlier design docs that touch this surface**: t-paliad-179 Slice 2-4 (variant chips, lane view, side-by-side compare on `/tools/verfahrensablauf`); t-paliad-169 "+ Eintrag" CTA on the SmartTimeline (project-bound) path. | Each a separate slice. | Yes — parked from their original tasks; would be revisited when m prioritises. |
|
||||
|
||||
None of these warrant a "next fermi slice" right now. They're polish + corpus tail, and best handled as individual issues that m can pick from.
|
||||
|
||||
## 3. Recommendation
|
||||
|
||||
Close t-paliad-207. Fire fermi. The remaining tail (items 1–6 above) is appropriate as a small "polish backlog" m can dip into when relevant, but not a coherent unit of work that needs a parked inventor.
|
||||
126
frontend/public/patentstyle/index.html
Normal file
126
frontend/public/patentstyle/index.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HL Patents Style</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #002236;
|
||||
--fg: #e8e8ed;
|
||||
--muted: #8a9aa6;
|
||||
--accent: #bff355;
|
||||
--rule: #0f3a55;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif;
|
||||
line-height: 1.55;
|
||||
font-size: 17px;
|
||||
}
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem 6rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
margin: 0 0 0.25rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h1 .accent { color: var(--accent); }
|
||||
.lead {
|
||||
color: var(--muted);
|
||||
margin: 0 0 3rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
margin: 2.5rem 0 0.75rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
ul { padding-left: 1.25rem; margin: 0.5rem 0 1rem; }
|
||||
li { margin: 0.35rem 0; }
|
||||
p { margin: 0.6rem 0; }
|
||||
a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; }
|
||||
a:hover { border-bottom-color: var(--accent); }
|
||||
code, kbd {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
background: #0a2d44;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.download {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
border: 0;
|
||||
}
|
||||
.download:hover { border-bottom: 0; filter: brightness(1.05); }
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
footer code { color: var(--muted); background: transparent; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<h1>HL <span class="accent">Patents Style</span></h1>
|
||||
<p class="lead">Das Word-Template fuer Patentschriftsaetze bei Hogan Lovells.</p>
|
||||
|
||||
<h2>Was es kann</h2>
|
||||
<ul>
|
||||
<li>Vorlagen-Stile fuer alle gaengigen Schriftsatz-Bausteine (Headings, Randnummern, Antraege, Exhibits)</li>
|
||||
<li>BuildingBlocks: ueber das Ribbon vorgefertigte Abschnitte einfuegen</li>
|
||||
<li>Sprachumschaltung DE / EN per Ribbon-Toggle</li>
|
||||
<li>Scaffolding: kompletter Schriftsatz-Aufbau mit einem Klick</li>
|
||||
<li>Margin Numbers, Exhibit-Nummerierung, SEQ-Felder</li>
|
||||
<li>Auto-Update ueber das Ribbon (siehe unten)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Aktualisierungen</h2>
|
||||
<p>Im Ribbon-Tab <em>HL Patent</em> → Gruppe <em>Manage</em> → <kbd>Check for Updates</kbd>. Holt das aktuelle Manifest von diesem Server, prueft die Version, laedt die neue <code>.dotm</code> nur bei Bedarf, verifiziert per SHA256, installiert. Nach dem Update Word neu starten.</p>
|
||||
|
||||
<h2>Frische Installation</h2>
|
||||
<p>Wer das Template noch nicht installiert hat, laedt einmal manuell die aktuelle Version und kopiert sie in den Word-Startup-Ordner. Den Rest macht die <code>InstallTemplate</code>-Routine im Template selbst.</p>
|
||||
<p><a class="download" href="HL-Patents-Style.dotm" download>HL Patents Style.dotm herunterladen</a></p>
|
||||
|
||||
<h2>Hilfe & Feedback</h2>
|
||||
<p>Fehler, Wuensche, Stilfragen, Build-Probleme: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HL%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
|
||||
|
||||
<footer>
|
||||
<p>Update-Endpoint: <code>paliad.msbls.de/patentstyle/</code> · Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
|
||||
<p id="ver"></p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Best-effort: show the currently-served version
|
||||
fetch('version.json', { cache: 'no-cache' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(j => {
|
||||
if (j && j.version) {
|
||||
document.getElementById('ver').textContent = 'Aktuell ausgeliefert: ' + j.version;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
</script>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
255
frontend/src/client/components/approval-edit-modal.ts
Normal file
255
frontend/src/client/components/approval-edit-modal.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
// t-paliad-216 Slice B — modal for the "Suggest changes" approval action.
|
||||
//
|
||||
// The approver authors a counter-proposal: edits any of the date-allowlist
|
||||
// fields (per entity_type) AND/OR leaves a free-text note. On submit the
|
||||
// caller POSTs to /api/approval-requests/{id}/suggest-changes, which closes
|
||||
// the OLD row as `changes_requested` and spawns a NEW pending row authored
|
||||
// by the approver carrying counter_payload as its payload.
|
||||
//
|
||||
// Scope (v1):
|
||||
// - update-lifecycle only — the suggest_changes button is hidden for
|
||||
// create / complete / delete lifecycles in shape-list.ts, so the modal
|
||||
// never opens on them. If callers somehow trigger it on an unsupported
|
||||
// lifecycle, openApprovalEditModal() resolves with null (cancel) after
|
||||
// surfacing the unsupported-lifecycle copy.
|
||||
// - Hard-coded fields per entity_type. We deliberately don't build a
|
||||
// generic field-editor framework — only two entity_types exist and
|
||||
// both have small fixed allowlists.
|
||||
//
|
||||
// API:
|
||||
// const result = await openApprovalEditModal({
|
||||
// entityType: "deadline",
|
||||
// lifecycleEvent: "update",
|
||||
// payload: {...}, // requester's original proposed values
|
||||
// preImage: {...}, // pre-mutation values (for diff display)
|
||||
// });
|
||||
// if (result) {
|
||||
// // result.counterPayload + result.note ready to POST
|
||||
// } else {
|
||||
// // user cancelled
|
||||
// }
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
export interface ApprovalEditModalArgs {
|
||||
entityType: "deadline" | "appointment";
|
||||
lifecycleEvent: string;
|
||||
payload: Record<string, unknown> | null;
|
||||
preImage: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ApprovalEditModalResult {
|
||||
counterPayload: Record<string, unknown>;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// Per-entity-type editable field allowlist. Matches buildRevertSetClauses
|
||||
// in internal/services/approval_service.go — the server side rejects any
|
||||
// key outside this set anyway. Keeping the UI list in sync is a
|
||||
// safety-vs-confusion trade-off: a stray key here would be silently
|
||||
// dropped server-side, so it's harmless but misleading.
|
||||
const DEADLINE_FIELDS: ReadonlyArray<{ key: string; type: "date" }> = [
|
||||
{ key: "due_date", type: "date" },
|
||||
{ key: "original_due_date", type: "date" },
|
||||
{ key: "warning_date", type: "date" },
|
||||
];
|
||||
|
||||
const APPOINTMENT_FIELDS: ReadonlyArray<{ key: string; type: "datetime-local" }> = [
|
||||
{ key: "start_at", type: "datetime-local" },
|
||||
{ key: "end_at", type: "datetime-local" },
|
||||
];
|
||||
|
||||
export function openApprovalEditModal(
|
||||
args: ApprovalEditModalArgs,
|
||||
): Promise<ApprovalEditModalResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (args.lifecycleEvent !== "update") {
|
||||
// Defence-in-depth: shape-list.ts hides the button for non-update
|
||||
// lifecycles, but if some caller bypasses that gate, fail cleanly.
|
||||
window.alert(t("approvals.suggest.unsupported_lifecycle"));
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById("approval-edit-modal")?.remove();
|
||||
|
||||
const fields = args.entityType === "deadline" ? DEADLINE_FIELDS : APPOINTMENT_FIELDS;
|
||||
const original = (args.payload ?? {}) as Record<string, unknown>;
|
||||
const preImage = (args.preImage ?? {}) as Record<string, unknown>;
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "approval-edit-modal";
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = renderShell(args, fields, original, preImage);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const close = (result: ApprovalEditModalResult | null) => {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", onKey);
|
||||
resolve(result);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close(null);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
overlay.querySelectorAll("[data-suggest-cancel]").forEach((el) =>
|
||||
el.addEventListener("click", () => close(null)),
|
||||
);
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) close(null);
|
||||
});
|
||||
|
||||
const submitBtn = overlay.querySelector<HTMLButtonElement>("[data-suggest-submit]");
|
||||
const noteEl = overlay.querySelector<HTMLTextAreaElement>("[data-suggest-note]");
|
||||
const inputs = Array.from(
|
||||
overlay.querySelectorAll<HTMLInputElement>("[data-suggest-field]"),
|
||||
);
|
||||
|
||||
const refreshSubmit = () => {
|
||||
if (!submitBtn) return;
|
||||
const dirty = inputs.some((el) => {
|
||||
const orig = formatFieldForInput(original[el.dataset.suggestField || ""]);
|
||||
return el.value !== orig;
|
||||
});
|
||||
const hasNote = !!(noteEl && noteEl.value.trim());
|
||||
submitBtn.disabled = !(dirty || hasNote);
|
||||
submitBtn.title = submitBtn.disabled
|
||||
? t("approvals.suggest.submit_disabled_hint")
|
||||
: "";
|
||||
};
|
||||
inputs.forEach((el) => el.addEventListener("input", refreshSubmit));
|
||||
noteEl?.addEventListener("input", refreshSubmit);
|
||||
refreshSubmit();
|
||||
|
||||
const form = overlay.querySelector<HTMLFormElement>("[data-suggest-form]");
|
||||
form?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
if (submitBtn?.disabled) return;
|
||||
// Build counter_payload from inputs that differ from original.
|
||||
// Fields unchanged stay out of the payload — the server's
|
||||
// buildRevertSetClauses only writes the keys it sees, so we don't
|
||||
// need to send untouched fields.
|
||||
const counterPayload: Record<string, unknown> = {};
|
||||
for (const el of inputs) {
|
||||
const key = el.dataset.suggestField || "";
|
||||
const orig = formatFieldForInput(original[key]);
|
||||
if (el.value !== orig) {
|
||||
counterPayload[key] = formatFieldForServer(el.value, el.type);
|
||||
}
|
||||
}
|
||||
close({
|
||||
counterPayload,
|
||||
note: (noteEl?.value ?? "").trim(),
|
||||
});
|
||||
});
|
||||
|
||||
// Focus first input (or note if no fields).
|
||||
(inputs[0] ?? noteEl)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(
|
||||
args: ApprovalEditModalArgs,
|
||||
fields: ReadonlyArray<{ key: string; type: string }>,
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): string {
|
||||
const entityLabel = esc(t(("approvals.entity." + args.entityType) as never));
|
||||
const fieldRows = fields
|
||||
.map((f) => {
|
||||
const label = fieldLabel(args.entityType, f.key);
|
||||
const value = esc(formatFieldForInput(original[f.key]));
|
||||
const preVal = formatFieldForInput(preImage[f.key]);
|
||||
const preHint = preVal
|
||||
? `<span class="suggest-field-prehint">${esc(t("approvals.diff.before"))}: ${esc(preVal)}</span>`
|
||||
: "";
|
||||
return `
|
||||
<label class="suggest-field">
|
||||
<span class="suggest-field-label">${esc(label)}</span>
|
||||
<input type="${esc(f.type)}" data-suggest-field="${esc(f.key)}" value="${value}" />
|
||||
${preHint}
|
||||
</label>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="modal modal-approval-suggest" role="dialog" aria-modal="true" aria-labelledby="approval-suggest-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="approval-suggest-title">${esc(t("approvals.suggest.modal_title"))} — ${entityLabel}</h2>
|
||||
<button type="button" class="modal-close" data-suggest-cancel aria-label="${esc(t("approvals.suggest.cancel"))}">×</button>
|
||||
</header>
|
||||
<form data-suggest-form>
|
||||
<div class="modal-body">
|
||||
<p class="suggest-intro muted">${esc(t("approvals.suggest.intro"))}</p>
|
||||
<div class="suggest-fields">${fieldRows}</div>
|
||||
<label class="suggest-note">
|
||||
<span class="suggest-field-label">${esc(t("approvals.suggest.note_label"))}</span>
|
||||
<textarea data-suggest-note rows="3" placeholder="${esc(t("approvals.suggest.note_placeholder"))}"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-suggest-cancel>${esc(t("approvals.suggest.cancel"))}</button>
|
||||
<button type="submit" class="btn btn-primary" data-suggest-submit disabled>${esc(t("approvals.suggest.submit"))}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// fieldLabel — pick the user-facing label for a given (entity_type, key)
|
||||
// tuple. Reuses existing entity-field i18n where it exists so the same
|
||||
// label that's used on the deadline / appointment edit forms also shows
|
||||
// in this modal.
|
||||
function fieldLabel(entityType: string, key: string): string {
|
||||
const lookups: Record<string, string> = {
|
||||
"deadline.due_date": t("deadlines.field.due" as never) || "Fälligkeitsdatum",
|
||||
"deadline.original_due_date": "Ursprüngliches Fälligkeitsdatum",
|
||||
"deadline.warning_date": "Warndatum",
|
||||
"appointment.start_at": t("appointments.field.start" as never) || "Beginn",
|
||||
"appointment.end_at": t("appointments.field.end" as never) || "Ende",
|
||||
};
|
||||
return lookups[`${entityType}.${key}`] || key;
|
||||
}
|
||||
|
||||
// formatFieldForInput — convert a server-side payload value to the format
|
||||
// the <input> wants. Dates round-trip cleanly as YYYY-MM-DD; datetime-local
|
||||
// wants YYYY-MM-DDTHH:MM. Server returns ISO 8601 / RFC 3339 timestamps,
|
||||
// we trim to the local-input shape.
|
||||
function formatFieldForInput(v: unknown): string {
|
||||
if (v == null) return "";
|
||||
const s = String(v);
|
||||
// Pure date: keep first 10 chars.
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||
// ISO timestamp: keep YYYY-MM-DDTHH:MM (drop seconds + tz).
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/);
|
||||
if (m) return `${m[1]}T${m[2]}`;
|
||||
return s;
|
||||
}
|
||||
|
||||
// formatFieldForServer — convert the input element's string value back to
|
||||
// a server-friendly shape. Date inputs send YYYY-MM-DD; datetime-local
|
||||
// sends YYYY-MM-DDTHH:MM (we let the server interpret as local time, same
|
||||
// as the existing entity-edit forms — there's no tz-shift specific to
|
||||
// suggest-changes).
|
||||
function formatFieldForServer(value: string, inputType: string): unknown {
|
||||
if (!value) return null;
|
||||
if (inputType === "date") return value; // YYYY-MM-DD
|
||||
if (inputType === "datetime-local") return value; // YYYY-MM-DDTHH:MM
|
||||
return value;
|
||||
}
|
||||
|
||||
// HTML-escape helper. Local to this module so the modal doesn't bring in a
|
||||
// utility from elsewhere.
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[&<>"]/g, (c) => {
|
||||
switch (c) {
|
||||
case "&": return "&";
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
case '"': return """;
|
||||
default: return c;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -162,10 +162,11 @@ function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
{ value: "changes_requested", key: "views.bar.approval_status.changes_requested" },
|
||||
];
|
||||
|
||||
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
|
||||
@@ -57,6 +57,19 @@ type ProcedureView = "timeline" | "columns";
|
||||
// HLC team than the single vertical line.
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// Notes toggle — off by default; per-rule notes render as a compact
|
||||
// ⓘ hover icon. Flipped on, they expand under each card. Choice is
|
||||
// localStorage-persisted (paliad.fristen.notes-show key shared with
|
||||
// /tools/verfahrensablauf so the preference carries across both).
|
||||
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
|
||||
function readNotesPref(): boolean {
|
||||
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
function writeNotesPref(on: boolean): void {
|
||||
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
||||
}
|
||||
let showNotes = readNotesPref();
|
||||
|
||||
onLangChange(() => {
|
||||
if (lastResponse) renderProcedureResults(lastResponse);
|
||||
// Update trigger event name if a proceeding is selected
|
||||
@@ -391,8 +404,8 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true });
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
printBtn.style.display = "block";
|
||||
@@ -661,6 +674,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const saveBtn = document.getElementById("fristen-save-cta");
|
||||
if (saveBtn) saveBtn.addEventListener("click", openSaveModal);
|
||||
|
||||
// Notes toggle — restores last preference on load + re-renders when
|
||||
// the user flips it. Lives in the same toggle bar as the view picker.
|
||||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||||
if (notesShowCb) {
|
||||
notesShowCb.checked = showNotes;
|
||||
notesShowCb.addEventListener("change", () => {
|
||||
showNotes = notesShowCb.checked;
|
||||
writeNotesPref(showNotes);
|
||||
if (lastResponse) renderProcedureResults(lastResponse);
|
||||
});
|
||||
}
|
||||
|
||||
// View toggle (timeline vs. columns layout) for procedure mode.
|
||||
initViewToggle();
|
||||
|
||||
|
||||
@@ -300,6 +300,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.label": "Ansicht:",
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
"deadlines.view.columns": "Spalten",
|
||||
"deadlines.notes.show": "Hinweise anzeigen",
|
||||
"deadlines.col.proactive": "Proaktiv",
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.reactive": "Reaktiv",
|
||||
@@ -971,18 +972,22 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.deadline_approval_approved": "Genehmigung erteilt",
|
||||
"event.title.deadline_approval_rejected": "Genehmigung abgelehnt",
|
||||
"event.title.deadline_approval_revoked": "Anfrage zurückgezogen",
|
||||
"event.title.deadline_approval_changes_suggested": "Änderungen vorgeschlagen",
|
||||
"event.title.appointment_approval_requested": "Genehmigung beantragt",
|
||||
"event.title.appointment_approval_approved": "Genehmigung erteilt",
|
||||
"event.title.appointment_approval_rejected": "Genehmigung abgelehnt",
|
||||
"event.title.appointment_approval_revoked": "Anfrage zurückgezogen",
|
||||
"event.title.appointment_approval_changes_suggested": "Änderungen vorgeschlagen",
|
||||
"event.description.deadline_approval_requested": "4-Augen-Genehmigung für Frist beantragt",
|
||||
"event.description.deadline_approval_approved": "Genehmigung für Frist erteilt",
|
||||
"event.description.deadline_approval_rejected": "Genehmigung für Frist abgelehnt",
|
||||
"event.description.deadline_approval_revoked": "Genehmigungsanfrage für Frist zurückgezogen",
|
||||
"event.description.deadline_approval_changes_suggested": "Frist abgelehnt mit Gegenvorschlag",
|
||||
"event.description.appointment_approval_requested": "4-Augen-Genehmigung für Termin beantragt",
|
||||
"event.description.appointment_approval_approved": "Genehmigung für Termin erteilt",
|
||||
"event.description.appointment_approval_rejected": "Genehmigung für Termin abgelehnt",
|
||||
"event.description.appointment_approval_revoked": "Genehmigungsanfrage für Termin zurückgezogen",
|
||||
"event.description.appointment_approval_changes_suggested": "Termin abgelehnt mit Gegenvorschlag",
|
||||
"dashboard.action.short.deadline_approval_requested": "beantragte Genehmigung",
|
||||
"dashboard.action.short.deadline_approval_approved": "genehmigte Frist",
|
||||
"dashboard.action.short.deadline_approval_rejected": "lehnte Frist ab",
|
||||
@@ -1126,6 +1131,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.",
|
||||
@@ -1245,6 +1261,16 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.termine": "Termine",
|
||||
"projects.detail.tab.notizen": "Notizen",
|
||||
"projects.detail.tab.checklisten": "Checklisten",
|
||||
"projects.detail.tab.submissions": "Schriftsätze",
|
||||
"projects.detail.submissions.empty": "Für dieses Verfahren sind keine Schriftsätze hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Bitte zuerst einen Verfahrenstyp setzen.",
|
||||
"projects.detail.submissions.col.name": "Schriftsatz",
|
||||
"projects.detail.submissions.col.party": "Partei",
|
||||
"projects.detail.submissions.col.source": "Rechtsgrundlage",
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generieren",
|
||||
"projects.detail.submissions.action.no_template": "Keine Vorlage",
|
||||
"projects.detail.submissions.hint": "Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.",
|
||||
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
|
||||
"projects.detail.verlauf.loadMore": "Mehr laden",
|
||||
// SmartTimeline (t-paliad-171, Slice 1).
|
||||
@@ -2203,10 +2229,21 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.status.rejected": "Abgelehnt",
|
||||
"approvals.status.revoked": "Zurückgezogen",
|
||||
"approvals.status.superseded": "Ersetzt",
|
||||
"approvals.status.changes_requested": "Abgelehnt mit Vorschlag",
|
||||
"approvals.action.approve": "Genehmigen",
|
||||
"approvals.action.reject": "Ablehnen",
|
||||
"approvals.action.revoke": "Zurückziehen",
|
||||
"approvals.action.suggest_changes": "Änderungen vorschlagen",
|
||||
"approvals.note.placeholder": "Optionale Begründung...",
|
||||
"approvals.suggest.modal_title": "Änderungen vorschlagen",
|
||||
"approvals.suggest.intro": "Bearbeite die vorgeschlagenen Werte und/oder hinterlasse einen Kommentar. Dein Vorschlag wird als neue Genehmigungsanfrage eingestellt und kann vom ursprünglichen Antragsteller (oder einer anderen berechtigten Person) genehmigt werden.",
|
||||
"approvals.suggest.note_label": "Kommentar zum Vorschlag",
|
||||
"approvals.suggest.note_placeholder": "Warum sollen die Werte angepasst werden?",
|
||||
"approvals.suggest.submit": "Vorschlag einreichen",
|
||||
"approvals.suggest.cancel": "Abbrechen",
|
||||
"approvals.suggest.submit_disabled_hint": "Bitte mindestens ein Feld ändern oder einen Kommentar hinterlassen.",
|
||||
"approvals.suggest.next_request_link": "→ Neuer Vorschlag von {name}",
|
||||
"approvals.suggest.unsupported_lifecycle": "Änderungen vorschlagen ist nur für Update-Anfragen möglich.",
|
||||
"approvals.requested_by": "Eingereicht von",
|
||||
"approvals.decided_by": "Entschieden von",
|
||||
"approvals.decision_kind.peer": "Genehmigt durch Teammitglied",
|
||||
@@ -2218,9 +2255,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.",
|
||||
"approvals.error.awaiting_approval": "Diese Anforderung wartet auf Genehmigung.",
|
||||
"approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.",
|
||||
"approvals.error.suggestion_requires_change": "Ein Vorschlag braucht entweder geänderte Werte oder einen Kommentar.",
|
||||
"approvals.error.suggestion_lifecycle_invalid": "Änderungen vorschlagen ist nur für Update-Anfragen möglich.",
|
||||
"approvals.disabled.self_approval": "Du kannst eigene Anträge nicht genehmigen",
|
||||
"approvals.disabled.not_authorized": "Du hast keine Genehmigungsberechtigung für diesen Antrag",
|
||||
"approvals.disabled.revoke_not_requester": "Nur der Antragsteller kann zurückziehen",
|
||||
"approvals.disabled.suggest_lifecycle": "Änderungen vorschlagen ist nur für Update-Anfragen möglich",
|
||||
"approvals.pending.badge": "Wartet auf Genehmigung",
|
||||
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
|
||||
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
|
||||
@@ -2388,6 +2428,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.approval_status.approved": "Genehmigt",
|
||||
"views.bar.approval_status.rejected": "Abgelehnt",
|
||||
"views.bar.approval_status.revoked": "Zurückgezogen",
|
||||
"views.bar.approval_status.changes_requested": "Mit Vorschlag",
|
||||
"views.bar.approval_entity.deadline": "Frist",
|
||||
"views.bar.approval_entity.appointment": "Termin",
|
||||
"views.bar.deadline_status.pending": "Offen",
|
||||
@@ -2888,6 +2929,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.label": "View:",
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
"deadlines.view.columns": "Columns",
|
||||
"deadlines.notes.show": "Show details",
|
||||
"deadlines.col.proactive": "Proactive",
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.reactive": "Reactive",
|
||||
@@ -3547,18 +3589,22 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.deadline_approval_approved": "Approval granted",
|
||||
"event.title.deadline_approval_rejected": "Approval rejected",
|
||||
"event.title.deadline_approval_revoked": "Request revoked",
|
||||
"event.title.deadline_approval_changes_suggested": "Changes suggested",
|
||||
"event.title.appointment_approval_requested": "Approval requested",
|
||||
"event.title.appointment_approval_approved": "Approval granted",
|
||||
"event.title.appointment_approval_rejected": "Approval rejected",
|
||||
"event.title.appointment_approval_revoked": "Request revoked",
|
||||
"event.title.appointment_approval_changes_suggested": "Changes suggested",
|
||||
"event.description.deadline_approval_requested": "Four-eyes approval requested for deadline",
|
||||
"event.description.deadline_approval_approved": "Deadline approval granted",
|
||||
"event.description.deadline_approval_rejected": "Deadline approval rejected",
|
||||
"event.description.deadline_approval_revoked": "Deadline approval request revoked",
|
||||
"event.description.deadline_approval_changes_suggested": "Deadline declined with a counter-proposal",
|
||||
"event.description.appointment_approval_requested": "Four-eyes approval requested for appointment",
|
||||
"event.description.appointment_approval_approved": "Appointment approval granted",
|
||||
"event.description.appointment_approval_rejected": "Appointment approval rejected",
|
||||
"event.description.appointment_approval_revoked": "Appointment approval request revoked",
|
||||
"event.description.appointment_approval_changes_suggested": "Appointment declined with a counter-proposal",
|
||||
"dashboard.action.short.deadline_approval_requested": "requested approval",
|
||||
"dashboard.action.short.deadline_approval_approved": "approved deadline",
|
||||
"dashboard.action.short.deadline_approval_rejected": "rejected deadline",
|
||||
@@ -3702,6 +3748,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.",
|
||||
@@ -3821,6 +3878,16 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.termine": "Appointments",
|
||||
"projects.detail.tab.notizen": "Notes",
|
||||
"projects.detail.tab.checklisten": "Checklists",
|
||||
"projects.detail.tab.submissions": "Submissions",
|
||||
"projects.detail.submissions.empty": "No submissions are configured for this proceeding.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Please set a proceeding type first.",
|
||||
"projects.detail.submissions.col.name": "Submission",
|
||||
"projects.detail.submissions.col.party": "Party",
|
||||
"projects.detail.submissions.col.source": "Legal basis",
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generate",
|
||||
"projects.detail.submissions.action.no_template": "No template",
|
||||
"projects.detail.submissions.hint": "Submissions are generated as .docx directly from the project. Edit, print, file.",
|
||||
"projects.detail.verlauf.empty": "No events recorded yet.",
|
||||
"projects.detail.verlauf.loadMore": "Load more",
|
||||
"projects.detail.smarttimeline.empty": "No events captured yet.",
|
||||
@@ -4775,10 +4842,21 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.status.rejected": "Rejected",
|
||||
"approvals.status.revoked": "Revoked",
|
||||
"approvals.status.superseded": "Superseded",
|
||||
"approvals.status.changes_requested": "Declined with changes",
|
||||
"approvals.action.approve": "Approve",
|
||||
"approvals.action.reject": "Reject",
|
||||
"approvals.action.revoke": "Revoke",
|
||||
"approvals.action.suggest_changes": "Suggest changes",
|
||||
"approvals.note.placeholder": "Optional note...",
|
||||
"approvals.suggest.modal_title": "Suggest changes",
|
||||
"approvals.suggest.intro": "Edit the proposed values and/or leave a note. Your suggestion will be filed as a new approval request and may be approved by the original requester (or anyone else eligible).",
|
||||
"approvals.suggest.note_label": "Note about your suggestion",
|
||||
"approvals.suggest.note_placeholder": "Why should these values change?",
|
||||
"approvals.suggest.submit": "Submit suggestion",
|
||||
"approvals.suggest.cancel": "Cancel",
|
||||
"approvals.suggest.submit_disabled_hint": "Change at least one field or leave a note.",
|
||||
"approvals.suggest.next_request_link": "→ New suggestion by {name}",
|
||||
"approvals.suggest.unsupported_lifecycle": "Suggest changes is only available for update requests.",
|
||||
"approvals.requested_by": "Submitted by",
|
||||
"approvals.decided_by": "Decided by",
|
||||
"approvals.decision_kind.peer": "Peer approval",
|
||||
@@ -4790,9 +4868,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.",
|
||||
"approvals.error.awaiting_approval": "This entity is awaiting approval.",
|
||||
"approvals.error.request_not_pending": "This request is no longer open.",
|
||||
"approvals.error.suggestion_requires_change": "A suggestion needs either changed values or a note.",
|
||||
"approvals.error.suggestion_lifecycle_invalid": "Suggest changes is only available for update requests.",
|
||||
"approvals.disabled.self_approval": "You cannot approve your own requests",
|
||||
"approvals.disabled.not_authorized": "You are not authorized to approve this request",
|
||||
"approvals.disabled.revoke_not_requester": "Only the requester can withdraw",
|
||||
"approvals.disabled.suggest_lifecycle": "Suggest changes is only available for update requests",
|
||||
"approvals.pending.badge": "Awaiting approval",
|
||||
"approvals.withdraw.cta": "Withdraw approval request",
|
||||
"approvals.withdraw.confirm": "Withdraw the approval request?",
|
||||
@@ -4959,6 +5040,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.approval_status.approved": "Approved",
|
||||
"views.bar.approval_status.rejected": "Rejected",
|
||||
"views.bar.approval_status.revoked": "Revoked",
|
||||
"views.bar.approval_status.changes_requested": "With suggestion",
|
||||
"views.bar.approval_entity.deadline": "Deadline",
|
||||
"views.bar.approval_entity.appointment": "Appointment",
|
||||
"views.bar.deadline_status.pending": "Open",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { AxisKey } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
import { openApprovalEditModal } from "./components/approval-edit-modal";
|
||||
|
||||
// /inbox client — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
@@ -123,11 +124,20 @@ function paint(
|
||||
|
||||
function wireApprovalActions(host: HTMLElement): void {
|
||||
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
||||
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
|
||||
const action = btn.dataset.action as
|
||||
| "approve"
|
||||
| "reject"
|
||||
| "revoke"
|
||||
| "suggest_changes"
|
||||
| undefined;
|
||||
const li = btn.closest<HTMLLIElement>(".views-approval-row");
|
||||
const id = li?.dataset.requestId;
|
||||
if (!action || !id) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (action === "suggest_changes") {
|
||||
await handleSuggestChanges(btn, id, li!);
|
||||
return;
|
||||
}
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
@@ -141,8 +151,8 @@ function wireApprovalActions(host: HTMLElement): void {
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
alert(mapApprovalError(body.error || "internal"));
|
||||
const body = await r.json().catch(() => ({} as { error?: string; code?: string }));
|
||||
alert(mapApprovalError(body.code || body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
@@ -156,14 +166,97 @@ function wireApprovalActions(host: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// handleSuggestChanges — t-paliad-216. Open the edit modal with the
|
||||
// requester's original payload + pre_image pre-populated. If the user
|
||||
// submits non-empty changes / note, POST to
|
||||
// /api/approval-requests/{id}/suggest-changes; refresh the bar on success
|
||||
// so the OLD row flips to changes_requested and the NEW pending row
|
||||
// appears.
|
||||
async function handleSuggestChanges(
|
||||
btn: HTMLButtonElement,
|
||||
requestID: string,
|
||||
li: HTMLLIElement,
|
||||
): Promise<void> {
|
||||
// Read the row's detail blob off the data-attrs the shape-list stamped.
|
||||
// shape-list serialises payload/pre_image inline; we fetch fresh via
|
||||
// the per-row API to avoid relying on stale list data.
|
||||
let payload: Record<string, unknown> | null = null;
|
||||
let preImage: Record<string, unknown> | null = null;
|
||||
let entityType: "deadline" | "appointment" = "deadline";
|
||||
let lifecycleEvent = "update";
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${requestID}`, { credentials: "include" });
|
||||
if (r.ok) {
|
||||
const body = (await r.json()) as {
|
||||
entity_type?: "deadline" | "appointment";
|
||||
lifecycle_event?: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
};
|
||||
payload = body.payload ?? null;
|
||||
preImage = body.pre_image ?? null;
|
||||
if (body.entity_type === "appointment") entityType = "appointment";
|
||||
if (body.lifecycle_event) lifecycleEvent = body.lifecycle_event;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Modal still opens with empty defaults if the fetch fails; the
|
||||
// server-side schema validation catches a misshapen counter.
|
||||
}
|
||||
|
||||
const result = await openApprovalEditModal({
|
||||
entityType,
|
||||
lifecycleEvent,
|
||||
payload,
|
||||
preImage,
|
||||
});
|
||||
if (!result) return; // cancel
|
||||
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${requestID}/suggest-changes`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
counter_payload: result.counterPayload,
|
||||
note: result.note,
|
||||
}),
|
||||
});
|
||||
const body = (await r.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
new_request_id?: string;
|
||||
};
|
||||
if (!r.ok) {
|
||||
alert(mapApprovalError(body.code || body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await bar?.refresh();
|
||||
await refreshInboxBadge();
|
||||
btn.disabled = false;
|
||||
|
||||
// Surface the new row's id on the OLD row's <li> so callers (e.g.
|
||||
// tests, future inspection) can find it without re-querying.
|
||||
if (body.new_request_id) {
|
||||
li.dataset.spawnedRequestId = body.new_request_id;
|
||||
}
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked": return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending": return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized": return t("approvals.error.not_authorized");
|
||||
case "request_not_pending": return t("approvals.error.request_not_pending");
|
||||
default: return key;
|
||||
case "self_approval_blocked": return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending": return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized": return t("approvals.error.not_authorized");
|
||||
case "request_not_pending": return t("approvals.error.request_not_pending");
|
||||
case "suggestion_requires_change": return t("approvals.error.suggestion_requires_change");
|
||||
case "suggestion_lifecycle_invalid": return t("approvals.error.suggestion_lifecycle_invalid");
|
||||
default: return key;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec } from "./views/types";
|
||||
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
|
||||
import { loadAndRenderSubmissions } from "./submissions";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -158,7 +159,8 @@ type TabId =
|
||||
| "deadlines"
|
||||
| "appointments"
|
||||
| "notes"
|
||||
| "checklists";
|
||||
| "checklists"
|
||||
| "submissions";
|
||||
|
||||
const VALID_TABS: TabId[] = [
|
||||
"history",
|
||||
@@ -169,6 +171,7 @@ const VALID_TABS: TabId[] = [
|
||||
"appointments",
|
||||
"notes",
|
||||
"checklists",
|
||||
"submissions",
|
||||
];
|
||||
|
||||
// Legacy German tab slugs that may appear in bookmarked URLs after the
|
||||
@@ -1610,6 +1613,9 @@ function showTab(tab: TabId) {
|
||||
if (tab === "checklists" && project) {
|
||||
void loadAndRenderChecklistInstances(project.id);
|
||||
}
|
||||
if (tab === "submissions" && project) {
|
||||
void loadAndRenderSubmissions(project.id);
|
||||
}
|
||||
}
|
||||
|
||||
let checklistInstancesInited = false;
|
||||
|
||||
@@ -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();
|
||||
|
||||
208
frontend/src/client/submissions.ts
Normal file
208
frontend/src/client/submissions.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// Submissions panel — fetches the project's submission catalog and
|
||||
// renders one row per filing-type rule, with a [Generieren] action
|
||||
// when a .docx template resolves server-side.
|
||||
//
|
||||
// t-paliad-215 Slice 1. Loaded lazily by the projects-detail tab
|
||||
// switcher so projects without the Schriftsätze tab open don't pay
|
||||
// for the per-row template-availability probes.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
interface SubmissionEntry {
|
||||
submission_code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
event_type?: string;
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
}
|
||||
|
||||
interface SubmissionListResponse {
|
||||
project_id: string;
|
||||
proceeding_type_id?: number;
|
||||
entries: SubmissionEntry[];
|
||||
}
|
||||
|
||||
// Module state — set once per page load when the user first opens the
|
||||
// tab. Subsequent activations re-use the cached result so the lawyer
|
||||
// doesn't pay for repeat list calls flipping between tabs.
|
||||
let cached: { projectID: string; data: SubmissionListResponse } | null = null;
|
||||
let loading = false;
|
||||
|
||||
/**
|
||||
* Load + render the submissions panel for the given project.
|
||||
*
|
||||
* Idempotent: safe to call on every tab activation. The second call
|
||||
* paints from cache instantly; the first call shows a loading state
|
||||
* until the list response arrives.
|
||||
*/
|
||||
export async function loadAndRenderSubmissions(projectID: string): Promise<void> {
|
||||
if (loading) return;
|
||||
if (cached && cached.projectID === projectID) {
|
||||
render(cached.data);
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${projectID}/submissions`);
|
||||
if (!resp.ok) {
|
||||
renderError();
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as SubmissionListResponse;
|
||||
cached = { projectID, data };
|
||||
render(data);
|
||||
} catch {
|
||||
renderError();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function render(data: SubmissionListResponse): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
const wrap = document.getElementById("project-submissions-tablewrap");
|
||||
const body = document.getElementById("project-submissions-body");
|
||||
if (!empty || !noProc || !wrap || !body) return;
|
||||
|
||||
if (data.proceeding_type_id == null || data.proceeding_type_id === 0) {
|
||||
noProc.style.display = "";
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
noProc.style.display = "none";
|
||||
if (data.entries.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
|
||||
const isEN = document.documentElement.lang === "en";
|
||||
body.innerHTML = data.entries.map((entry) => {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const action = entry.has_template
|
||||
? `<button type="button" class="btn-primary btn-cta-lime btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(data.project_id)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`
|
||||
: `<span class="submission-no-template" data-i18n="projects.detail.submissions.action.no_template">${isEN ? "No template" : "Keine Vorlage"}</span>`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
// Wire button clicks. One click handler per render to avoid stale
|
||||
// closures from the previous render's data.
|
||||
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onGenerateClick(btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderError(): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
const wrap = document.getElementById("project-submissions-tablewrap");
|
||||
if (!empty || !noProc || !wrap) return;
|
||||
noProc.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "";
|
||||
empty.textContent = document.documentElement.lang === "en"
|
||||
? "Failed to load submissions list."
|
||||
: "Schriftsatzliste konnte nicht geladen werden.";
|
||||
}
|
||||
|
||||
function formatParty(role: string | undefined, isEN: boolean): string {
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "claimant": return isEN ? "Claimant" : "Klägerin";
|
||||
case "defendant": return isEN ? "Defendant" : "Beklagte";
|
||||
case "both": return isEN ? "Both" : "Beide";
|
||||
case "court": return isEN ? "Court" : "Gericht";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// onGenerateClick triggers a download. Disables the button while the
|
||||
// request is in flight to prevent double-submits and surfaces an
|
||||
// inline error on failure.
|
||||
async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
|
||||
const code = btn.dataset.code;
|
||||
const projectID = btn.dataset.project;
|
||||
if (!code || !projectID) return;
|
||||
|
||||
const originalLabel = btn.textContent ?? "";
|
||||
btn.disabled = true;
|
||||
btn.textContent = document.documentElement.lang === "en" ? "Generating…" : "Wird generiert…";
|
||||
|
||||
try {
|
||||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = await resp.json() as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
alert(
|
||||
(document.documentElement.lang === "en"
|
||||
? "Generation failed."
|
||||
: "Generieren fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "")
|
||||
?? `${code}.docx`;
|
||||
triggerDownload(blob, filename);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// parseFilename pulls the filename out of a Content-Disposition
|
||||
// header. Supports both unquoted and quoted forms.
|
||||
function parseFilename(header: string): string | null {
|
||||
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// triggerDownload creates an <a> with an object URL, clicks it, and
|
||||
// revokes the URL. Standard browser-side download pattern.
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Revoke on next tick so the click actually triggers the download
|
||||
// before the URL is gone.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
@@ -25,6 +25,19 @@ let lastResponse: DeadlineResponse | null = null;
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// Notes toggle — when off (default), per-rule descriptive notes render
|
||||
// as a compact ⓘ icon next to the meta line (hover for full text). When
|
||||
// on, the full notes block expands under each card. Choice persists in
|
||||
// localStorage so a reload or recalc keeps the user's preference.
|
||||
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
|
||||
function readNotesPref(): boolean {
|
||||
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
function writeNotesPref(on: boolean): void {
|
||||
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
||||
}
|
||||
let showNotes = readNotesPref();
|
||||
|
||||
// Jurisdiction display prefix for the proceeding-summary chip + the
|
||||
// trigger-event placeholder. Same forum slugs the .proceeding-group
|
||||
// `data-forum` attribute carries in verfahrensablauf.tsx /
|
||||
@@ -167,8 +180,8 @@ function renderResults(data: DeadlineResponse) {
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data)
|
||||
: renderTimelineBody(data);
|
||||
? renderColumnsBody(data, { showNotes })
|
||||
: renderTimelineBody(data, { showParty: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
@@ -299,6 +312,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
// Notes toggle — restores last preference on load + re-renders when
|
||||
// the user flips it. Lives in the same toggle bar as the view picker.
|
||||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||||
if (notesShowCb) {
|
||||
notesShowCb.checked = showNotes;
|
||||
notesShowCb.addEventListener("change", () => {
|
||||
showNotes = notesShowCb.checked;
|
||||
writeNotesPref(showNotes);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
|
||||
onLangChange(() => {
|
||||
|
||||
@@ -196,6 +196,12 @@ interface ApprovalDetail {
|
||||
requester_kind?: "user" | "agent";
|
||||
decider_name?: string;
|
||||
decision_note?: string;
|
||||
// counter_payload + next_request_id — populated on the OLD row of a
|
||||
// suggest-changes pair (t-paliad-216). The new row's id lets us
|
||||
// render a back-link "→ Neuer Vorschlag von {decider}". Both stay
|
||||
// unset on any non-changes_requested status.
|
||||
counter_payload?: Record<string, unknown> | null;
|
||||
next_request_id?: string;
|
||||
// Per-viewer eligibility flags resolved server-side against the caller
|
||||
// (t-paliad-202). Used to grey out actions the server would reject.
|
||||
// Optional so an older payload still renders — falsy means "treat as
|
||||
@@ -204,6 +210,11 @@ interface ApprovalDetail {
|
||||
viewer_is_requester?: boolean;
|
||||
}
|
||||
|
||||
// Pending-row action set. suggest_changes was added in t-paliad-216 as
|
||||
// the fourth action — the approver authors a counter-proposal which
|
||||
// becomes a NEW pending row authored by them.
|
||||
type ApprovalAction = "approve" | "reject" | "revoke" | "suggest_changes";
|
||||
|
||||
function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list views-approval-list";
|
||||
@@ -262,13 +273,20 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// All three actions are stamped on every pending row; the per-viewer
|
||||
// All four actions are stamped on every pending row; the per-viewer
|
||||
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
|
||||
// decide which are enabled vs. greyed out with a tooltip. m's ask
|
||||
// (2026-05-17): show what's possible but disable what isn't, rather
|
||||
// than alert-after-click. The server still enforces — disabled buttons
|
||||
// are a UI hint, not a security gate.
|
||||
//
|
||||
// suggest_changes is hidden for non-update lifecycles (the backend
|
||||
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
|
||||
// so we don't even render the button for them).
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
if (detail.lifecycle_event === "update") {
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
}
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
} else if (detail.status) {
|
||||
@@ -285,6 +303,22 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
// Back-link from the OLD changes_requested row to the NEW pending
|
||||
// counter row (t-paliad-216). Hydrated server-side as
|
||||
// detail.next_request_id; the surface renders a link that scrolls
|
||||
// / filters to the new row. Falsy next_request_id = no link (e.g.
|
||||
// older rows pre-mig-103, or rows where the server hasn't joined the
|
||||
// back-pointer).
|
||||
if (detail.status === "changes_requested" && detail.next_request_id) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "inbox-row-next-request";
|
||||
link.href = `#request-${detail.next_request_id}`;
|
||||
link.dataset.nextRequestId = detail.next_request_id;
|
||||
const deciderName = detail.decider_name || "";
|
||||
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
|
||||
li.appendChild(link);
|
||||
}
|
||||
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
@@ -321,17 +355,24 @@ function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
||||
}
|
||||
|
||||
function approvalActionBtn(
|
||||
action: "approve" | "reject" | "revoke",
|
||||
action: ApprovalAction,
|
||||
detail: ApprovalDetail,
|
||||
): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.action = action;
|
||||
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
|
||||
// suggest_changes shares the secondary style with revoke; reject is
|
||||
// danger (terminal "no"); approve is primary.
|
||||
const cls = action === "approve"
|
||||
? "btn-primary"
|
||||
: action === "reject"
|
||||
? "btn-danger"
|
||||
: "btn-secondary";
|
||||
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
|
||||
// approve / reject share the eligibility gate; revoke is requester-only.
|
||||
// approve / reject / suggest_changes share the canApprove eligibility
|
||||
// gate; revoke is requester-only.
|
||||
const reason = disabledReasonFor(action, detail);
|
||||
if (reason) {
|
||||
btn.disabled = true;
|
||||
@@ -341,13 +382,13 @@ function approvalActionBtn(
|
||||
}
|
||||
|
||||
function disabledReasonFor(
|
||||
action: "approve" | "reject" | "revoke",
|
||||
action: ApprovalAction,
|
||||
detail: ApprovalDetail,
|
||||
): I18nKey | null {
|
||||
if (action === "revoke") {
|
||||
return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester";
|
||||
}
|
||||
// approve + reject — same gate as the server's canApprove.
|
||||
// approve / reject / suggest_changes — same gate as the server's canApprove.
|
||||
if (detail.viewer_can_approve) return null;
|
||||
if (detail.viewer_is_requester) return "approvals.disabled.self_approval";
|
||||
return "approvals.disabled.not_authorized";
|
||||
|
||||
@@ -219,6 +219,13 @@ export interface CardOpts {
|
||||
// verfahrensablauf abstract-browse surface keeps editable=false because
|
||||
// there's no anchor-override state on that page in Slice 1.
|
||||
editable?: boolean;
|
||||
// showNotes controls how the per-rule descriptive notes render:
|
||||
// true → expanded `<div class="timeline-notes">…</div>` below the card
|
||||
// false → compact ⓘ icon next to the meta line, full text on hover
|
||||
// (browser-native `title` attribute) and screen-reader-readable
|
||||
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
|
||||
// re-renders. Default false — notes are noisy on long timelines.
|
||||
showNotes?: boolean;
|
||||
}
|
||||
|
||||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||||
@@ -264,14 +271,19 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
}
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const notes = noteText
|
||||
const showNotes = opts.showNotes === true;
|
||||
const notesBlock = noteText && showNotes
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
: "";
|
||||
const noteHint = noteText && !showNotes
|
||||
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef)
|
||||
const meta = (opts.showParty || ruleRef || noteHint)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${ruleRef}
|
||||
${noteHint}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
@@ -284,7 +296,7 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
${notes}`;
|
||||
${notesBlock}`;
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
@@ -358,7 +370,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
|
||||
@@ -546,6 +546,10 @@ export function renderFristenrechner(): string {
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
|
||||
@@ -583,6 +583,7 @@ export type I18nKey =
|
||||
| "approvals.action.approve"
|
||||
| "approvals.action.reject"
|
||||
| "approvals.action.revoke"
|
||||
| "approvals.action.suggest_changes"
|
||||
| "approvals.agent.byline"
|
||||
| "approvals.agent.label"
|
||||
| "approvals.agent.suggestion_pending"
|
||||
@@ -595,6 +596,7 @@ export type I18nKey =
|
||||
| "approvals.disabled.not_authorized"
|
||||
| "approvals.disabled.revoke_not_requester"
|
||||
| "approvals.disabled.self_approval"
|
||||
| "approvals.disabled.suggest_lifecycle"
|
||||
| "approvals.empty.mine"
|
||||
| "approvals.empty.pending_mine"
|
||||
| "approvals.entity.appointment"
|
||||
@@ -605,6 +607,8 @@ export type I18nKey =
|
||||
| "approvals.error.not_authorized"
|
||||
| "approvals.error.request_not_pending"
|
||||
| "approvals.error.self_approval"
|
||||
| "approvals.error.suggestion_lifecycle_invalid"
|
||||
| "approvals.error.suggestion_requires_change"
|
||||
| "approvals.heading"
|
||||
| "approvals.lifecycle.complete"
|
||||
| "approvals.lifecycle.create"
|
||||
@@ -631,11 +635,21 @@ export type I18nKey =
|
||||
| "approvals.required_role.pa"
|
||||
| "approvals.required_role.senior_pa"
|
||||
| "approvals.status.approved"
|
||||
| "approvals.status.changes_requested"
|
||||
| "approvals.status.pending"
|
||||
| "approvals.status.rejected"
|
||||
| "approvals.status.revoked"
|
||||
| "approvals.status.superseded"
|
||||
| "approvals.subtitle"
|
||||
| "approvals.suggest.cancel"
|
||||
| "approvals.suggest.intro"
|
||||
| "approvals.suggest.modal_title"
|
||||
| "approvals.suggest.next_request_link"
|
||||
| "approvals.suggest.note_label"
|
||||
| "approvals.suggest.note_placeholder"
|
||||
| "approvals.suggest.submit"
|
||||
| "approvals.suggest.submit_disabled_hint"
|
||||
| "approvals.suggest.unsupported_lifecycle"
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
@@ -1069,6 +1083,7 @@ export type I18nKey =
|
||||
| "deadlines.neu.submit"
|
||||
| "deadlines.neu.subtitle"
|
||||
| "deadlines.neu.title"
|
||||
| "deadlines.notes.show"
|
||||
| "deadlines.optional.badge"
|
||||
| "deadlines.party.both"
|
||||
| "deadlines.party.both.label"
|
||||
@@ -1229,6 +1244,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"
|
||||
@@ -1272,9 +1297,11 @@ export type I18nKey =
|
||||
| "einstellungen.subtitle"
|
||||
| "einstellungen.tab.benachrichtigungen"
|
||||
| "einstellungen.tab.caldav"
|
||||
| "einstellungen.tab.export"
|
||||
| "einstellungen.tab.profil"
|
||||
| "einstellungen.title"
|
||||
| "event.description.appointment_approval_approved"
|
||||
| "event.description.appointment_approval_changes_suggested"
|
||||
| "event.description.appointment_approval_rejected"
|
||||
| "event.description.appointment_approval_requested"
|
||||
| "event.description.appointment_approval_revoked"
|
||||
@@ -1283,6 +1310,7 @@ export type I18nKey =
|
||||
| "event.description.appointment_project_changed"
|
||||
| "event.description.appointment_updated"
|
||||
| "event.description.deadline_approval_approved"
|
||||
| "event.description.deadline_approval_changes_suggested"
|
||||
| "event.description.deadline_approval_rejected"
|
||||
| "event.description.deadline_approval_requested"
|
||||
| "event.description.deadline_approval_revoked"
|
||||
@@ -1298,6 +1326,7 @@ export type I18nKey =
|
||||
| "event.note.parent.deadline"
|
||||
| "event.note.parent.project"
|
||||
| "event.title.appointment_approval_approved"
|
||||
| "event.title.appointment_approval_changes_suggested"
|
||||
| "event.title.appointment_approval_rejected"
|
||||
| "event.title.appointment_approval_requested"
|
||||
| "event.title.appointment_approval_revoked"
|
||||
@@ -1312,6 +1341,7 @@ export type I18nKey =
|
||||
| "event.title.checklist_reset"
|
||||
| "event.title.checklist_unlinked"
|
||||
| "event.title.deadline_approval_approved"
|
||||
| "event.title.deadline_approval_changes_suggested"
|
||||
| "event.title.deadline_approval_rejected"
|
||||
| "event.title.deadline_approval_requested"
|
||||
| "event.title.deadline_approval_revoked"
|
||||
@@ -2019,11 +2049,21 @@ export type I18nKey =
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.submissions.action.generate"
|
||||
| "projects.detail.submissions.action.no_template"
|
||||
| "projects.detail.submissions.col.action"
|
||||
| "projects.detail.submissions.col.name"
|
||||
| "projects.detail.submissions.col.party"
|
||||
| "projects.detail.submissions.col.source"
|
||||
| "projects.detail.submissions.empty"
|
||||
| "projects.detail.submissions.empty.no_proceeding"
|
||||
| "projects.detail.submissions.hint"
|
||||
| "projects.detail.tab.checklisten"
|
||||
| "projects.detail.tab.fristen"
|
||||
| "projects.detail.tab.kinder"
|
||||
| "projects.detail.tab.notizen"
|
||||
| "projects.detail.tab.parteien"
|
||||
| "projects.detail.tab.submissions"
|
||||
| "projects.detail.tab.team"
|
||||
| "projects.detail.tab.termine"
|
||||
| "projects.detail.tab.verlauf"
|
||||
@@ -2288,6 +2328,7 @@ export type I18nKey =
|
||||
| "views.bar.approval_role.approver_eligible"
|
||||
| "views.bar.approval_role.self_requested"
|
||||
| "views.bar.approval_status.approved"
|
||||
| "views.bar.approval_status.changes_requested"
|
||||
| "views.bar.approval_status.pending"
|
||||
| "views.bar.approval_status.rejected"
|
||||
| "views.bar.approval_status.revoked"
|
||||
|
||||
@@ -80,6 +80,7 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="appointments" href="#" data-i18n="projects.detail.tab.termine">Termine</a>
|
||||
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
@@ -571,6 +572,38 @@ export function renderProjectsDetail(): string {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Submissions (Schriftsätze) — t-paliad-215 Slice 1.
|
||||
Lists the project's filing-type rules with a per-row
|
||||
[Generieren] button when a .docx template resolves
|
||||
in the registry's fallback chain (firm → base/code →
|
||||
base/family → skeleton). Empty for projects with no
|
||||
proceeding bound; otherwise enumerates every active
|
||||
filing rule for the proceeding. */}
|
||||
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
|
||||
<p id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty.no_proceeding">
|
||||
Bitte zuerst einen Verfahrenstyp setzen.
|
||||
</p>
|
||||
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
|
||||
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
|
||||
</p>
|
||||
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="projects.detail.submissions.col.name">Schriftsatz</th>
|
||||
<th data-i18n="projects.detail.submissions.col.party">Partei</th>
|
||||
<th data-i18n="projects.detail.submissions.col.source">Rechtsgrundlage</th>
|
||||
<th data-i18n="projects.detail.submissions.col.action" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="project-submissions-body" />
|
||||
</table>
|
||||
</div>
|
||||
<p className="tool-subtitle submissions-hint" data-i18n="projects.detail.submissions.hint">
|
||||
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="entity-detail-footer" id="project-delete-wrap" style="display:none">
|
||||
<button id="project-delete-btn" className="btn-secondary" type="button" data-i18n="projects.detail.delete">
|
||||
Projekt archivieren
|
||||
|
||||
@@ -40,6 +40,7 @@ export function renderSettings(): string {
|
||||
<a className="entity-tab" data-tab="profil" href="?tab=profil" data-i18n="einstellungen.tab.profil">Profil</a>
|
||||
<a className="entity-tab" data-tab="benachrichtigungen" href="?tab=benachrichtigungen" data-i18n="einstellungen.tab.benachrichtigungen">Benachrichtigungen</a>
|
||||
<a className="entity-tab" data-tab="caldav" href="?tab=caldav" data-i18n="einstellungen.tab.caldav">CalDAV</a>
|
||||
<a className="entity-tab" data-tab="export" href="?tab=export" data-i18n="einstellungen.tab.export">Datenexport</a>
|
||||
</nav>
|
||||
|
||||
{/* --- Profil tab ---------------------------------------- */}
|
||||
@@ -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>
|
||||
|
||||
@@ -3441,6 +3441,49 @@ input[type="range"]::-moz-range-thumb {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Notes toggle — checkbox affordance in the view-toggle bar that flips
|
||||
per-card descriptive notes between compact (ⓘ tooltip icon) and
|
||||
expanded (timeline-notes block). Sits with a leading separator so it
|
||||
reads as a distinct control from the radio view picker. */
|
||||
.fristen-notes-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
margin-left: auto;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.fristen-notes-option input[type=checkbox] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Compact note hint — sits in the timeline-meta line when the notes
|
||||
toggle is off. Native browser tooltip via title= attribute carries
|
||||
the full text on hover; tabindex=0 + aria-label make it
|
||||
keyboard / screen-reader accessible. */
|
||||
.timeline-note-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: help;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.timeline-note-hint:hover,
|
||||
.timeline-note-hint:focus-visible {
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Fristenrechner — three-column lane view (Proactive | Court | Reactive).
|
||||
Each lane is independently date-ordered; party=both rows render below
|
||||
as full-width spans because they apply to all sides. */
|
||||
@@ -5204,6 +5247,40 @@ input[type="range"]::-moz-range-thumb {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Submissions panel — t-paliad-215 Slice 1. */
|
||||
.submission-row td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.submission-name {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.submission-code {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85em;
|
||||
font-family: var(--font-mono, monospace);
|
||||
display: block;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.submission-action-cell {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.submission-no-template {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.submissions-hint {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.checklist-instance-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
|
||||
@@ -225,6 +225,10 @@ export function renderVerfahrensablauf(): string {
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
|
||||
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
|
||||
}
|
||||
13
internal/db/migrations/101_caldav_multi_calendar.down.sql
Normal file
13
internal/db/migrations/101_caldav_multi_calendar.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Reverse of 101_caldav_multi_calendar.up.sql.
|
||||
--
|
||||
-- Drop the new join + binding tables. CASCADE on the FK references
|
||||
-- isn't needed because we drop targets before bindings, and Postgres
|
||||
-- handles RLS policies / indexes automatically on DROP TABLE.
|
||||
--
|
||||
-- The legacy paliad.appointments.caldav_uid / caldav_etag columns are
|
||||
-- untouched by the up migration, so they're untouched here too —
|
||||
-- rollback returns the system to the pre-Slice-1 state where those
|
||||
-- scalars are the single source of CalDAV truth.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.appointment_caldav_targets;
|
||||
DROP TABLE IF EXISTS paliad.user_calendar_bindings;
|
||||
350
internal/db/migrations/101_caldav_multi_calendar.up.sql
Normal file
350
internal/db/migrations/101_caldav_multi_calendar.up.sql
Normal file
@@ -0,0 +1,350 @@
|
||||
-- t-paliad-212 — Slice 1 of the CalDAV multi-calendar design (see
|
||||
-- docs/design-caldav-multi-calendar-2026-05-19.md). Pure schema +
|
||||
-- backfill; the sync engine is NOT touched in this migration. Slice 2
|
||||
-- wires the per-binding fan-out.
|
||||
--
|
||||
-- What we add:
|
||||
-- 1. paliad.user_calendar_bindings — N bindings per user, each with
|
||||
-- a scope_kind enum (all_visible / personal_only / project /
|
||||
-- client / litigation / patent / case) and an optional scope_id
|
||||
-- pointing at a paliad.projects row when the scope is hierarchy-
|
||||
-- anchored. The same Appointment can be PUT into multiple of
|
||||
-- these bindings (e.g. master cal + per-project cal).
|
||||
-- 2. paliad.appointment_caldav_targets — (appointment_id, binding_id)
|
||||
-- join carrying the per-target caldav_uid + caldav_etag. The
|
||||
-- canonical UID is still per-appointment (paliad-appointment-
|
||||
-- <uuid>@paliad.de) so the same event in N cals shares one UID.
|
||||
-- 3. Backfill: one all_visible binding per existing
|
||||
-- user_caldav_config row, plus one target row per Appointment
|
||||
-- already pushed (caldav_uid IS NOT NULL). Backfill maps the
|
||||
-- target's binding_id to the appointment creator's binding —
|
||||
-- that matches today's Phase F semantics, where the creator's
|
||||
-- sync goroutine owns the etag.
|
||||
--
|
||||
-- The scalar columns paliad.appointments.caldav_uid / caldav_etag
|
||||
-- STAY in place through Slice 1 and Slice 2. Slice 1 keeps them as
|
||||
-- read-once denormalised pointers to the default binding's target
|
||||
-- row; Slice 4 drops them after telemetry confirms no path still
|
||||
-- reads them.
|
||||
--
|
||||
-- Idempotent: every CREATE uses IF NOT EXISTS, both backfills are
|
||||
-- guarded by NOT EXISTS. Safe to re-run.
|
||||
--
|
||||
-- audit_reason set_config required at the top because m's recent
|
||||
-- migration friction had several mig failures from missing reasons.
|
||||
-- The trigger raising 'audit reason required' is on
|
||||
-- paliad.deadline_rules only — this migration doesn't touch that
|
||||
-- table — but we set the reason for symmetry per paliadin's 2026-05-19
|
||||
-- coder-shift brief.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 101: CalDAV multi-calendar schema + backfill (Slice 1 of t-paliad-212; design doc docs/design-caldav-multi-calendar-2026-05-19.md). No row mutations on existing trigger-guarded tables; this is a defensive symmetry set_config.',
|
||||
true);
|
||||
|
||||
-- =========================================================================
|
||||
-- 1. paliad.user_calendar_bindings
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.user_calendar_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Full URL or path under user_caldav_config.url. The CalDAV client
|
||||
-- resolves it against the user's server URL the same way it
|
||||
-- resolves the legacy user_caldav_config.calendar_path today.
|
||||
calendar_path text NOT NULL,
|
||||
|
||||
-- What the picker UI shows for this binding. Discovered via
|
||||
-- PROPFIND <displayname/> at add-time and cached here so we don't
|
||||
-- re-fetch every render. Default '' (Slice 1 backfill leaves it
|
||||
-- empty; Slice 2 fills it during the picker flow).
|
||||
display_name text NOT NULL DEFAULT '',
|
||||
|
||||
-- Which appointments push into this calendar. Slice 1 only really
|
||||
-- needs 'all_visible' (that's all the backfill creates) but we
|
||||
-- ship the full enum now so the schema is final and Slice 2/3
|
||||
-- don't have to ALTER it.
|
||||
scope_kind text NOT NULL,
|
||||
scope_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
|
||||
-- Only meaningful when scope_kind is hierarchy-anchored
|
||||
-- (project / client / litigation / patent / case). When true,
|
||||
-- the binding ALSO receives the user's personal (project_id IS
|
||||
-- NULL AND created_by = user_id) appointments. Ignored for
|
||||
-- 'all_visible' (already includes them) and 'personal_only'.
|
||||
include_personal boolean NOT NULL DEFAULT false,
|
||||
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
last_sync_at timestamptz,
|
||||
last_sync_error text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT user_calendar_bindings_scope_kind_chk CHECK (
|
||||
scope_kind IN ('all_visible','personal_only','project','client','litigation','patent','case')
|
||||
),
|
||||
CONSTRAINT user_calendar_bindings_scope_id_chk CHECK (
|
||||
(scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
|
||||
OR
|
||||
(scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- One binding per (user, calendar) — can't bind the same external
|
||||
-- calendar twice for the same user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_user_path_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, calendar_path);
|
||||
|
||||
-- One hierarchy binding per (user, scope_kind, scope_id) — a user
|
||||
-- can't have two bindings for the same project, but CAN have a
|
||||
-- 'project' binding for project X alongside an 'all_visible'
|
||||
-- master binding (different scope_kind ⇒ different row).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_hier_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, scope_kind, scope_id)
|
||||
WHERE scope_id IS NOT NULL;
|
||||
|
||||
-- One scope-less binding per (user, scope_kind) — at most one
|
||||
-- 'all_visible' and one 'personal_only' per user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_root_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, scope_kind)
|
||||
WHERE scope_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_calendar_bindings_user_idx
|
||||
ON paliad.user_calendar_bindings (user_id)
|
||||
WHERE enabled;
|
||||
|
||||
-- No updated_at trigger — paliad.user_caldav_config also doesn't have
|
||||
-- one. The Go service layer sets updated_at = NOW() explicitly on
|
||||
-- every write (see SaveConfig in caldav_service.go); we follow the
|
||||
-- same convention here so all CalDAV-related tables are consistent.
|
||||
|
||||
ALTER TABLE paliad.user_calendar_bindings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Same shape as user_caldav_config policies: a user sees + mutates
|
||||
-- only their own rows. auth.uid() returns the authenticated user's
|
||||
-- id (mirrors auth.uid()).
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_select ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_select ON paliad.user_calendar_bindings
|
||||
FOR SELECT TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_insert ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_insert ON paliad.user_calendar_bindings
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_update ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_update ON paliad.user_calendar_bindings
|
||||
FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_delete ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_delete ON paliad.user_calendar_bindings
|
||||
FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 2. paliad.appointment_caldav_targets
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.appointment_caldav_targets (
|
||||
appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
|
||||
binding_id uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,
|
||||
|
||||
-- 'paliad-appointment-<uuid>@paliad.de' — derived from
|
||||
-- appointment_id, identical across all bindings of one appointment.
|
||||
caldav_uid text NOT NULL,
|
||||
|
||||
-- ETag returned by the CalDAV server on the last successful PUT.
|
||||
-- NULLABLE to match the legacy paliad.appointments.caldav_etag
|
||||
-- column: some servers don't return ETag on PUT and we
|
||||
-- re-PROPFIND lazily on next tick.
|
||||
caldav_etag text,
|
||||
|
||||
last_pushed_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
PRIMARY KEY (appointment_id, binding_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS appointment_caldav_targets_binding_idx
|
||||
ON paliad.appointment_caldav_targets (binding_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS appointment_caldav_targets_uid_idx
|
||||
ON paliad.appointment_caldav_targets (caldav_uid);
|
||||
|
||||
ALTER TABLE paliad.appointment_caldav_targets ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- A target row is visible/mutable to the user who owns the binding.
|
||||
-- Appointment-side visibility is enforced separately by AppointmentService;
|
||||
-- the target is a sync-state row, scoped per-user.
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets
|
||||
FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets
|
||||
FOR UPDATE TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets
|
||||
FOR DELETE TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 3. Backfill — one all_visible binding per existing CalDAV-configured user
|
||||
-- =========================================================================
|
||||
|
||||
-- For every paliad.user_caldav_config row, insert an 'all_visible'
|
||||
-- binding that mirrors today's single-target Phase F push. The new
|
||||
-- binding inherits the legacy `calendar_path` (or, when that's empty,
|
||||
-- the server URL itself — same fallback the client uses today). The
|
||||
-- enabled flag carries over.
|
||||
--
|
||||
-- Idempotent: skipped when this user already has an all_visible binding
|
||||
-- (re-running the migration is a no-op).
|
||||
INSERT INTO paliad.user_calendar_bindings
|
||||
(user_id, calendar_path, display_name, scope_kind, scope_id, include_personal, enabled)
|
||||
SELECT
|
||||
c.user_id,
|
||||
COALESCE(NULLIF(c.calendar_path, ''), c.url),
|
||||
'',
|
||||
'all_visible',
|
||||
NULL,
|
||||
false,
|
||||
c.enabled
|
||||
FROM paliad.user_caldav_config c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.user_id = c.user_id
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 4. Backfill — one target row per already-pushed appointment
|
||||
-- =========================================================================
|
||||
|
||||
-- For every appointment with a non-null caldav_uid, insert one target
|
||||
-- row pointing at the appointment creator's new all_visible binding.
|
||||
-- That preserves the (appointment, calendar) sync state exactly as it
|
||||
-- existed before this migration.
|
||||
--
|
||||
-- Why created_by, not "every visible user": today's Phase F
|
||||
-- caldav_uid/caldav_etag scalars on appointments are populated by
|
||||
-- whoever happened to push last; in practice the etag almost always
|
||||
-- belongs to the creator's calendar because pull-side updates only
|
||||
-- run when CreatedBy = userID (caldav_service.go:449). Mapping the
|
||||
-- backfill target to the creator's binding keeps the etag pointing
|
||||
-- where it actually came from. Other users' goroutines will create
|
||||
-- their own target rows on their next sync tick after Slice 2 ships.
|
||||
--
|
||||
-- Idempotent: skipped when (appointment_id, binding_id) target already
|
||||
-- exists.
|
||||
INSERT INTO paliad.appointment_caldav_targets
|
||||
(appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at)
|
||||
SELECT
|
||||
a.id,
|
||||
b.id,
|
||||
a.caldav_uid,
|
||||
a.caldav_etag,
|
||||
a.updated_at
|
||||
FROM paliad.appointments a
|
||||
JOIN paliad.user_calendar_bindings b
|
||||
ON b.user_id = a.created_by
|
||||
AND b.scope_kind = 'all_visible'
|
||||
WHERE a.caldav_uid IS NOT NULL
|
||||
AND a.created_by IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM paliad.appointment_caldav_targets t
|
||||
WHERE t.appointment_id = a.id
|
||||
AND t.binding_id = b.id
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 5. Assertions — hard fail if the backfill didn't catch every row
|
||||
-- =========================================================================
|
||||
|
||||
-- Every paliad.user_caldav_config row must have at least one
|
||||
-- all_visible binding after this migration. If it doesn't, either a
|
||||
-- row was inserted between the backfill and the assertion (race —
|
||||
-- run is wrapped in a transaction by golang-migrate, so this can't
|
||||
-- happen) or the backfill is buggy. Hard fail either way.
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_users int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO missing_users
|
||||
FROM paliad.user_caldav_config c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.user_id = c.user_id
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
IF missing_users > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 101 assertion failed: % paliad.user_caldav_config row(s) without an all_visible binding',
|
||||
missing_users;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Every appointment with a non-null caldav_uid AND a non-null
|
||||
-- created_by must have a target row pointing at its creator's
|
||||
-- all_visible binding. created_by can be NULL on legacy rows
|
||||
-- (e.g. seed data) so we exclude those from the assertion.
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_targets int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO missing_targets
|
||||
FROM paliad.appointments a
|
||||
WHERE a.caldav_uid IS NOT NULL
|
||||
AND a.created_by IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.appointment_caldav_targets t
|
||||
JOIN paliad.user_calendar_bindings b
|
||||
ON b.id = t.binding_id
|
||||
WHERE t.appointment_id = a.id
|
||||
AND b.user_id = a.created_by
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
IF missing_targets > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 101 assertion failed: % appointment(s) with caldav_uid but no all_visible target row',
|
||||
missing_targets;
|
||||
END IF;
|
||||
END $$;
|
||||
15
internal/db/migrations/102_system_audit_log.down.sql
Normal file
15
internal/db/migrations/102_system_audit_log.down.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Revert mig 102 — drop paliad.system_audit_log and its indexes / policies.
|
||||
-- audit_reason set_config required by the mig 079 trigger pattern.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 102 down: drop paliad.system_audit_log (t-paliad-214 Slice 1 revert)',
|
||||
true);
|
||||
|
||||
DROP POLICY IF EXISTS system_audit_log_select_admin ON paliad.system_audit_log;
|
||||
DROP POLICY IF EXISTS system_audit_log_select_self ON paliad.system_audit_log;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.system_audit_log_event_type_created_at_idx;
|
||||
DROP INDEX IF EXISTS paliad.system_audit_log_actor_id_created_at_idx;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.system_audit_log;
|
||||
79
internal/db/migrations/102_system_audit_log.up.sql
Normal file
79
internal/db/migrations/102_system_audit_log.up.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- t-paliad-214 Slice 1 — create paliad.system_audit_log as the 6th source
|
||||
-- in the AuditService.ListEntries union. Captures org-wide / scope-spanning
|
||||
-- actions that don't naturally belong on any single project_events row.
|
||||
--
|
||||
-- Design: docs/design-paliad-data-export-2026-05-19.md §4.
|
||||
--
|
||||
-- Initial use case is data-export auditing (every export run writes one row,
|
||||
-- before the artifact is generated, then is patched with row_counts +
|
||||
-- file_size_bytes on completion). The table is intentionally generic
|
||||
-- (`event_type` + `metadata jsonb`) so future org-wide actions can land here
|
||||
-- without a new table per concept.
|
||||
--
|
||||
-- Idempotent: CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS.
|
||||
-- audit_reason set_config required by the mig 079 trigger pattern when
|
||||
-- migrations touch the database — universal convention even for pure-DDL
|
||||
-- migrations.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 102: add paliad.system_audit_log for org-wide / scope-spanning audit events (t-paliad-214 Slice 1 — data-export audit chain)',
|
||||
true);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.system_audit_log (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_type text NOT NULL,
|
||||
actor_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
-- actor_email is captured at write time so the audit row survives a
|
||||
-- subsequent user-deletion (FK above sets NULL, but the historical
|
||||
-- identity stays readable).
|
||||
actor_email text NOT NULL,
|
||||
scope text NOT NULL CHECK (scope IN ('org', 'project', 'personal')),
|
||||
-- scope_root is the project_id for scope='project'; NULL otherwise.
|
||||
-- Not a hard FK because we want the audit row to outlive a project
|
||||
-- deletion. Resolution happens at read time.
|
||||
scope_root uuid,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Indexes mirror the read patterns:
|
||||
-- - actor lookup ("show me what I've exported"): actor_id + created_at desc
|
||||
-- - scope rollup ("how much org-wide activity in the last 30 days"): event_type + created_at desc
|
||||
CREATE INDEX IF NOT EXISTS system_audit_log_actor_id_created_at_idx
|
||||
ON paliad.system_audit_log (actor_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS system_audit_log_event_type_created_at_idx
|
||||
ON paliad.system_audit_log (event_type, created_at DESC);
|
||||
|
||||
-- RLS: every authenticated user can SELECT their own rows (actor_id = auth.uid());
|
||||
-- global_admins see everything. INSERT / UPDATE happen via the Go service path
|
||||
-- under the migration-runner role (no end-user write surface) so no INSERT
|
||||
-- policy is needed for end users.
|
||||
ALTER TABLE paliad.system_audit_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS system_audit_log_select_self ON paliad.system_audit_log;
|
||||
CREATE POLICY system_audit_log_select_self ON paliad.system_audit_log
|
||||
FOR SELECT
|
||||
USING (actor_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS system_audit_log_select_admin ON paliad.system_audit_log;
|
||||
CREATE POLICY system_audit_log_select_admin ON paliad.system_audit_log
|
||||
FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.system_audit_log IS
|
||||
'Org-wide / scope-spanning audit events. 6th source of AuditService union. Generic event_type + metadata jsonb. Initial users: data-export audit chain (t-paliad-214). Audit rows persist forever; artifact retention is separate.';
|
||||
|
||||
COMMENT ON COLUMN paliad.system_audit_log.actor_email IS
|
||||
'Captured at write time so the audit row survives user deletion (actor_id FK uses ON DELETE SET NULL).';
|
||||
|
||||
COMMENT ON COLUMN paliad.system_audit_log.scope_root IS
|
||||
'project_id for scope=project; NULL otherwise. Not a hard FK so audit survives project deletion.';
|
||||
27
internal/db/migrations/103_approval_suggest_changes.down.sql
Normal file
27
internal/db/migrations/103_approval_suggest_changes.down.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Reverse of 103_approval_suggest_changes.up.sql.
|
||||
--
|
||||
-- Drops the previous_request_id index + column, drops counter_payload, and
|
||||
-- restores the original status CHECK (without 'changes_requested'). If any
|
||||
-- live rows are at status='changes_requested' OR carry a non-NULL
|
||||
-- counter_payload OR previous_request_id, the down will fail on the CHECK
|
||||
-- restore. That is intentional: it forces an explicit cleanup decision
|
||||
-- before tearing the schema back.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 103 DOWN: revert suggest-changes schema extensions (t-paliad-216)',
|
||||
true);
|
||||
|
||||
DROP INDEX IF EXISTS paliad.approval_requests_previous_idx;
|
||||
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP COLUMN IF EXISTS previous_request_id;
|
||||
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP COLUMN IF EXISTS counter_payload;
|
||||
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD CONSTRAINT approval_requests_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded'));
|
||||
57
internal/db/migrations/103_approval_suggest_changes.up.sql
Normal file
57
internal/db/migrations/103_approval_suggest_changes.up.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- t-paliad-216 Slice A — add the "Suggest changes" action to the approval
|
||||
-- flow alongside Approve / Reject / Revoke. Design:
|
||||
-- docs/design-approval-suggest-changes-2026-05-19.md.
|
||||
--
|
||||
-- Mental model (m's 2026-05-19 decisions, §0a of the design doc):
|
||||
-- "Suggest changes" is not a soft-reject with a hint. It is the approver
|
||||
-- AUTHORING A COUNTER-PROPOSAL that gets re-injected into the approval
|
||||
-- flow as a fresh `pending` row. The original requester (no longer the
|
||||
-- new row's requested_by) becomes potentially-eligible to approve the
|
||||
-- counter — 4-Augen still holds via the standard self-approval guard.
|
||||
--
|
||||
-- Three schema additions to paliad.approval_requests:
|
||||
-- 1. Extend the status CHECK to allow 'changes_requested'.
|
||||
-- 2. counter_payload jsonb NULL — the approver's edited values,
|
||||
-- stored on the OLD (changes_requested) row so the audit chain
|
||||
-- can show "approver edited X, Y, Z" without joining forward.
|
||||
-- Also used as the `payload` for the NEW row spawned in the same
|
||||
-- tx by ApprovalService.SuggestChanges.
|
||||
-- 3. previous_request_id uuid NULL FK — back-pointer on the NEW row
|
||||
-- to the OLD (changes_requested) row that spawned it. ON DELETE
|
||||
-- SET NULL keeps a survivor row intact if either end is ever
|
||||
-- pruned. Partial index covers chain traversal.
|
||||
--
|
||||
-- The set_config('paliad.audit_reason', ...) line is the universal
|
||||
-- convention for paliad migrations (mig 079 trigger pattern) — even
|
||||
-- pure-DDL migrations set it so an audit trigger that fires on any
|
||||
-- migration-touched table has a non-NULL reason string to record.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 103: add suggest-changes action — extend approval_requests.status CHECK with changes_requested, add counter_payload jsonb + previous_request_id FK (t-paliad-216 Slice A)',
|
||||
true);
|
||||
|
||||
-- 1. Extend approval_requests.status CHECK.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD CONSTRAINT approval_requests_status_check
|
||||
CHECK (status IN (
|
||||
'pending', 'approved', 'rejected', 'revoked', 'superseded', 'changes_requested'
|
||||
));
|
||||
|
||||
-- 2. counter_payload — the approver's edited values when suggesting
|
||||
-- changes. Stays NULL for every status other than changes_requested.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN counter_payload jsonb;
|
||||
|
||||
-- 3. previous_request_id — back-pointer FK. NULL for first-attempt rows;
|
||||
-- set to the prior (changes_requested) row's id on the NEW row spawned
|
||||
-- by SuggestChanges. ON DELETE SET NULL keeps survivor rows intact.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN previous_request_id uuid
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS approval_requests_previous_idx
|
||||
ON paliad.approval_requests (previous_request_id)
|
||||
WHERE previous_request_id IS NOT NULL;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Revert mig 104 — restore the bracket-bearing Einspruch names and
|
||||
-- flip the CCR priority back to 'informational'.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 104 down: restore "Einspruch (R. 19 VerfO)" and "Einspruch (R. 19 i.V.m. R. 46 VerfO)" names + flip upc.inf.cfi.ccr priority back to informational',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection (RoP 19)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch (R. 19 VerfO)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET priority = 'informational'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.ccr'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.priority = 'optional';
|
||||
@@ -0,0 +1,89 @@
|
||||
-- t-paliad-207 (m's interactive session) — two label/priority polish
|
||||
-- fixes on upc.inf.cfi / upc.rev.cfi:
|
||||
--
|
||||
-- 1. **CCR priority informational → optional.** m's correction
|
||||
-- 2026-05-18 18:01: the Nichtigkeitswiderklage is a substantive
|
||||
-- defensive choice the defendant makes — not just an informational
|
||||
-- notice. priority='optional' renders it as an unchecked save row
|
||||
-- the user can opt into. The fermi amend (commit e8d658a) flipping
|
||||
-- this didn't land in main — paliadin's merge of mig 100 (commit
|
||||
-- c10f8cf, merge 4ddcd28) picked up the pre-amend 'informational'
|
||||
-- version. This is the recovery.
|
||||
--
|
||||
-- 2. **Strip rule citation from Einspruch names.** m's correction
|
||||
-- 2026-05-18 18:08: every other rule name in the corpus carries
|
||||
-- the act-name without a parenthetical rule cite (Klageerwiderung,
|
||||
-- Antrag auf Patentänderung, Replik, etc.). The Einspruch rule
|
||||
-- names are the outliers:
|
||||
-- upc.inf.cfi.prelim "Einspruch (R. 19 VerfO)" → "Einspruch"
|
||||
-- upc.rev.cfi.prelim "Einspruch (R. 19 i.V.m. R. 46 VerfO)" → "Einspruch"
|
||||
-- and EN equivalents:
|
||||
-- "Preliminary Objection (RoP 19)" → "Preliminary Objection"
|
||||
-- "Preliminary Objection (RoP 19 in conjunction with RoP 46)"
|
||||
-- → "Preliminary Objection"
|
||||
-- The legal_source / rule_code columns already carry the citation
|
||||
-- and render in the deadline card's meta line, so the name stays
|
||||
-- clean. The R.46-i.V.m. distinction is preserved in the legal
|
||||
-- source field (RoP.019.1 for both — m may want to further
|
||||
-- differentiate; flagged in description text instead).
|
||||
--
|
||||
-- audit_reason set_config required at the top — the deadline_rules
|
||||
-- audit trigger raises EXCEPTION 'audit reason required' on any
|
||||
-- mutation without it (cf. mig 099 hotfix history).
|
||||
--
|
||||
-- Idempotency:
|
||||
-- * Priority UPDATE guarded on the current 'informational' value.
|
||||
-- * Name UPDATEs guarded on the current parenthetical-bearing names.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 104: flip upc.inf.cfi.ccr priority informational→optional + strip rule-cite brackets from R.19 Einspruch names on both upc.inf.cfi.prelim and upc.rev.cfi.prelim (m''s corrections 2026-05-18, t-paliad-207 interactive session)',
|
||||
true);
|
||||
|
||||
-- 1) Flip CCR priority
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET priority = 'optional'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.ccr'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.priority = 'informational';
|
||||
|
||||
-- 2a) Strip "(R. 19 VerfO)" from upc.inf.cfi.prelim DE/EN names
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch (R. 19 VerfO)';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection (RoP 19)';
|
||||
|
||||
-- 2b) Strip "(R. 19 i.V.m. R. 46 VerfO)" from upc.rev.cfi.prelim DE/EN names
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Revert mig 105 — restore the pre-mig-105 sequence_order values
|
||||
-- (post-mig-100 state). Same two-phase swap pattern.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 105 down: restore pre-track-aware sequence_order on upc.inf.cfi rules',
|
||||
true);
|
||||
|
||||
-- Phase 1: park
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1011 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 20;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1012 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 22;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1013 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 30;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1020 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 12;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1021 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 32;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1022 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 24;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1030 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 14;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1031 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 34;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1032 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 26;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1033 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 36;
|
||||
|
||||
-- Phase 2: assign originals
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 11 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1011;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 12 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1012;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 13 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1013;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 20 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1020;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 21 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1021;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 22 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1022;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 30 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1030;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 31 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1031;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 32 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1032;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 33 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1033;
|
||||
211
internal/db/migrations/105_upc_inf_track_aware_sequence.up.sql
Normal file
211
internal/db/migrations/105_upc_inf_track_aware_sequence.up.sql
Normal file
@@ -0,0 +1,211 @@
|
||||
-- t-paliad-207 — re-sequence upc.inf.cfi rules so within any tied-date
|
||||
-- group the infringement-track responses sit ABOVE the revocation-
|
||||
-- track responses ABOVE the amendment-track responses. m's ask
|
||||
-- 2026-05-18 18:08: "the infringement parts (like Replik) should show
|
||||
-- above the part for the revocation (Erwiderung Nichtigkeitswider-
|
||||
-- klage)".
|
||||
--
|
||||
-- Three tracks coexist on upc.inf.cfi once the with_ccr / with_amend
|
||||
-- flags are set. They share calendar dates because R.29 / R.30 / R.32
|
||||
-- all key off the SoD or its descendants. The current sequence_orders
|
||||
-- (post-mig 100) interleave them; the user sees Erwiderung-zur-CCR
|
||||
-- before Replik even though Replik is the infringement-side response
|
||||
-- to the same triggering event.
|
||||
--
|
||||
-- New sequence_order assignment (preserves the soc=0, prelim=5,
|
||||
-- sod=10, ccr=11 anchors at the head; phase markers interim/oral/
|
||||
-- decision/cost_app/appeal_spawn keep their existing 40/50/60/70/80
|
||||
-- slots at the tail):
|
||||
--
|
||||
-- Old → New submission_code track date
|
||||
-- --- --- --------------- ----- ----
|
||||
-- 0 0 upc.inf.cfi.soc — D+0
|
||||
-- 5 5 upc.inf.cfi.prelim — D+1mo
|
||||
-- 10 10 upc.inf.cfi.sod infringement D+3mo
|
||||
-- 11 20 upc.inf.cfi.ccr revocation D+3mo
|
||||
-- 20 12 upc.inf.cfi.reply infringement D+5mo ← MOVED UP
|
||||
-- 12 22 upc.inf.cfi.def_to_ccr revocation D+5mo
|
||||
-- 13 30 upc.inf.cfi.app_to_amend amendment D+5mo
|
||||
-- 30 14 upc.inf.cfi.rejoin infringement D+6mo ← MOVED UP
|
||||
-- 22 24 upc.inf.cfi.reply_def_ccr revocation D+7mo
|
||||
-- 21 32 upc.inf.cfi.def_to_amend amendment D+7mo
|
||||
-- 32 26 upc.inf.cfi.rejoin_reply_ccr revocation D+8mo
|
||||
-- 31 34 upc.inf.cfi.reply_def_amd amendment D+8mo
|
||||
-- 33 36 upc.inf.cfi.rejoin_amd amendment D+9mo
|
||||
-- 40 40 upc.inf.cfi.interim phase later
|
||||
-- 50 50 upc.inf.cfi.oral phase later
|
||||
-- 60 60 upc.inf.cfi.decision phase later
|
||||
-- 70 70 upc.inf.cfi.cost_app phase later
|
||||
-- 80 80 upc.inf.cfi.appeal_spawn phase later
|
||||
--
|
||||
-- Order within each tied-date group after the reshuffle:
|
||||
-- D+3mo: sod(10), ccr(20) — SoD then its CCR
|
||||
-- D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
|
||||
-- D+7mo: reply_def_ccr(24), def_to_amend(32) — rev → amd
|
||||
-- D+8mo: rejoin_reply_ccr(26), reply_def_amd(34) — rev → amd
|
||||
--
|
||||
-- (no infringement-track rule at +7mo or +8mo so revocation leads
|
||||
-- those dates; rejoin sits alone at +6mo so it has no peers to order
|
||||
-- against.)
|
||||
--
|
||||
-- audit_reason set_config required at the top — the deadline_rules
|
||||
-- audit trigger raises EXCEPTION 'audit reason required' on any
|
||||
-- mutation without it (cf. mig 099 hotfix history).
|
||||
--
|
||||
-- Idempotency: every UPDATE is guarded by both the submission_code
|
||||
-- AND the SOURCE sequence_order, so re-apply is a no-op once the new
|
||||
-- numbers are in place.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 105: re-sequence upc.inf.cfi rules track-aware (infringement → revocation → amendment within tied-date groups; m''s 2026-05-18 ask, t-paliad-207 interactive session)',
|
||||
true);
|
||||
|
||||
-- Two-phase swap to avoid sequence collisions during the UPDATE
|
||||
-- (otherwise two rules can briefly share a sequence_order if Postgres
|
||||
-- evaluates them in parallel). Phase 1: move every reshuffled rule to
|
||||
-- a high temporary number (1000+). Phase 2: assign final numbers.
|
||||
|
||||
-- ─── Phase 1: park reshuffled rules at 1000+ ────────────────────────
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1011
|
||||
WHERE submission_code = 'upc.inf.cfi.ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 11;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1012
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 12;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1013
|
||||
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 13;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1020
|
||||
WHERE submission_code = 'upc.inf.cfi.reply'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 20;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1021
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 21;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1022
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 22;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1030
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 30;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1031
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 31;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1032
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 32;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1033
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 33;
|
||||
|
||||
-- ─── Phase 2: assign final track-aware numbers ──────────────────────
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 12
|
||||
WHERE submission_code = 'upc.inf.cfi.reply'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1020;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 14
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1030;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 20
|
||||
WHERE submission_code = 'upc.inf.cfi.ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1011;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 22
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1012;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 24
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1022;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 26
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1032;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 30
|
||||
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1013;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 32
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1021;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 34
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1031;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 36
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1033;
|
||||
28
internal/db/migrations/106_add_madrid_office.down.sql
Normal file
28
internal/db/migrations/106_add_madrid_office.down.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Revert mig 106 — drop 'madrid' from the office CHECK constraints.
|
||||
--
|
||||
-- Will fail if any users.office or partner_units.office row carries
|
||||
-- 'madrid' — that's intentional (the down has no opinion on the data;
|
||||
-- caller must clean up first or accept the failure).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 106 down: restore pre-madrid office CHECK on users + partner_units',
|
||||
true);
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_office_check;
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan'
|
||||
));
|
||||
|
||||
ALTER TABLE paliad.partner_units
|
||||
DROP CONSTRAINT IF EXISTS partner_units_office_check;
|
||||
ALTER TABLE paliad.partner_units
|
||||
ADD CONSTRAINT partner_units_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan'
|
||||
));
|
||||
42
internal/db/migrations/106_add_madrid_office.up.sql
Normal file
42
internal/db/migrations/106_add_madrid_office.up.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- mig 106 — add 'madrid' to firm office CHECK constraints
|
||||
--
|
||||
-- m's ask 2026-05-20 09:42: add Madrid as an HLC office, alongside the
|
||||
-- existing seven (munich, duesseldorf, hamburg, amsterdam, london,
|
||||
-- paris, milan). Two active CHECK constraints to extend:
|
||||
-- - paliad.users.office (mig 002)
|
||||
-- - paliad.partner_units.office (mig 018; renamed mig 024 + mig 027)
|
||||
--
|
||||
-- The Go-side source of truth lives in internal/offices/offices.go;
|
||||
-- this migration keeps the DB in sync.
|
||||
--
|
||||
-- Long-term, the admin area will let firms manage their own office
|
||||
-- list (separate issue) — but for now the list is hard-coded here
|
||||
-- + offices.go.
|
||||
--
|
||||
-- Non-blocking: extending a CHECK constraint is a metadata-only change
|
||||
-- on a small enum-style column.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 106: add madrid to firm office CHECK on users + partner_units',
|
||||
true);
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_office_check;
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan',
|
||||
'madrid'
|
||||
));
|
||||
|
||||
ALTER TABLE paliad.partner_units
|
||||
DROP CONSTRAINT IF EXISTS partner_units_office_check;
|
||||
ALTER TABLE paliad.partner_units
|
||||
ADD CONSTRAINT partner_units_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan',
|
||||
'madrid'
|
||||
));
|
||||
@@ -270,7 +270,8 @@ func isValidInboxStatus(s string) bool {
|
||||
services.RequestStatusApproved,
|
||||
services.RequestStatusRejected,
|
||||
services.RequestStatusRevoked,
|
||||
services.RequestStatusSuperseded:
|
||||
services.RequestStatusSuperseded,
|
||||
services.RequestStatusChangesRequested:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -325,6 +326,67 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
handleApprovalDecision(w, r, "revoke")
|
||||
}
|
||||
|
||||
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
|
||||
// counter_payload is an entity-shaped jsonb of the approver's edited
|
||||
// values (allowlist enforced server-side); note is the optional free-text
|
||||
// explanation. The service rejects the call with
|
||||
// ErrSuggestionRequiresChange when both are no-ops (counter is identical
|
||||
// to the old row's payload AND note is empty).
|
||||
type suggestChangesBody struct {
|
||||
CounterPayload map[string]any `json:"counter_payload"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// POST /api/approval-requests/{id}/suggest-changes — t-paliad-216.
|
||||
//
|
||||
// In one transaction: close the pending request as 'changes_requested'
|
||||
// (with the caller's note + counter_payload on the row), revert the entity
|
||||
// from pre_image, then spawn a NEW pending approval_request authored by
|
||||
// the caller carrying the counter_payload. Returns the new request id.
|
||||
//
|
||||
// Status mapping (see writeApprovalError → mapApprovalError):
|
||||
//
|
||||
// 400 suggestion_requires_change — counter == old payload AND no note
|
||||
// 400 suggestion_lifecycle_invalid — old row's lifecycle ∉ (update, complete)
|
||||
// 403 self_approval_blocked — caller == old row's requested_by
|
||||
// 403 not_authorized — caller doesn't satisfy canApprove
|
||||
// 404 — request not found / not visible
|
||||
// 409 request_not_pending — old row already decided
|
||||
// 409 no_qualified_approver — deadlock on the new row
|
||||
func handleSuggestChangesApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requestID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
||||
return
|
||||
}
|
||||
var body suggestChangesBody
|
||||
if r.Body != nil && r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"code": "invalid_body",
|
||||
"message": "Ungültiger Body.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
newID, err := dbSvc.approval.SuggestChanges(r.Context(), requestID, uid, body.CounterPayload, body.Note)
|
||||
if err != nil {
|
||||
writeApprovalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
"new_request_id": newID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func handleApprovalDecision(w http.ResponseWriter, r *http.Request, action string) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
|
||||
@@ -82,6 +82,44 @@ func TestMapApprovalError_MissReturnsFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapApprovalError_SuggestionRequiresChange400 pins t-paliad-216:
|
||||
// a no-op suggest-changes (no counter diff + no note) surfaces as a 400
|
||||
// with code suggestion_requires_change so the frontend can disable the
|
||||
// submit button instead of letting the user click into a dead-end alert.
|
||||
func TestMapApprovalError_SuggestionRequiresChange400(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
if !mapApprovalError(w, services.ErrSuggestionRequiresChange) {
|
||||
t.Fatal("mapApprovalError returned false for ErrSuggestionRequiresChange")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["code"] != "suggestion_requires_change" {
|
||||
t.Errorf("code = %q, want suggestion_requires_change", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapApprovalError_SuggestionLifecycleInvalid400 pins t-paliad-216:
|
||||
// suggest-changes on a create/delete lifecycle is rejected with a clean
|
||||
// 400 + code suggestion_lifecycle_invalid so the frontend can hide the
|
||||
// button for those rows.
|
||||
func TestMapApprovalError_SuggestionLifecycleInvalid400(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
if !mapApprovalError(w, services.ErrSuggestionLifecycleInvalid) {
|
||||
t.Fatal("mapApprovalError returned false for ErrSuggestionLifecycleInvalid")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["code"] != "suggestion_lifecycle_invalid" {
|
||||
t.Errorf("code = %q, want suggestion_lifecycle_invalid", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseInboxFilter_DropsUnknownStatus pins t-paliad-160 §D regression
|
||||
// hardening: a stray ?status=foo from a stale frontend build (or an
|
||||
// attacker scoping us out of our own list) must NOT shadow rows out of
|
||||
@@ -97,6 +135,7 @@ func TestParseInboxFilter_DropsUnknownStatus(t *testing.T) {
|
||||
{"rejected", "rejected"},
|
||||
{"revoked", "revoked"},
|
||||
{"superseded", "superseded"},
|
||||
{"changes_requested", "changes_requested"}, // t-paliad-216
|
||||
{"foo", ""}, // unknown — dropped
|
||||
{"DROP+TABLE", ""}, // hostile — dropped
|
||||
{"PENDING", ""}, // case mismatch — dropped (we don't normalise)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
@@ -21,6 +22,19 @@ func noCacheAssets(h http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// patentstyleDownload sets a Content-Disposition with the spaced filename
|
||||
// "HL Patents Style.dotm" for .dotm requests under /patentstyle/. The URL
|
||||
// path stays clean (dashes), browsers and download tools land the file
|
||||
// with the name PAs expect to see.
|
||||
func patentstyleDownload(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ".dotm") {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="HL Patents Style.dotm"`)
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// noCachePages wraps a handler so its response always revalidates. Combined
|
||||
// with the build-time `?v=<buildVersion>` stamp on /assets/*.js and /css URLs
|
||||
// in dist/*.html, this is what makes a deploy actually reach users: the HTML
|
||||
@@ -71,6 +85,16 @@ type Services struct {
|
||||
Pin *services.PinService
|
||||
CardLayout *services.CardLayoutService
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
// Submission generator (t-paliad-215) — Klageerwiderung &
|
||||
// friends. Three coordinated services: registry fetches templates
|
||||
// from Gitea; vars builds the placeholder map from project +
|
||||
// parties + rule; renderer merges the .docx. Wired together in
|
||||
// cmd/server/main.go; nil here when DATABASE_URL is unset.
|
||||
SubmissionRegistry *services.TemplateRegistry
|
||||
SubmissionVars *services.SubmissionVarsService
|
||||
SubmissionRenderer *services.SubmissionRenderer
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
@@ -88,6 +112,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
paliadinSvc = svc.Paliadin
|
||||
}
|
||||
|
||||
// Submission generator singletons (t-paliad-215). All three or
|
||||
// none — the handler short-circuits with 503 when any is nil.
|
||||
if svc != nil {
|
||||
submissionRegistry = svc.SubmissionRegistry
|
||||
submissionVars = svc.SubmissionVars
|
||||
submissionRenderer = svc.SubmissionRenderer
|
||||
}
|
||||
|
||||
if svc != nil {
|
||||
dbSvc = &dbServices{
|
||||
projects: svc.Project,
|
||||
@@ -125,9 +157,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)
|
||||
@@ -163,8 +207,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// 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")))))
|
||||
// is never stale after a release. patentstyleDownload renames the .dotm
|
||||
// to "HL Patents Style.dotm" (with spaces) on download — the on-disk
|
||||
// filename has dashes so the URL is clean, but Word users expect the
|
||||
// spaced name in their downloads folder.
|
||||
mux.Handle("GET /patentstyle/", noCacheAssets(patentstyleDownload(http.StripPrefix("/patentstyle/", http.FileServer(http.Dir("dist/patentstyle"))))))
|
||||
|
||||
// Protected routes
|
||||
protected := http.NewServeMux()
|
||||
@@ -240,6 +287,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/milestone", handleCreateProjectTimelineMilestone)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/anchor", handleProjectTimelineAnchor)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/skip", handleProjectTimelineSkip)
|
||||
// t-paliad-215 Slice 1 — submission generator. /submissions lists
|
||||
// the project's filing-type rules with template-availability flags;
|
||||
// /submissions/{code}/generate streams the rendered .docx.
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions", handleListProjectSubmissions)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
|
||||
// /counterclaim creates a CCR sub-project linked via the new
|
||||
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
|
||||
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
|
||||
@@ -340,6 +392,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)
|
||||
@@ -509,6 +565,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
|
||||
|
||||
// t-paliad-154 — form-time effective policy lookup. Reachable by
|
||||
// every authenticated user (NOT admin-gated) so deadline +
|
||||
|
||||
@@ -52,6 +52,7 @@ type dbServices struct {
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
@@ -169,6 +170,18 @@ func mapApprovalError(w http.ResponseWriter, err error) bool {
|
||||
"message": "Die Anfrage ist nicht mehr offen.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrSuggestionRequiresChange):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"code": "suggestion_requires_change",
|
||||
"message": "Ein Vorschlag braucht entweder geänderte Werte oder einen Kommentar.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrSuggestionLifecycleInvalid):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"code": "suggestion_lifecycle_invalid",
|
||||
"message": "Änderungen vorschlagen ist nur für Update- und Complete-Anfragen möglich.",
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
387
internal/handlers/submissions.go
Normal file
387
internal/handlers/submissions.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package handlers
|
||||
|
||||
// Submission generator HTTP layer (t-paliad-215 Slice 1).
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// GET /api/projects/{id}/submissions
|
||||
// Lists the project's proceeding-relevant submission codes
|
||||
// and reports template availability for each. Powers the
|
||||
// SubmissionsPanel on the project detail page.
|
||||
//
|
||||
// GET /api/projects/{id}/submissions/{code}/generate
|
||||
// Renders the .docx and streams it as an attachment download.
|
||||
// Writes one paliad.system_audit_log row and one
|
||||
// paliad.project_events row per generation. No server-side
|
||||
// binary persistence (design §3, m's Q3 pick).
|
||||
//
|
||||
// Visibility: every endpoint runs through ProjectService.GetByID
|
||||
// (paliad.can_see_project gate). Unauthorised callers get 404, never
|
||||
// 403 — same convention as the rest of the project surfaces (avoids
|
||||
// project-existence enumeration).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// submissionRenderer + registry + vars are package-level singletons
|
||||
// wired by Register() once at boot. Stateless rendering + thread-safe
|
||||
// caches inside the registry mean no per-request construction.
|
||||
var (
|
||||
submissionRenderer *services.SubmissionRenderer
|
||||
submissionRegistry *services.TemplateRegistry
|
||||
submissionVars *services.SubmissionVarsService
|
||||
)
|
||||
|
||||
// submissionRenderTimeout caps a single generate request. Template
|
||||
// fetch (cache-miss) + rendering of a typical pleading takes well
|
||||
// under a second; the timeout exists to surface "Gitea is unreachable"
|
||||
// quickly rather than letting the browser spin.
|
||||
const submissionRenderTimeout = 30 * time.Second
|
||||
|
||||
// docxMime is the .docx Content-Type per the OOXML spec.
|
||||
const docxMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
|
||||
// submissionListEntry is one row in the SubmissionsPanel.
|
||||
type submissionListEntry struct {
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
}
|
||||
|
||||
// submissionListResponse wraps the list with a project-level header.
|
||||
type submissionListResponse struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
Entries []submissionListEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// handleListProjectSubmissions returns the filing-type rules for the
|
||||
// project's proceeding, annotated with template availability.
|
||||
func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSubmissionsWired(w) {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := submissionListResponse{
|
||||
ProjectID: projectID,
|
||||
ProceedingTypeID: project.ProceedingTypeID,
|
||||
Entries: []submissionListEntry{},
|
||||
}
|
||||
|
||||
if project.ProceedingTypeID == nil {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := dbSvc.rules.List(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
log.Printf("submissions: list rules for proceeding %d: %v", *project.ProceedingTypeID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule.SubmissionCode == nil || *rule.SubmissionCode == "" {
|
||||
continue
|
||||
}
|
||||
if rule.EventType == nil || *rule.EventType != "filing" {
|
||||
// Hearings + decisions don't generate submissions. The
|
||||
// "Schriftsätze" panel only lists filings.
|
||||
continue
|
||||
}
|
||||
if rule.LifecycleState != "published" {
|
||||
continue
|
||||
}
|
||||
entry := submissionListEntry{
|
||||
SubmissionCode: *rule.SubmissionCode,
|
||||
Name: rule.Name,
|
||||
NameEN: rule.NameEN,
|
||||
HasTemplate: submissionRegistry.HasTemplate(ctx, *rule.SubmissionCode),
|
||||
}
|
||||
if rule.EventType != nil {
|
||||
entry.EventType = *rule.EventType
|
||||
}
|
||||
if rule.PrimaryParty != nil {
|
||||
entry.PrimaryParty = *rule.PrimaryParty
|
||||
}
|
||||
if rule.LegalSource != nil {
|
||||
entry.LegalSource = *rule.LegalSource
|
||||
}
|
||||
resp.Entries = append(resp.Entries, entry)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleGenerateProjectSubmission renders the .docx and streams it
|
||||
// back to the browser. Audits the generation; never persists the
|
||||
// rendered bytes server-side.
|
||||
func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSubmissionsWired(w) {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
submissionCode := strings.TrimSpace(r.PathValue("code"))
|
||||
if submissionCode == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "submission code required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
|
||||
defer cancel()
|
||||
|
||||
varsResult, err := submissionVars.Build(ctx, services.SubmissionVarsContext{
|
||||
UserID: uid,
|
||||
ProjectID: projectID,
|
||||
SubmissionCode: submissionCode,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := submissionRegistry.Resolve(ctx, submissionCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrNoTemplate) {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "no template available for this submission",
|
||||
"hint": "ask an admin to upload a .docx template under templates/_base/ in mWorkRepo",
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("submissions: template resolve for %s: %v", submissionCode, err)
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "template repository unreachable",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
missing := services.DefaultMissingMarker(varsResult.Lang)
|
||||
rendered, err := submissionRenderer.Render(tmpl.Bytes, varsResult.Placeholders, missing)
|
||||
if err != nil {
|
||||
log.Printf("submissions: render %s for project %s: %v", submissionCode, projectID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "render failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(varsResult, projectID)
|
||||
|
||||
// Audit + Verlauf writes. Best-effort with a background context so
|
||||
// the user still receives the download even if the audit insert
|
||||
// races a slow DB.
|
||||
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelBG()
|
||||
if err := writeSubmissionAuditRow(bgCtx, varsResult, tmpl, submissionCode); err != nil {
|
||||
log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
if err := writeSubmissionProjectEvent(bgCtx, varsResult, tmpl, submissionCode); err != nil {
|
||||
log.Printf("submissions: project_events insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
if err := writeSubmissionDocumentRow(bgCtx, varsResult, tmpl, submissionCode); err != nil {
|
||||
log.Printf("submissions: documents insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", docxMime)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(rendered)))
|
||||
w.Header().Set("X-Paliad-Template-Sha", tmpl.SHA)
|
||||
w.Header().Set("X-Paliad-Template-Tier", tmpl.FirmTier)
|
||||
if _, err := w.Write(rendered); err != nil {
|
||||
log.Printf("submissions: response write failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
// requireSubmissionsWired returns false (and writes 503) when the
|
||||
// generator wasn't constructed at boot. Happens in DATABASE_URL-less
|
||||
// deployments — knowledge-platform-only stacks don't ship the
|
||||
// submission engine.
|
||||
func requireSubmissionsWired(w http.ResponseWriter) bool {
|
||||
if submissionRenderer == nil || submissionRegistry == nil || submissionVars == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "submission generator not configured",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// submissionFileName builds the user-facing filename per design §7:
|
||||
//
|
||||
// {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx
|
||||
//
|
||||
// Slashes and backslashes in case_number sanitise to underscores so
|
||||
// the file saves cleanly across Windows + macOS + Linux. Missing
|
||||
// case_number falls back to an 8-hex-char stable id from the project
|
||||
// UUID so the file still has a deterministic handle.
|
||||
func submissionFileName(vars *services.SubmissionVarsResult, projectID uuid.UUID) string {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
ruleName := strings.TrimSpace(vars.Rule.Name)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
ruleName = strings.TrimSpace(vars.Rule.NameEN)
|
||||
}
|
||||
if ruleName == "" {
|
||||
ruleName = "submission"
|
||||
}
|
||||
caseNo := ""
|
||||
if vars.Project != nil && vars.Project.CaseNumber != nil {
|
||||
caseNo = strings.TrimSpace(*vars.Project.CaseNumber)
|
||||
}
|
||||
if caseNo == "" {
|
||||
caseNo = projectID.String()[:8]
|
||||
}
|
||||
caseNo = strings.ReplaceAll(caseNo, "/", "_")
|
||||
caseNo = strings.ReplaceAll(caseNo, `\`, "_")
|
||||
return fmt.Sprintf("%s-%s-%s.docx", ruleName, caseNo, day.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// writeSubmissionAuditRow files the org-wide audit entry. Reuses the
|
||||
// system_audit_log convention (event_type='submission.generated')
|
||||
// established in t-paliad-214's mig 102.
|
||||
func writeSubmissionAuditRow(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
|
||||
meta := map[string]any{
|
||||
"submission_code": code,
|
||||
"template_path": tmpl.Path,
|
||||
"template_sha": tmpl.SHA,
|
||||
"template_tier": tmpl.FirmTier,
|
||||
"project_id": vars.Project.ID.String(),
|
||||
"rule_id": vars.Rule.ID.String(),
|
||||
"firm": branding.Name,
|
||||
}
|
||||
body, _ := json.Marshal(meta)
|
||||
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
||||
`INSERT INTO paliad.system_audit_log
|
||||
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||
VALUES ('submission.generated', $1, $2, 'project', $3, $4::jsonb)`,
|
||||
vars.User.ID, vars.User.Email, vars.Project.ID.String(), string(body),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeSubmissionProjectEvent surfaces the generation in the project
|
||||
// Verlauf / SmartTimeline. event_type stays free-text (no CHECK on
|
||||
// paliad.project_events.event_type per Slice 2 of SmartTimeline) so we
|
||||
// don't need a migration to introduce 'submission_generated'.
|
||||
func writeSubmissionProjectEvent(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
|
||||
ruleName := strings.TrimSpace(vars.Rule.Name)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
ruleName = strings.TrimSpace(vars.Rule.NameEN)
|
||||
}
|
||||
title := fmt.Sprintf("%s generiert", ruleName)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
title = fmt.Sprintf("%s generated", ruleName)
|
||||
}
|
||||
meta := map[string]any{
|
||||
"submission_code": code,
|
||||
"template_path": tmpl.Path,
|
||||
"template_sha": tmpl.SHA,
|
||||
"template_tier": tmpl.FirmTier,
|
||||
"rule_id": vars.Rule.ID.String(),
|
||||
}
|
||||
body, _ := json.Marshal(meta)
|
||||
now := time.Now().UTC()
|
||||
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, description, event_date,
|
||||
created_by, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, 'submission_generated', $3, NULL, $4, $5, $6::jsonb, $4, $4)`,
|
||||
uuid.New(), vars.Project.ID, title, now, vars.User.ID, string(body),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeSubmissionDocumentRow files the audit-only paliad.documents
|
||||
// row. file_path stays NULL — the bytes are regenerable from inputs
|
||||
// (m's Q3 pick: no server-side binary). doc_type='generated_submission'
|
||||
// is the additive marker; no CHECK constraint exists on doc_type, so
|
||||
// this requires no migration.
|
||||
func writeSubmissionDocumentRow(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
|
||||
ruleName := strings.TrimSpace(vars.Rule.Name)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
ruleName = strings.TrimSpace(vars.Rule.NameEN)
|
||||
}
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
title := fmt.Sprintf("%s (generiert %s)", ruleName, day.Format("2006-01-02"))
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
title = fmt.Sprintf("%s (generated %s)", ruleName, day.Format("2006-01-02"))
|
||||
}
|
||||
provenance := map[string]any{
|
||||
"submission_code": code,
|
||||
"template_path": tmpl.Path,
|
||||
"template_sha": tmpl.SHA,
|
||||
"template_tier": tmpl.FirmTier,
|
||||
"firm": branding.Name,
|
||||
"rule_id": vars.Rule.ID.String(),
|
||||
}
|
||||
body, _ := json.Marshal(provenance)
|
||||
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
||||
`INSERT INTO paliad.documents
|
||||
(id, project_id, title, doc_type, file_path, file_size, mime_type,
|
||||
ai_extracted, uploaded_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'generated_submission', NULL, NULL, $4, $5::jsonb, $6, now(), now())`,
|
||||
uuid.New(), vars.Project.ID, title, docxMime, string(body), vars.User.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -805,6 +805,15 @@ type ApprovalRequest struct {
|
||||
// alongside 👀 with a sparkle ✨ on the eye-pill surface.
|
||||
RequesterKind string `db:"requester_kind" json:"requester_kind"`
|
||||
AgentTurnID *uuid.UUID `db:"agent_turn_id" json:"agent_turn_id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// CounterPayload carries the approver's edited values on a
|
||||
// changes_requested row (mig 103, t-paliad-216). NULL for every
|
||||
// other status. Frontend renders it as a diff against the OLD
|
||||
// payload to show "approver suggested X→Y on the following fields".
|
||||
CounterPayload NullableJSON `db:"counter_payload" json:"counter_payload,omitempty"`
|
||||
// PreviousRequestID is the back-pointer from a row spawned by
|
||||
// SuggestChanges to the prior changes_requested row that birthed it
|
||||
// (mig 103, t-paliad-216). NULL on first-attempt rows.
|
||||
PreviousRequestID *uuid.UUID `db:"previous_request_id" json:"previous_request_id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Package offices is the single source of truth for the firm's office list.
|
||||
//
|
||||
// The keys here must stay in sync with the CHECK constraint on
|
||||
// paliad.users.office and paliad.akten.owning_office (migration 001).
|
||||
// The keys here must stay in sync with the CHECK constraints on
|
||||
// paliad.users.office (mig 002) and paliad.partner_units.office
|
||||
// (mig 018, renamed mig 024 + mig 027). Madrid added mig 106.
|
||||
package offices
|
||||
|
||||
// Office is a single firm office with its i18n-ready labels.
|
||||
@@ -20,6 +21,7 @@ var All = []Office{
|
||||
{Key: "london", LabelDE: "London", LabelEN: "London"},
|
||||
{Key: "paris", LabelDE: "Paris", LabelEN: "Paris"},
|
||||
{Key: "milan", LabelDE: "Mailand", LabelEN: "Milan"},
|
||||
{Key: "madrid", LabelDE: "Madrid", LabelEN: "Madrid"},
|
||||
}
|
||||
|
||||
// IsValid reports whether the given key names a known office.
|
||||
|
||||
@@ -3,7 +3,7 @@ package offices
|
||||
import "testing"
|
||||
|
||||
func TestIsValid(t *testing.T) {
|
||||
for _, key := range []string{"munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"} {
|
||||
for _, key := range []string{"munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"} {
|
||||
if !IsValid(key) {
|
||||
t.Errorf("IsValid(%q) = false, want true", key)
|
||||
}
|
||||
|
||||
@@ -61,11 +61,12 @@ const (
|
||||
|
||||
// RequestStatus values on paliad.approval_requests.status.
|
||||
const (
|
||||
RequestStatusPending = "pending"
|
||||
RequestStatusApproved = "approved"
|
||||
RequestStatusRejected = "rejected"
|
||||
RequestStatusRevoked = "revoked"
|
||||
RequestStatusSuperseded = "superseded"
|
||||
RequestStatusPending = "pending"
|
||||
RequestStatusApproved = "approved"
|
||||
RequestStatusRejected = "rejected"
|
||||
RequestStatusRevoked = "revoked"
|
||||
RequestStatusSuperseded = "superseded"
|
||||
RequestStatusChangesRequested = "changes_requested"
|
||||
)
|
||||
|
||||
// DecisionKind discriminates 'peer' (normal in-team sign-off) from
|
||||
@@ -158,12 +159,14 @@ func IsValidResponsibility(r string) bool {
|
||||
// ErrRequestNotPending -> 409
|
||||
// ErrUnknownEntityType -> 500 (programming error)
|
||||
var (
|
||||
ErrSelfApproval = errors.New("self-approval blocked")
|
||||
ErrNoQualifiedApprover = errors.New("no qualified approver available")
|
||||
ErrConcurrentPending = errors.New("entity already has a pending approval request")
|
||||
ErrNotApprover = errors.New("not authorized to approve this request")
|
||||
ErrRequestNotPending = errors.New("request is not pending")
|
||||
ErrUnknownEntityType = errors.New("unknown entity type")
|
||||
ErrSelfApproval = errors.New("self-approval blocked")
|
||||
ErrNoQualifiedApprover = errors.New("no qualified approver available")
|
||||
ErrConcurrentPending = errors.New("entity already has a pending approval request")
|
||||
ErrNotApprover = errors.New("not authorized to approve this request")
|
||||
ErrRequestNotPending = errors.New("request is not pending")
|
||||
ErrUnknownEntityType = errors.New("unknown entity type")
|
||||
ErrSuggestionRequiresChange = errors.New("suggestion requires a counter_payload diff or a note")
|
||||
ErrSuggestionLifecycleInvalid = errors.New("suggest-changes is only valid for update / complete lifecycles")
|
||||
)
|
||||
|
||||
// PendingApprovalError wraps ErrConcurrentPending with the in-flight
|
||||
|
||||
@@ -35,6 +35,7 @@ package services
|
||||
// pool, so the deadlock path can't be silently bypassed.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
@@ -363,6 +364,267 @@ func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.U
|
||||
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
|
||||
}
|
||||
|
||||
// SuggestChanges is the fourth approval action (t-paliad-216). The caller
|
||||
// proposes a counter-payload + optional free-text note; in one transaction
|
||||
// we close the old request as 'changes_requested', revert the entity from
|
||||
// pre_image, then immediately spawn a NEW 'pending' approval_request
|
||||
// authored by the caller carrying counter_payload as the new payload. The
|
||||
// new row enters the normal pending flow — anyone eligible (including the
|
||||
// original requester) can approve, reject, or suggest changes back on it.
|
||||
// 4-Augen still holds: the suggesting caller is now the new row's
|
||||
// requested_by, so self-approval is blocked by the standard 3-layer guard.
|
||||
//
|
||||
// Authorization is the same as Approve/Reject on the OLD row (canApprove).
|
||||
// The new row's deadlock check (qualified-approver-exists-other-than-
|
||||
// caller) runs before the new INSERT so we never spawn an unapprovable
|
||||
// request.
|
||||
//
|
||||
// counterPayload must differ from the old row's payload OR a non-empty
|
||||
// note must be present — a no-op suggestion (same values, no note) is
|
||||
// indistinguishable from "I have no opinion" and is rejected with
|
||||
// ErrSuggestionRequiresChange. counterPayload field shape is the same
|
||||
// allowlist used by Submit*/applyRevert (the date-bearing columns per
|
||||
// entity_type); unknown keys are silently dropped at apply time.
|
||||
//
|
||||
// SuggestChanges is only valid for lifecycle in (update, complete). For
|
||||
// create the original entity would be deleted by applyRevert, leaving no
|
||||
// row to apply a counter to. For delete the original is "remove this
|
||||
// entity" — a counter-proposal would be a different lifecycle entirely.
|
||||
// Both return ErrSuggestionLifecycleInvalid; the caller (handler) maps
|
||||
// it to 400.
|
||||
//
|
||||
// Returns the new request ID on success.
|
||||
func (s *ApprovalService) SuggestChanges(ctx context.Context, requestID, callerID uuid.UUID, counterPayload map[string]any, note string) (*uuid.UUID, error) {
|
||||
trimmedNote := strings.TrimSpace(note)
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
old, err := s.getRequestForUpdate(ctx, tx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if old.Status != RequestStatusPending {
|
||||
return nil, fmt.Errorf("%w: status=%s", ErrRequestNotPending, old.Status)
|
||||
}
|
||||
if old.LifecycleEvent != LifecycleUpdate && old.LifecycleEvent != LifecycleComplete {
|
||||
return nil, fmt.Errorf("%w: lifecycle=%s", ErrSuggestionLifecycleInvalid, old.LifecycleEvent)
|
||||
}
|
||||
|
||||
// No-op guard: counter must differ from old.payload OR note must be present.
|
||||
payloadDiffers, err := payloadsDiffer(old.Payload, counterPayload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !payloadDiffers && trimmedNote == "" {
|
||||
return nil, ErrSuggestionRequiresChange
|
||||
}
|
||||
|
||||
// Authorization on the OLD row: caller must satisfy canApprove (same
|
||||
// gate as Approve/Reject). Self-approval blocks here too.
|
||||
decisionKind, err := s.canApprove(ctx, tx, callerID, old)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
counterJSON, err := marshalJSONOrNull(counterPayload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal counter_payload: %w", err)
|
||||
}
|
||||
|
||||
// Validate counter has at least one allowlisted field for the entity
|
||||
// type — otherwise the entity-update below would be a no-op and the
|
||||
// new row would just resubmit the SAME values, which is a degenerate
|
||||
// case we should reject cleanly. Only run this check when the
|
||||
// payload "differs" (i.e. caller actually provided something).
|
||||
if payloadDiffers {
|
||||
if _, _, err := buildRevertSetClauses(old.EntityType, counterPayload); err != nil {
|
||||
// ErrUnknownEntityType wraps "empty pre_image for X" when no
|
||||
// allowlisted key is present. Rebrand as suggestion-input
|
||||
// failure for the handler's 400 mapping.
|
||||
return nil, fmt.Errorf("%w: %v", ErrSuggestionRequiresChange, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Close the OLD row as changes_requested.
|
||||
var noteArg any
|
||||
if trimmedNote != "" {
|
||||
noteArg = trimmedNote
|
||||
}
|
||||
updateOldSQL := `UPDATE paliad.approval_requests
|
||||
SET status = $1, decided_by = $2, decided_at = $3, decision_kind = $4,
|
||||
decision_note = $5, counter_payload = $6, updated_at = $3
|
||||
WHERE id = $7`
|
||||
if _, err := tx.ExecContext(ctx, updateOldSQL,
|
||||
RequestStatusChangesRequested, callerID, now, decisionKind,
|
||||
noteArg, counterJSON, requestID); err != nil {
|
||||
return nil, fmt.Errorf("close old request: %w", err)
|
||||
}
|
||||
|
||||
// 2. Revert the entity from old.pre_image (same as Reject).
|
||||
if err := s.applyRevert(ctx, tx, old); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Deadlock check on the NEW row: someone other than the caller
|
||||
// must be qualified to approve. Original requester is no longer
|
||||
// excluded (they're a regular team member now from the new row's
|
||||
// POV), so they count if their role is sufficient.
|
||||
ok, err := s.hasQualifiedApprover(ctx, tx, old.ProjectID, callerID, old.RequiredRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, old.RequiredRole)
|
||||
}
|
||||
|
||||
// 4. Re-apply the counter_payload to the entity row (write-then-approve).
|
||||
// Reuses buildRevertSetClauses (date-allowlist translation). Always
|
||||
// runs because we validated payloadDiffers + a valid set of keys
|
||||
// above; even when only a note was provided (payloadDiffers=false),
|
||||
// the original payload is re-applied for symmetry with Submit*.
|
||||
applyPayload := counterPayload
|
||||
if !payloadDiffers {
|
||||
// Counter is identical to original — resubmit the same values as
|
||||
// the new row's payload so the standard Submit* shape holds.
|
||||
if err := json.Unmarshal(old.Payload, &applyPayload); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal original payload: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.applyEntityUpdate(ctx, tx, old.EntityType, old.EntityID, applyPayload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. INSERT the NEW pending row, authored by the caller, with
|
||||
// previous_request_id pointing back at the old row.
|
||||
newID := uuid.New()
|
||||
applyPayloadJSON, err := marshalJSONOrNull(applyPayload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal new payload: %w", err)
|
||||
}
|
||||
insertNewSQL := `INSERT INTO paliad.approval_requests
|
||||
(id, project_id, entity_type, entity_id, lifecycle_event,
|
||||
pre_image, payload, requested_by, required_role, status,
|
||||
requester_kind, agent_turn_id, previous_request_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', 'user', NULL, $10)`
|
||||
if _, err := tx.ExecContext(ctx, insertNewSQL,
|
||||
newID, old.ProjectID, old.EntityType, old.EntityID, old.LifecycleEvent,
|
||||
[]byte(old.PreImage), applyPayloadJSON, callerID, old.RequiredRole,
|
||||
requestID); err != nil {
|
||||
return nil, fmt.Errorf("insert new approval_request: %w", err)
|
||||
}
|
||||
|
||||
// 6. Mark the entity pending pointing at the new row.
|
||||
updateEntitySQL := fmt.Sprintf(`UPDATE paliad.%s
|
||||
SET approval_status = 'pending', pending_request_id = $1, updated_at = now()
|
||||
WHERE id = $2 AND approval_status IN ('approved','legacy')`,
|
||||
entityTableName(old.EntityType))
|
||||
res, err := tx.ExecContext(ctx, updateEntitySQL, newID, old.EntityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mark entity pending: %w", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows != 1 {
|
||||
return nil, ErrConcurrentPending
|
||||
}
|
||||
|
||||
// 7. Emit *_approval_changes_suggested for the OLD row's transition.
|
||||
suggestedEvent := approvalEventType(old.EntityType, "changes_suggested")
|
||||
suggestedDesc := approvalDescription("changes_suggested", old.RequiredRole, old.LifecycleEvent)
|
||||
suggestedMeta := map[string]any{
|
||||
"approval_request_id": requestID.String(),
|
||||
"new_request_id": newID.String(),
|
||||
"lifecycle_event": old.LifecycleEvent,
|
||||
"decision_kind": decisionKind,
|
||||
old.EntityType + "_id": old.EntityID.String(),
|
||||
}
|
||||
if trimmedNote != "" {
|
||||
suggestedMeta["decision_note"] = trimmedNote
|
||||
}
|
||||
if counterJSON != nil {
|
||||
suggestedMeta["counter_payload"] = json.RawMessage(counterJSON)
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, old.ProjectID, callerID, suggestedEvent, suggestedEvent, suggestedDesc, suggestedMeta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8. Emit *_approval_requested for the NEW row (same shape as Submit*).
|
||||
requestedEvent := approvalEventType(old.EntityType, "requested")
|
||||
requestedDesc := approvalDescription("requested", old.RequiredRole, old.LifecycleEvent)
|
||||
requestedMeta := map[string]any{
|
||||
"approval_request_id": newID.String(),
|
||||
"previous_request_id": requestID.String(),
|
||||
"lifecycle_event": old.LifecycleEvent,
|
||||
"required_role": old.RequiredRole,
|
||||
"requester_kind": "user",
|
||||
old.EntityType + "_id": old.EntityID.String(),
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, old.ProjectID, callerID, requestedEvent, requestedEvent, requestedDesc, requestedMeta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return &newID, nil
|
||||
}
|
||||
|
||||
// applyEntityUpdate writes the allowlisted fields from payload onto the
|
||||
// entity row. Mirrors the write side of write-then-approve (which lives in
|
||||
// DeadlineService / AppointmentService for the user-driven path) — used
|
||||
// by SuggestChanges to apply an approver's counter-proposal back onto the
|
||||
// entity inside the same tx. Reuses buildRevertSetClauses for the
|
||||
// jsonb-key-to-SQL-SET translation so the allowlist is one source of
|
||||
// truth.
|
||||
func (s *ApprovalService) applyEntityUpdate(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID, payload map[string]any) error {
|
||||
if len(payload) == 0 {
|
||||
return fmt.Errorf("%w: empty payload", ErrSuggestionRequiresChange)
|
||||
}
|
||||
setClauses, args, err := buildRevertSetClauses(entityType, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setClauses = append(setClauses, "updated_at = now()")
|
||||
args = append(args, entityID)
|
||||
q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
|
||||
entityTableName(entityType), strings.Join(setClauses, ", "), len(args))
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return fmt.Errorf("apply counter payload to entity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// payloadsDiffer returns true iff the candidate counter map decodes to a
|
||||
// value that differs from the old row's payload jsonb. Used by
|
||||
// SuggestChanges to detect "no-op suggestion". Both NULL or both empty
|
||||
// map = identical → false. Comparison is by canonical re-marshal so
|
||||
// jsonb-key-ordering doesn't poison the equality check.
|
||||
func payloadsDiffer(old models.NullableJSON, candidate map[string]any) (bool, error) {
|
||||
if len(candidate) == 0 && len(old) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if len(candidate) == 0 || len(old) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
var oldMap map[string]any
|
||||
if err := json.Unmarshal(old, &oldMap); err != nil {
|
||||
return false, fmt.Errorf("unmarshal old payload: %w", err)
|
||||
}
|
||||
oldCanonical, err := json.Marshal(oldMap)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("re-marshal old payload: %w", err)
|
||||
}
|
||||
candCanonical, err := json.Marshal(candidate)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal candidate payload: %w", err)
|
||||
}
|
||||
return !bytes.Equal(oldCanonical, candCanonical), nil
|
||||
}
|
||||
|
||||
// decide is the shared kernel for Approve / Reject / Revoke. The decision
|
||||
// kind is derived from the (caller, request) relationship and the requested
|
||||
// final status:
|
||||
@@ -692,6 +954,8 @@ func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx,
|
||||
q := `SELECT id, project_id, entity_type, entity_id, lifecycle_event,
|
||||
pre_image, payload, requested_by, requested_at, required_role,
|
||||
status, decided_by, decided_at, decision_kind, decision_note,
|
||||
requester_kind, agent_turn_id,
|
||||
counter_payload, previous_request_id,
|
||||
created_at, updated_at
|
||||
FROM paliad.approval_requests
|
||||
WHERE id = $1
|
||||
@@ -816,14 +1080,20 @@ func marshalJSONOrNull(m map[string]any) ([]byte, error) {
|
||||
// server would reject, replacing the previous click-then-alert UX.
|
||||
type ApprovalRequestView struct {
|
||||
models.ApprovalRequest
|
||||
ProjectTitle string `db:"project_title" json:"project_title"`
|
||||
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
|
||||
RequesterName string `db:"requester_name" json:"requester_name"`
|
||||
RequesterEmail string `db:"requester_email" json:"requester_email"`
|
||||
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
|
||||
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
|
||||
ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"`
|
||||
ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"`
|
||||
ProjectTitle string `db:"project_title" json:"project_title"`
|
||||
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
|
||||
RequesterName string `db:"requester_name" json:"requester_name"`
|
||||
RequesterEmail string `db:"requester_email" json:"requester_email"`
|
||||
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
|
||||
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
|
||||
ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"`
|
||||
ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"`
|
||||
// NextRequestID is the forward-pointer from a changes_requested row
|
||||
// to the new pending row spawned by SuggestChanges (t-paliad-216).
|
||||
// Hydrated via correlated subquery on previous_request_id; the
|
||||
// partial index approval_requests_previous_idx keeps the lookup O(1).
|
||||
// NULL on every row that hasn't been counter-proposed.
|
||||
NextRequestID *uuid.UUID `db:"next_request_id" json:"next_request_id,omitempty"`
|
||||
}
|
||||
|
||||
// approvalEligibilitySQL is the SELECT-and-WHERE-compatible boolean
|
||||
@@ -875,6 +1145,7 @@ const approvalRequestViewColumns = `
|
||||
ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
|
||||
ar.status, ar.decided_by, ar.decided_at, ar.decision_kind, ar.decision_note,
|
||||
ar.requester_kind, ar.agent_turn_id,
|
||||
ar.counter_payload, ar.previous_request_id,
|
||||
ar.created_at, ar.updated_at,
|
||||
p.title AS project_title,
|
||||
CASE WHEN ar.entity_type = 'deadline' THEN d.title
|
||||
@@ -885,7 +1156,11 @@ const approvalRequestViewColumns = `
|
||||
du.display_name AS decider_name,
|
||||
du.email AS decider_email,
|
||||
(ar.status = 'pending' AND ar.requested_by <> $1 AND ` + approvalEligibilitySQL + `) AS viewer_can_approve,
|
||||
(ar.requested_by = $1) AS viewer_is_requester`
|
||||
(ar.requested_by = $1) AS viewer_is_requester,
|
||||
(SELECT nxt.id FROM paliad.approval_requests nxt
|
||||
WHERE nxt.previous_request_id = ar.id
|
||||
ORDER BY nxt.requested_at DESC
|
||||
LIMIT 1) AS next_request_id`
|
||||
|
||||
const approvalRequestViewJoins = `
|
||||
paliad.approval_requests ar
|
||||
|
||||
@@ -946,3 +946,393 @@ func TestApprovalService_ViewerFlags(t *testing.T) {
|
||||
t.Error("ListSubmittedByUser: viewer_is_requester = false on self-authored row, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SuggestChanges — t-paliad-216 Slice A. The fourth approval action: the
|
||||
// approver authors a counter-proposal which becomes a NEW pending row
|
||||
// requested by the approver. 4-Augen still holds via the standard
|
||||
// self-approval guard.
|
||||
// ============================================================================
|
||||
|
||||
// seedPendingUpdate spins up the {policy, deadline, pending update
|
||||
// request} triple SuggestChanges needs. Returns the deadline id, the
|
||||
// pending request id, and the pre-image due_date (so callers can assert
|
||||
// applyRevert restored it correctly).
|
||||
func (e *approvalTestEnv) seedPendingUpdate(t *testing.T) (uuid.UUID, uuid.UUID, time.Time) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
e.seedPolicy(EntityTypeDeadline, LifecycleUpdate, "associate")
|
||||
|
||||
originalDue := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
deadlineID := e.seedDeadline(originalDue)
|
||||
newDue := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
tx, err := e.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadlines SET due_date = $1 WHERE id = $2`,
|
||||
newDue, deadlineID); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("UPDATE pre-submit: %v", err)
|
||||
}
|
||||
preImage := map[string]any{"due_date": "2026-06-01"}
|
||||
payload := map[string]any{"due_date": "2026-06-15"}
|
||||
reqID, err := e.approvals.SubmitUpdate(ctx, tx, e.projectID, deadlineID, e.requester, EntityTypeDeadline, preImage, payload)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("SubmitUpdate: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
if reqID == nil {
|
||||
t.Fatal("SubmitUpdate returned nil request id")
|
||||
}
|
||||
return deadlineID, *reqID, originalDue
|
||||
}
|
||||
|
||||
// TestApprovalService_SuggestChanges_HappyPath: approver suggests a
|
||||
// different due_date + note. Expected end state:
|
||||
// - OLD request: status='changes_requested', decision_note set,
|
||||
// counter_payload set, decided_by=approver.
|
||||
// - Entity: approval_status='pending', pending_request_id points at
|
||||
// a NEW pending row, due_date == approver's counter_payload value.
|
||||
// - NEW request: status='pending', requested_by=approver,
|
||||
// payload=counter_payload, previous_request_id=OLD.
|
||||
// - Two project_events emitted: *_approval_changes_suggested and
|
||||
// *_approval_requested.
|
||||
func TestApprovalService_SuggestChanges_HappyPath(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
|
||||
|
||||
counterDue := time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)
|
||||
counter := map[string]any{"due_date": "2026-06-20"}
|
||||
newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "Bitte später, Raumkonflikt am 15.6.")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestChanges: %v", err)
|
||||
}
|
||||
if newReqID == nil {
|
||||
t.Fatal("expected new request id, got nil")
|
||||
}
|
||||
if *newReqID == oldReqID {
|
||||
t.Fatal("new request id must differ from old")
|
||||
}
|
||||
|
||||
// OLD row.
|
||||
oldRow := struct {
|
||||
Status string `db:"status"`
|
||||
DecidedBy *uuid.UUID `db:"decided_by"`
|
||||
DecidedAt *time.Time `db:"decided_at"`
|
||||
DecisionNote *string `db:"decision_note"`
|
||||
CounterPayload []byte `db:"counter_payload"`
|
||||
PreviousRequest *uuid.UUID `db:"previous_request_id"`
|
||||
DecisionKind *string `db:"decision_kind"`
|
||||
}{}
|
||||
if err := env.pool.GetContext(ctx, &oldRow,
|
||||
`SELECT status, decided_by, decided_at, decision_note, counter_payload,
|
||||
previous_request_id, decision_kind
|
||||
FROM paliad.approval_requests WHERE id = $1`, oldReqID); err != nil {
|
||||
t.Fatalf("read old row: %v", err)
|
||||
}
|
||||
if oldRow.Status != RequestStatusChangesRequested {
|
||||
t.Errorf("old row status = %q, want %q", oldRow.Status, RequestStatusChangesRequested)
|
||||
}
|
||||
if oldRow.DecidedBy == nil || *oldRow.DecidedBy != env.approver {
|
||||
t.Errorf("old row decided_by = %v, want %v", oldRow.DecidedBy, env.approver)
|
||||
}
|
||||
if oldRow.DecisionNote == nil || *oldRow.DecisionNote == "" {
|
||||
t.Error("old row decision_note should be set")
|
||||
}
|
||||
if len(oldRow.CounterPayload) == 0 {
|
||||
t.Error("old row counter_payload should be set")
|
||||
}
|
||||
if oldRow.PreviousRequest != nil {
|
||||
t.Errorf("old row previous_request_id = %v, want NULL", oldRow.PreviousRequest)
|
||||
}
|
||||
if oldRow.DecisionKind == nil || (*oldRow.DecisionKind != DecisionKindPeer && *oldRow.DecisionKind != DecisionKindAdminOverride) {
|
||||
t.Errorf("old row decision_kind = %v, want peer or admin_override", oldRow.DecisionKind)
|
||||
}
|
||||
|
||||
// NEW row.
|
||||
newRow := struct {
|
||||
Status string `db:"status"`
|
||||
RequestedBy uuid.UUID `db:"requested_by"`
|
||||
Payload []byte `db:"payload"`
|
||||
PreviousRequestID *uuid.UUID `db:"previous_request_id"`
|
||||
LifecycleEvent string `db:"lifecycle_event"`
|
||||
}{}
|
||||
if err := env.pool.GetContext(ctx, &newRow,
|
||||
`SELECT status, requested_by, payload, previous_request_id, lifecycle_event
|
||||
FROM paliad.approval_requests WHERE id = $1`, *newReqID); err != nil {
|
||||
t.Fatalf("read new row: %v", err)
|
||||
}
|
||||
if newRow.Status != RequestStatusPending {
|
||||
t.Errorf("new row status = %q, want pending", newRow.Status)
|
||||
}
|
||||
if newRow.RequestedBy != env.approver {
|
||||
t.Errorf("new row requested_by = %v, want %v (approver)", newRow.RequestedBy, env.approver)
|
||||
}
|
||||
if newRow.PreviousRequestID == nil || *newRow.PreviousRequestID != oldReqID {
|
||||
t.Errorf("new row previous_request_id = %v, want %v", newRow.PreviousRequestID, oldReqID)
|
||||
}
|
||||
if newRow.LifecycleEvent != LifecycleUpdate {
|
||||
t.Errorf("new row lifecycle = %q, want update", newRow.LifecycleEvent)
|
||||
}
|
||||
|
||||
// Entity: pending, due_date == counter.
|
||||
entity := struct {
|
||||
Status string `db:"approval_status"`
|
||||
PendingRequest *uuid.UUID `db:"pending_request_id"`
|
||||
DueDate time.Time `db:"due_date"`
|
||||
}{}
|
||||
if err := env.pool.GetContext(ctx, &entity,
|
||||
`SELECT approval_status, pending_request_id, due_date FROM paliad.deadlines WHERE id = $1`,
|
||||
deadlineID); err != nil {
|
||||
t.Fatalf("read entity: %v", err)
|
||||
}
|
||||
if entity.Status != "pending" {
|
||||
t.Errorf("entity approval_status = %q, want pending", entity.Status)
|
||||
}
|
||||
if entity.PendingRequest == nil || *entity.PendingRequest != *newReqID {
|
||||
t.Errorf("entity pending_request_id = %v, want %v", entity.PendingRequest, *newReqID)
|
||||
}
|
||||
if !entity.DueDate.Equal(counterDue) {
|
||||
t.Errorf("entity due_date = %v, want %v (counter)", entity.DueDate, counterDue)
|
||||
}
|
||||
|
||||
// Two project_events: one *_approval_changes_suggested + one *_approval_requested
|
||||
// for the NEW row.
|
||||
var nSuggested, nRequested int
|
||||
if err := env.pool.GetContext(ctx, &nSuggested,
|
||||
`SELECT COUNT(*) FROM paliad.project_events
|
||||
WHERE project_id = $1 AND event_type = 'deadline_approval_changes_suggested'`,
|
||||
env.projectID); err != nil {
|
||||
t.Fatalf("count changes_suggested events: %v", err)
|
||||
}
|
||||
if nSuggested != 1 {
|
||||
t.Errorf("expected 1 deadline_approval_changes_suggested event, got %d", nSuggested)
|
||||
}
|
||||
if err := env.pool.GetContext(ctx, &nRequested,
|
||||
`SELECT COUNT(*) FROM paliad.project_events
|
||||
WHERE project_id = $1 AND event_type = 'deadline_approval_requested'`,
|
||||
env.projectID); err != nil {
|
||||
t.Fatalf("count requested events: %v", err)
|
||||
}
|
||||
// Two requested events expected: one from the original SubmitUpdate +
|
||||
// one from the SuggestChanges spawn.
|
||||
if nRequested != 2 {
|
||||
t.Errorf("expected 2 deadline_approval_requested events (original + spawn), got %d", nRequested)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SuggestChanges_NoOpRejected: identical counter +
|
||||
// empty note returns ErrSuggestionRequiresChange.
|
||||
func TestApprovalService_SuggestChanges_NoOpRejected(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, oldReqID, _ := env.seedPendingUpdate(t)
|
||||
|
||||
// Same payload as the original SubmitUpdate. No note.
|
||||
identical := map[string]any{"due_date": "2026-06-15"}
|
||||
_, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, identical, "")
|
||||
if !errors.Is(err, ErrSuggestionRequiresChange) {
|
||||
t.Errorf("no-op suggest: got %v, want ErrSuggestionRequiresChange", err)
|
||||
}
|
||||
|
||||
// Empty counter, empty note → also rejected.
|
||||
_, err = env.approvals.SuggestChanges(ctx, oldReqID, env.approver, nil, "")
|
||||
if !errors.Is(err, ErrSuggestionRequiresChange) {
|
||||
t.Errorf("empty suggest: got %v, want ErrSuggestionRequiresChange", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SuggestChanges_NoteOnlyAccepted: when the counter
|
||||
// is unchanged but a non-empty note is present, the call succeeds. The
|
||||
// new row's payload equals the OLD payload (the approver said "I want a
|
||||
// fresh look from someone else; here's why", without a different value).
|
||||
func TestApprovalService_SuggestChanges_NoteOnlyAccepted(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
|
||||
|
||||
identical := map[string]any{"due_date": "2026-06-15"}
|
||||
newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, identical, "Bitte nochmal prüfen.")
|
||||
if err != nil {
|
||||
t.Fatalf("note-only suggest: %v", err)
|
||||
}
|
||||
if newReqID == nil {
|
||||
t.Fatal("expected new request id, got nil")
|
||||
}
|
||||
|
||||
// Entity's due_date stays at 2026-06-15 (the original counter == original payload).
|
||||
var got time.Time
|
||||
if err := env.pool.GetContext(ctx, &got,
|
||||
`SELECT due_date FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("read due_date: %v", err)
|
||||
}
|
||||
want := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("entity due_date = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SuggestChanges_SelfApprovalBlocked: the original
|
||||
// requester cannot suggest changes on their own row (would equal
|
||||
// self-approval).
|
||||
func TestApprovalService_SuggestChanges_SelfApprovalBlocked(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, oldReqID, _ := env.seedPendingUpdate(t)
|
||||
|
||||
counter := map[string]any{"due_date": "2026-06-20"}
|
||||
_, err := env.approvals.SuggestChanges(ctx, oldReqID, env.requester, counter, "")
|
||||
if !errors.Is(err, ErrSelfApproval) {
|
||||
t.Errorf("self suggest: got %v, want ErrSelfApproval", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SuggestChanges_RequestNotPending: a row already
|
||||
// decided (approved/rejected/revoked/changes_requested) rejects further
|
||||
// suggest-changes calls.
|
||||
func TestApprovalService_SuggestChanges_RequestNotPending(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, oldReqID, _ := env.seedPendingUpdate(t)
|
||||
|
||||
// Approve first.
|
||||
if err := env.approvals.Approve(ctx, oldReqID, env.approver, "ok"); err != nil {
|
||||
t.Fatalf("Approve: %v", err)
|
||||
}
|
||||
|
||||
counter := map[string]any{"due_date": "2026-06-20"}
|
||||
_, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "too late")
|
||||
if !errors.Is(err, ErrRequestNotPending) {
|
||||
t.Errorf("decided row suggest: got %v, want ErrRequestNotPending", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SuggestChanges_LifecycleInvalid: lifecycle ∉
|
||||
// (update, complete) rejects with ErrSuggestionLifecycleInvalid. A
|
||||
// create-lifecycle pending request is the easiest to set up.
|
||||
func TestApprovalService_SuggestChanges_LifecycleInvalid(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
|
||||
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, map[string]any{"due_date": "2026-05-20"})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("SubmitCreate: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
counter := map[string]any{"due_date": "2026-06-01"}
|
||||
_, err = env.approvals.SuggestChanges(ctx, *reqID, env.approver, counter, "different date")
|
||||
if !errors.Is(err, ErrSuggestionLifecycleInvalid) {
|
||||
t.Errorf("create-lifecycle suggest: got %v, want ErrSuggestionLifecycleInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SuggestChanges_OriginalRequesterCanApproveCounter:
|
||||
// the cleanest verification of m's Q6 mental model — after the approver
|
||||
// suggests changes, the ORIGINAL REQUESTER is no longer the new row's
|
||||
// requested_by and can now approve the counter themselves (provided
|
||||
// their profession is sufficient). For this test we promote the requester
|
||||
// to 'partner' profession so they pass the canApprove gate.
|
||||
func TestApprovalService_SuggestChanges_OriginalRequesterCanApproveCounter(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Promote the requester so they qualify as an approver of the counter.
|
||||
// The original Submit was theirs (excluded as requested_by); for the
|
||||
// counter their role lets them sign off.
|
||||
if _, err := env.pool.ExecContext(ctx,
|
||||
`UPDATE paliad.users SET profession='partner' WHERE id = $1`, env.requester); err != nil {
|
||||
t.Fatalf("promote requester profession: %v", err)
|
||||
}
|
||||
if _, err := env.pool.ExecContext(ctx,
|
||||
`UPDATE paliad.users SET profession='partner' WHERE id = $1`, env.approver); err != nil {
|
||||
t.Fatalf("promote approver profession: %v", err)
|
||||
}
|
||||
|
||||
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
|
||||
|
||||
counter := map[string]any{"due_date": "2026-06-22"}
|
||||
newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "Lieber den 22.")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestChanges: %v", err)
|
||||
}
|
||||
|
||||
// Original requester approves the counter.
|
||||
if err := env.approvals.Approve(ctx, *newReqID, env.requester, "Ja, passt."); err != nil {
|
||||
t.Fatalf("original requester approves counter: %v", err)
|
||||
}
|
||||
|
||||
// Entity is back to approved with the counter date.
|
||||
row := struct {
|
||||
Status string `db:"approval_status"`
|
||||
ApprovedBy *uuid.UUID `db:"approved_by"`
|
||||
DueDate time.Time `db:"due_date"`
|
||||
}{}
|
||||
if err := env.pool.GetContext(ctx, &row,
|
||||
`SELECT approval_status, approved_by, due_date FROM paliad.deadlines WHERE id = $1`,
|
||||
deadlineID); err != nil {
|
||||
t.Fatalf("read entity: %v", err)
|
||||
}
|
||||
if row.Status != "approved" {
|
||||
t.Errorf("entity approval_status = %q, want approved", row.Status)
|
||||
}
|
||||
if row.ApprovedBy == nil || *row.ApprovedBy != env.requester {
|
||||
t.Errorf("approved_by = %v, want %v (original requester)", row.ApprovedBy, env.requester)
|
||||
}
|
||||
want := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
|
||||
if !row.DueDate.Equal(want) {
|
||||
t.Errorf("due_date = %v, want %v", row.DueDate, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SuggestChanges_CounterApproverCannotSelfApprove:
|
||||
// after suggest-changes, the approver who suggested (= new row's
|
||||
// requested_by) is blocked from approving their own counter — 4-Augen
|
||||
// still holds.
|
||||
func TestApprovalService_SuggestChanges_CounterApproverCannotSelfApprove(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, oldReqID, _ := env.seedPendingUpdate(t)
|
||||
|
||||
counter := map[string]any{"due_date": "2026-06-22"}
|
||||
newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestChanges: %v", err)
|
||||
}
|
||||
|
||||
if err := env.approvals.Approve(ctx, *newReqID, env.approver, ""); !errors.Is(err, ErrSelfApproval) {
|
||||
t.Errorf("counter author self-approves: got %v, want ErrSelfApproval", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
266
internal/services/dump_export_test.go
Normal file
266
internal/services/dump_export_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package services
|
||||
|
||||
// Regression tests for the xlsx-generator pitfalls reported by m on
|
||||
// 2026-05-19:
|
||||
//
|
||||
// 1. Excel showed a "Repairs required" prompt on opening the .xlsx.
|
||||
// Root cause: SetPanes call passed only Freeze + YSplit; the
|
||||
// resulting <pane> XML missed topLeftCell + activePane, which
|
||||
// Excel rejects. Fix in buildXLSX: complete the Panes struct
|
||||
// (TopLeftCell="A2", ActivePane="bottomLeft", Selection on
|
||||
// bottomLeft).
|
||||
//
|
||||
// 2. Windows Explorer / Excel's File→Info showed Modified=2006-09-16
|
||||
// ("xuri" — excelize's first-commit defaults). Root cause:
|
||||
// SetDocProps was never called, so the canned default leaked
|
||||
// through. Fix in buildXLSX: SetDocProps({Created, Modified} =
|
||||
// meta.GeneratedAt; Creator = "Paliad (<firm>)").
|
||||
//
|
||||
// The tests are always-on (no env var gate) so a future writer
|
||||
// regression shows up loudly in `go test`. Developer-convenience hatch
|
||||
// at the bottom: set DUMP_EXPORT=1 to additionally write the bundle +
|
||||
// xlsx to /tmp for opening in real Excel.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// fixturePersonalExport builds a tiny in-memory bundle + the raw xlsx
|
||||
// for the regression assertions and the optional /tmp dump.
|
||||
func fixturePersonalExport(t *testing.T) (bundle []byte, xlsxBytes []byte, meta ExportMeta) {
|
||||
t.Helper()
|
||||
meta = 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": 1, "deadlines": 0},
|
||||
}
|
||||
sheets := []collectedSheet{
|
||||
{name: "projects", columns: []string{"id", "title", "umlauts"}, rows: [][]string{{"u1", "Acme", "Müller"}}},
|
||||
{name: "deadlines", columns: []string{"id", "due_date"}, rows: nil},
|
||||
}
|
||||
bundle = assembleBundleForTest(t, sheets, meta)
|
||||
var err error
|
||||
xlsxBytes, err = buildXLSX(sheets, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("buildXLSX: %v", err)
|
||||
}
|
||||
return bundle, xlsxBytes, meta
|
||||
}
|
||||
|
||||
// TestXLSX_DocProps_NotExcelizeDefault pins fix #2.
|
||||
//
|
||||
// Before the fix: core.xml had Created=Modified="2006-09-16T00:00:00Z"
|
||||
// (xuri's first commit). Now we expect both to equal meta.GeneratedAt
|
||||
// in RFC 3339 UTC, and Creator to be "Paliad (<firm>)".
|
||||
func TestXLSX_DocProps_NotExcelizeDefault(t *testing.T) {
|
||||
_, xlsxBytes, meta := fixturePersonalExport(t)
|
||||
fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("excelize.OpenReader: %v", err)
|
||||
}
|
||||
defer fl.Close()
|
||||
|
||||
props, err := fl.GetDocProps()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocProps: %v", err)
|
||||
}
|
||||
wantTS := meta.GeneratedAt.UTC().Format(time.RFC3339)
|
||||
if props.Created != wantTS {
|
||||
t.Errorf("Created = %q, want %q (excelize-default leak)", props.Created, wantTS)
|
||||
}
|
||||
if props.Modified != wantTS {
|
||||
t.Errorf("Modified = %q, want %q (excelize-default leak)", props.Modified, wantTS)
|
||||
}
|
||||
if props.Creator == "xuri" || props.Creator == "" {
|
||||
t.Errorf("Creator = %q, want non-empty non-xuri (e.g. \"Paliad (HLC)\")", props.Creator)
|
||||
}
|
||||
if !strings.Contains(props.Creator, "Paliad") {
|
||||
t.Errorf("Creator = %q, expected to contain \"Paliad\"", props.Creator)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXLSX_DocProps_TracksGeneratedAt pins that docProps stays bound to
|
||||
// meta.GeneratedAt across different timestamps — belt-and-braces vs
|
||||
// the fixed-fixture timestamp in the previous test.
|
||||
func TestXLSX_DocProps_TracksGeneratedAt(t *testing.T) {
|
||||
for _, ts := range []time.Time{
|
||||
time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2027, 12, 31, 23, 59, 59, 0, time.UTC),
|
||||
time.Now().UTC().Truncate(time.Second),
|
||||
} {
|
||||
meta := ExportMeta{
|
||||
SchemaVersion: 1,
|
||||
FirmName: "HLC",
|
||||
Scope: ExportScopePersonal,
|
||||
GeneratedAt: ts,
|
||||
RowCounts: map[string]int{"projects": 0},
|
||||
}
|
||||
xlsxBytes, err := buildXLSX([]collectedSheet{
|
||||
{name: "projects", columns: []string{"id"}, rows: nil},
|
||||
}, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("buildXLSX: %v", err)
|
||||
}
|
||||
fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReader: %v", err)
|
||||
}
|
||||
props, err := fl.GetDocProps()
|
||||
_ = fl.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocProps: %v", err)
|
||||
}
|
||||
want := ts.Format(time.RFC3339)
|
||||
if props.Modified != want {
|
||||
t.Errorf("Modified = %q, want %q", props.Modified, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXLSX_PaneXML_IsCompleteAndValid pins fix #1.
|
||||
//
|
||||
// excelize accepts the half-broken <pane state="frozen" ySplit="1"/>
|
||||
// shape on re-read (its parser is permissive), but Excel rejects it
|
||||
// with "Repairs required". To detect the regression without spinning
|
||||
// up Office, we read the raw worksheet XML out of the in-memory xlsx
|
||||
// zip and assert that the pane element has both topLeftCell + activePane.
|
||||
func TestXLSX_PaneXML_IsCompleteAndValid(t *testing.T) {
|
||||
_, xlsxBytes, _ := fixturePersonalExport(t)
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(xlsxBytes), int64(len(xlsxBytes)))
|
||||
if err != nil {
|
||||
t.Fatalf("xlsx is not a valid zip: %v", err)
|
||||
}
|
||||
|
||||
// sheet1 = __meta (no pane). sheet2 = projects, sheet3 = deadlines —
|
||||
// both have the frozen header.
|
||||
for _, target := range []string{"xl/worksheets/sheet2.xml", "xl/worksheets/sheet3.xml"} {
|
||||
var body []byte
|
||||
for _, f := range zr.File {
|
||||
if f.Name == target {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", target, err)
|
||||
}
|
||||
body, _ = io.ReadAll(rc)
|
||||
rc.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatalf("missing %s in xlsx zip", target)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, `topLeftCell="A2"`) {
|
||||
t.Errorf("%s pane missing topLeftCell — Excel will prompt 'repairs required'.\nXML: %s",
|
||||
target, s)
|
||||
}
|
||||
if !strings.Contains(s, `activePane="bottomLeft"`) {
|
||||
t.Errorf("%s pane missing activePane — Excel will prompt 'repairs required'.\nXML: %s",
|
||||
target, s)
|
||||
}
|
||||
if !strings.Contains(s, `state="frozen"`) {
|
||||
t.Errorf("%s pane missing state=frozen.\nXML: %s", target, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXLSX_NoExcelizeBuildDefaults guards against any future regression
|
||||
// where a code path writes the .xlsx without first overriding excelize's
|
||||
// canned defaults. Cheap byte-level assertions.
|
||||
func TestXLSX_NoExcelizeBuildDefaults(t *testing.T) {
|
||||
_, xlsxBytes, _ := fixturePersonalExport(t)
|
||||
if bytes.Contains(xlsxBytes, []byte("2006-09-16T00:00:00Z")) {
|
||||
t.Errorf("xlsx leaks excelize default Created/Modified=2006-09-16 — SetDocProps not called?")
|
||||
}
|
||||
if bytes.Contains(xlsxBytes, []byte(`<dc:creator>xuri</dc:creator>`)) {
|
||||
t.Errorf("xlsx leaks excelize default Creator=xuri — SetDocProps not called?")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXLSX_OpensCleanly is the catch-all: round-trip the file through
|
||||
// excelize and confirm sheet names, row counts, and GetDocProps work.
|
||||
func TestXLSX_OpensCleanly(t *testing.T) {
|
||||
_, xlsxBytes, _ := fixturePersonalExport(t)
|
||||
fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReader: %v", err)
|
||||
}
|
||||
defer fl.Close()
|
||||
|
||||
wantSheets := []string{"__meta", "projects", "deadlines"}
|
||||
got := fl.GetSheetList()
|
||||
if len(got) != len(wantSheets) {
|
||||
t.Fatalf("sheet list length = %d, want %d (%v vs %v)", len(got), len(wantSheets), got, wantSheets)
|
||||
}
|
||||
for i, want := range wantSheets {
|
||||
if got[i] != want {
|
||||
t.Errorf("sheet[%d] = %q, want %q", i, got[i], want)
|
||||
}
|
||||
}
|
||||
rows, err := fl.GetRows("projects")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRows(projects): %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("projects rows = %d, want 2 (header + 1)", len(rows))
|
||||
}
|
||||
if rows[0][0] != "id" || rows[1][0] != "u1" || rows[1][2] != "Müller" {
|
||||
t.Errorf("projects rows = %v, want header=[id title umlauts] row=[u1 Acme Müller]", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBundle_ZipEntryMTime_TracksGeneratedAt pins the outer-zip side of
|
||||
// fix #2. Pre-fix every entry was stamped 2000-01-01 (the deterministic
|
||||
// constant) so Windows showed extracted files with a stale Modified
|
||||
// column. Now they carry meta.GeneratedAt.
|
||||
func TestBundle_ZipEntryMTime_TracksGeneratedAt(t *testing.T) {
|
||||
bundle, _, meta := fixturePersonalExport(t)
|
||||
zr, err := zip.NewReader(bytes.NewReader(bundle), int64(len(bundle)))
|
||||
if err != nil {
|
||||
t.Fatalf("bundle not a valid zip: %v", err)
|
||||
}
|
||||
want := meta.GeneratedAt.UTC()
|
||||
for _, f := range zr.File {
|
||||
got := f.Modified.UTC()
|
||||
// Zip stores mtime at 2-second resolution; allow ≤2s drift.
|
||||
diff := got.Sub(want)
|
||||
if diff < -2*time.Second || diff > 2*time.Second {
|
||||
t.Errorf("zip entry %q Modified = %v, want ~%v", f.Name, got, want)
|
||||
}
|
||||
// Specifically catch the old 2000-01-01 stamp.
|
||||
if got.Year() == 2000 && got.Month() == 1 && got.Day() == 1 {
|
||||
t.Errorf("zip entry %q stamped 2000-01-01 — old deterministic-constant regression", f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDumpExport is the developer-convenience hatch. Skipped by default;
|
||||
// set DUMP_EXPORT=1 to write artifacts to /tmp for opening in real Excel.
|
||||
func TestDumpExport(t *testing.T) {
|
||||
if os.Getenv("DUMP_EXPORT") == "" {
|
||||
t.Skip("set DUMP_EXPORT=1 to dump artifacts to /tmp/paliad-export-debug.{zip,xlsx}")
|
||||
}
|
||||
bundle, xlsxBytes, _ := fixturePersonalExport(t)
|
||||
if err := os.WriteFile("/tmp/paliad-export-debug.zip", bundle, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile("/tmp/paliad-export-debug.xlsx", xlsxBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("wrote /tmp/paliad-export-debug.zip (%d bytes) + .xlsx (%d bytes)", len(bundle), len(xlsxBytes))
|
||||
}
|
||||
1007
internal/services/export_service.go
Normal file
1007
internal/services/export_service.go
Normal file
File diff suppressed because it is too large
Load Diff
466
internal/services/export_service_test.go
Normal file
466
internal/services/export_service_test.go
Normal file
@@ -0,0 +1,466 @@
|
||||
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)
|
||||
// Mirror writeBundle's mtime convention so the helper produces
|
||||
// realistic bytes — and so the TestBundle_ZipEntryMTime regression
|
||||
// test actually exercises the right code path.
|
||||
mod := meta.GeneratedAt.UTC()
|
||||
if mod.IsZero() {
|
||||
mod = time.Now().UTC()
|
||||
}
|
||||
for _, e := range entries {
|
||||
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate, Modified: mod}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -203,7 +203,7 @@ var KnownProjectEventKinds = []string{
|
||||
// filters and request-side status filters respectively.
|
||||
var (
|
||||
validEntityApprovalStatuses = []string{"approved", "pending", "legacy"}
|
||||
validRequestStatuses = []string{"pending", "approved", "rejected", "revoked"}
|
||||
validRequestStatuses = []string{"pending", "approved", "rejected", "revoked", "changes_requested"}
|
||||
validApprovalEntityTypes = []string{"deadline", "appointment"}
|
||||
validApprovalViewerRoles = []string{"approver_eligible", "self_requested", "any_visible"}
|
||||
validDeadlineStatuses = []string{"pending", "completed"}
|
||||
|
||||
315
internal/services/submission_render.go
Normal file
315
internal/services/submission_render.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package services
|
||||
|
||||
// Submission template renderer — in-house engine for the submission
|
||||
// generator (t-paliad-215, design doc
|
||||
// docs/design-submission-generator-2026-05-19.md §6).
|
||||
//
|
||||
// Design choice — why not lukasjarosch/go-docx:
|
||||
// The library's "nested placeholder" guard treats sibling placeholders
|
||||
// inside the same <w:t> run (e.g. "{{a}} ./. {{b}}") as nested and
|
||||
// refuses to replace either. Patent submissions routinely have multiple
|
||||
// placeholders per paragraph (party blocks especially), so the library
|
||||
// is a non-starter without a custom fork. The in-house renderer below
|
||||
// is ~150 LoC and handles both the single-run common case and the
|
||||
// cross-run case (where Word may split a placeholder across runs after
|
||||
// editing).
|
||||
//
|
||||
// Placeholder grammar: {{[A-Za-z][A-Za-z0-9_.]*}} with optional
|
||||
// whitespace inside braces ({{ project.case_number }} ≡
|
||||
// {{project.case_number}}).
|
||||
//
|
||||
// Missing-value behaviour: when a placeholder has no binding in the
|
||||
// PlaceholderMap, the renderer emits a marker token so the lawyer sees
|
||||
// the gap in Word rather than failing the request. See §6.3 of the
|
||||
// design doc.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlaceholderMap is the variable bag built by SubmissionVarsService.
|
||||
// Keys are dotted paths without braces (e.g. "project.case_number").
|
||||
// Values are the substituted text — already locale-aware, pretty-
|
||||
// printed, and sanitised by the caller.
|
||||
type PlaceholderMap map[string]string
|
||||
|
||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||
// in-document marker token. The default in DefaultMissingMarker is
|
||||
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
|
||||
type MissingPlaceholderFn func(key string) string
|
||||
|
||||
// DefaultMissingMarker returns the standard missing-value marker for
|
||||
// the given UI language.
|
||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
prefix := "KEIN WERT"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "NO VALUE"
|
||||
}
|
||||
return func(key string) string {
|
||||
return "[" + prefix + ": " + key + "]"
|
||||
}
|
||||
}
|
||||
|
||||
// placeholderRegex matches a single placeholder. The capture group
|
||||
// extracts the key name without braces or surrounding whitespace.
|
||||
//
|
||||
// Restricted to [A-Za-z][A-Za-z0-9_.]* so that stray "{{" sequences in
|
||||
// legal prose (extremely rare in DE/EN court briefs but possible)
|
||||
// don't get mistaken for placeholders. A genuine placeholder always
|
||||
// starts with an ASCII letter.
|
||||
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
|
||||
|
||||
// SubmissionRenderer renders a .docx template into a .docx output by
|
||||
// substituting {{placeholder}} tokens with values from a PlaceholderMap.
|
||||
// Stateless; safe for concurrent use.
|
||||
type SubmissionRenderer struct{}
|
||||
|
||||
// NewSubmissionRenderer constructs the renderer.
|
||||
func NewSubmissionRenderer() *SubmissionRenderer {
|
||||
return &SubmissionRenderer{}
|
||||
}
|
||||
|
||||
// Render reads the .docx template at templateBytes, substitutes every
|
||||
// placeholder from vars (or emits the missing-marker token), and writes
|
||||
// the result to the returned byte slice. Unknown placeholders never
|
||||
// fail the render — the lawyer sees the marker in Word and fixes it.
|
||||
func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) ([]byte, error) {
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker("de")
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(templateBytes), int64(len(templateBytes)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission template: open zip: %w", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
defer zw.Close()
|
||||
|
||||
for _, entry := range zr.File {
|
||||
body, err := readZipEntry(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission template: read %s: %w", entry.Name, err)
|
||||
}
|
||||
if isWordXMLEntry(entry.Name) {
|
||||
body = substituteInDocumentXML(body, vars, missing)
|
||||
}
|
||||
w, err := zw.CreateHeader(&zip.FileHeader{
|
||||
Name: entry.Name,
|
||||
Method: entry.Method,
|
||||
Modified: entry.Modified,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission template: write header %s: %w", entry.Name, err)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission template: write %s: %w", entry.Name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission template: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// isWordXMLEntry returns true for the .docx parts that contain
|
||||
// substitutable text. We touch document.xml plus header*.xml and
|
||||
// footer*.xml (templates may put firm letterhead in a header) but
|
||||
// skip styles, theme, settings, comments, footnotes — none of which
|
||||
// should carry merge placeholders in a well-formed template.
|
||||
func isWordXMLEntry(name string) bool {
|
||||
switch {
|
||||
case name == "word/document.xml":
|
||||
return true
|
||||
case strings.HasPrefix(name, "word/header") && strings.HasSuffix(name, ".xml"):
|
||||
return true
|
||||
case strings.HasPrefix(name, "word/footer") && strings.HasSuffix(name, ".xml"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readZipEntry slurps a zip entry's bytes.
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// substituteInDocumentXML walks document XML and replaces every
|
||||
// {{placeholder}} occurrence inside <w:t> text nodes. Handles both
|
||||
// single-run placeholders (the common case for freshly authored
|
||||
// templates) and cross-run placeholders (where Word's autocorrect or
|
||||
// manual editing has split a placeholder across runs).
|
||||
//
|
||||
// Two-pass strategy:
|
||||
//
|
||||
// 1. Pass 1: replace placeholders that fit entirely within one
|
||||
// <w:t>…</w:t>. This is the 99% case and preserves all run-level
|
||||
// formatting (bold, italic, font runs).
|
||||
// 2. Pass 2: for paragraphs that still contain orphan "{{" or "}}"
|
||||
// markers after pass 1, merge the text of every <w:t> inside the
|
||||
// paragraph, run the replacement on the merged text, and rewrite
|
||||
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
|
||||
// the formatting properties of the first run. Loses intra-paragraph
|
||||
// formatting on the affected paragraph — but only on paragraphs
|
||||
// where Word genuinely fragmented a placeholder.
|
||||
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
replaced := substituteInTextNodes(body, vars, missing)
|
||||
if !needsCrossRunMerge(replaced) {
|
||||
return replaced
|
||||
}
|
||||
return substituteAcrossRuns(replaced, vars, missing)
|
||||
}
|
||||
|
||||
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
|
||||
// the contents. Attributes on <w:t> (xml:space="preserve") are preserved
|
||||
// because the entire match is rewritten.
|
||||
var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
|
||||
|
||||
// substituteInTextNodes runs the placeholder replacement inside each
|
||||
// <w:t> text node independently. Format-preserving for single-run
|
||||
// placeholders.
|
||||
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
|
||||
sub := wTextNodeRegex.FindSubmatch(match)
|
||||
attrs := string(sub[1])
|
||||
contents := xmlDecode(string(sub[2]))
|
||||
replaced := replacePlaceholders(contents, vars, missing)
|
||||
if replaced == contents {
|
||||
return match
|
||||
}
|
||||
// xml:space="preserve" stays attached whenever the original
|
||||
// content had leading/trailing whitespace; ensure it's still
|
||||
// declared after replacement to avoid Word collapsing spaces.
|
||||
if !strings.Contains(attrs, "xml:space") &&
|
||||
(strings.HasPrefix(replaced, " ") || strings.HasSuffix(replaced, " ")) {
|
||||
attrs += ` xml:space="preserve"`
|
||||
}
|
||||
return []byte(`<w:t` + attrs + `>` + xmlEncode(replaced) + `</w:t>`)
|
||||
})
|
||||
}
|
||||
|
||||
// needsCrossRunMerge returns true when the body still contains an
|
||||
// unmatched "{{" or "}}" after pass 1 — a sign that Word fragmented
|
||||
// the placeholder across runs and pass 1 couldn't touch it.
|
||||
func needsCrossRunMerge(body []byte) bool {
|
||||
// Cheap heuristic: count "{{" vs "}}" inside <w:t> nodes. If we have
|
||||
// either marker present in the text-node space, pass 2 will handle
|
||||
// it. (Inside attributes or other XML, the markers don't matter.)
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(body, -1) {
|
||||
t := string(m[2])
|
||||
if strings.Contains(t, "{{") || strings.Contains(t, "}}") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// wParagraphRegex matches one <w:p>…</w:p> paragraph block. Greedy
|
||||
// inner-content match is safe here because <w:p> elements do not nest
|
||||
// in WordprocessingML — a paragraph is the leaf container for text.
|
||||
var wParagraphRegex = regexp.MustCompile(`(?s)<w:p\b[^>]*>.*?</w:p>`)
|
||||
|
||||
// wRunPropsRegex pulls the first <w:rPr>…</w:rPr> block from a
|
||||
// paragraph so we can reuse it as the formatting of the merged run.
|
||||
var wRunPropsRegex = regexp.MustCompile(`(?s)<w:rPr>.*?</w:rPr>`)
|
||||
|
||||
// wParagraphPropsRegex pulls the optional <w:pPr>…</w:pPr> that sits
|
||||
// at the top of a paragraph (alignment, spacing, etc.). Preserved.
|
||||
var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
|
||||
|
||||
// substituteAcrossRuns is pass 2: for any paragraph that still has a
|
||||
// split placeholder, concatenate every text node, run replacement, and
|
||||
// rewrite the paragraph as a single run using the first run's
|
||||
// properties. Paragraphs without orphan markers are left untouched so
|
||||
// run-level formatting survives wherever pass 1 already resolved the
|
||||
// placeholders.
|
||||
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
|
||||
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
|
||||
if len(textNodes) == 0 {
|
||||
return para
|
||||
}
|
||||
var merged strings.Builder
|
||||
for _, m := range textNodes {
|
||||
merged.WriteString(xmlDecode(string(m[2])))
|
||||
}
|
||||
original := merged.String()
|
||||
if !strings.Contains(original, "{{") {
|
||||
// No fragmented placeholder in this paragraph; leave it
|
||||
// alone so pass 1's run-level edits survive.
|
||||
return para
|
||||
}
|
||||
replaced := replacePlaceholders(original, vars, missing)
|
||||
if replaced == original {
|
||||
return para
|
||||
}
|
||||
// Preserve paragraph properties (alignment, spacing) and the
|
||||
// first run's properties (font, bold/italic).
|
||||
pPr := wParagraphPropsRegex.Find(para)
|
||||
rPr := wRunPropsRegex.Find(para)
|
||||
var rebuilt bytes.Buffer
|
||||
rebuilt.WriteString(`<w:p>`)
|
||||
if pPr != nil {
|
||||
rebuilt.Write(pPr)
|
||||
}
|
||||
rebuilt.WriteString(`<w:r>`)
|
||||
if rPr != nil {
|
||||
rebuilt.Write(rPr)
|
||||
}
|
||||
rebuilt.WriteString(`<w:t xml:space="preserve">`)
|
||||
rebuilt.WriteString(xmlEncode(replaced))
|
||||
rebuilt.WriteString(`</w:t></w:r></w:p>`)
|
||||
return rebuilt.Bytes()
|
||||
})
|
||||
}
|
||||
|
||||
// replacePlaceholders performs the actual substitution on a plain
|
||||
// string. Unbound placeholders render the missing marker.
|
||||
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn) string {
|
||||
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
|
||||
sub := placeholderRegex.FindStringSubmatch(match)
|
||||
if len(sub) < 2 {
|
||||
return match
|
||||
}
|
||||
key := sub[1]
|
||||
if value, ok := vars[key]; ok {
|
||||
return value
|
||||
}
|
||||
return missing(key)
|
||||
})
|
||||
}
|
||||
|
||||
// xmlDecode reverses the small set of escapes used in WordprocessingML
|
||||
// text content. We don't need a full XML parser — text nodes carry only
|
||||
// the standard five entities, and Word never emits numeric-character
|
||||
// references inside <w:t> for printable content.
|
||||
func xmlDecode(s string) string {
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, """, `"`)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
return s
|
||||
}
|
||||
|
||||
// xmlEncode escapes a substituted value for safe insertion back into a
|
||||
// WordprocessingML text node. & must be replaced first to avoid double
|
||||
// encoding the entity prefixes we introduce on the other characters.
|
||||
func xmlEncode(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
389
internal/services/submission_render_test.go
Normal file
389
internal/services/submission_render_test.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minimalDOCX builds a tiny .docx zip with one document.xml that
|
||||
// contains the given body. Just enough to exercise the renderer
|
||||
// without depending on Word's full OOXML scaffolding.
|
||||
func minimalDOCX(t *testing.T, documentBody string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create document.xml: %v", err)
|
||||
}
|
||||
if _, err := io.WriteString(w, documentBody); err != nil {
|
||||
t.Fatalf("write document.xml: %v", err)
|
||||
}
|
||||
// Drop in a stub Content-Types so the bytes look more like a real
|
||||
// .docx for any downstream sanity checks; Word doesn't care about
|
||||
// the content during our unit tests but the shape stays honest.
|
||||
w2, err := zw.Create("[Content_Types].xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create content types: %v", err)
|
||||
}
|
||||
if _, err := io.WriteString(w2, `<?xml version="1.0"?><Types/>`); err != nil {
|
||||
t.Fatalf("write content types: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// readDocumentXML pulls word/document.xml out of a rendered .docx.
|
||||
func readDocumentXML(t *testing.T, b []byte) string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
t.Fatalf("open rendered zip: %v", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open document.xml: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
body, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("read document.xml: %v", err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
t.Fatal("rendered .docx had no word/document.xml")
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestRender_SingleRunPlaceholder covers the 99% case: a placeholder
|
||||
// that sits inside a single <w:t> text node.
|
||||
func TestRender_SingleRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readDocumentXML(t, out)
|
||||
if !strings.Contains(body, ">HLC<") {
|
||||
t.Errorf("expected HLC in body, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_MultiplePlaceholdersPerRun is the case go-docx fails on
|
||||
// — sibling placeholders inside the same <w:t> run. The in-house
|
||||
// renderer must handle them.
|
||||
func TestRender_MultiplePlaceholdersPerRun(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
"parties.claimant.name": "Acme Inc.",
|
||||
"parties.claimant.representative": "Kanzlei Müller",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readDocumentXML(t, out)
|
||||
if !strings.Contains(body, "Acme Inc.") || !strings.Contains(body, "Kanzlei Müller") {
|
||||
t.Errorf("expected both party values, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_MissingMarker confirms unbound placeholders render the
|
||||
// missing-value marker instead of failing the request.
|
||||
func TestRender_MissingMarker(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readDocumentXML(t, out)
|
||||
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
|
||||
t.Errorf("expected KEIN WERT marker, got %q", body)
|
||||
}
|
||||
outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en"))
|
||||
if err != nil {
|
||||
t.Fatalf("render en: %v", err)
|
||||
}
|
||||
bodyEN := readDocumentXML(t, outEN)
|
||||
if !strings.Contains(bodyEN, "[NO VALUE: project.case_number]") {
|
||||
t.Errorf("expected NO VALUE marker, got %q", bodyEN)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_CrossRunPlaceholder simulates Word fragmenting a
|
||||
// placeholder across runs (autocorrect or post-edit run-split).
|
||||
// Pass 2 must catch it.
|
||||
func TestRender_CrossRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readDocumentXML(t, out)
|
||||
if !strings.Contains(body, "7 O 1234/26") {
|
||||
t.Errorf("expected case number after cross-run merge, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("orphan placeholder marker remained: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_XMLEscaping verifies special characters in placeholder
|
||||
// values are escaped so they don't corrupt the document XML.
|
||||
func TestRender_XMLEscaping(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readDocumentXML(t, out)
|
||||
if !strings.Contains(body, "Müller & Söhne <GmbH> "Special"") {
|
||||
t.Errorf("expected escaped value, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_PreservesNonWordEntries leaves the rest of the .docx
|
||||
// untouched so any styles / theme / settings parts come through bit-
|
||||
// for-bit.
|
||||
func TestRender_PreservesNonWordEntries(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(out), int64(len(out)))
|
||||
if err != nil {
|
||||
t.Fatalf("open rendered: %v", err)
|
||||
}
|
||||
var sawTypes bool
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "[Content_Types].xml" {
|
||||
sawTypes = true
|
||||
}
|
||||
}
|
||||
if !sawTypes {
|
||||
t.Error("rendered .docx lost [Content_Types].xml")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceholderRegex_Boundaries pins the placeholder grammar.
|
||||
func TestPlaceholderRegex_Boundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
matches []string
|
||||
}{
|
||||
{"plain text", nil},
|
||||
{"{{foo}}", []string{"{{foo}}"}},
|
||||
{"{{ foo }}", []string{"{{ foo }}"}},
|
||||
{"{{foo.bar}}", []string{"{{foo.bar}}"}},
|
||||
{"{{ foo.bar_baz }}", []string{"{{ foo.bar_baz }}"}},
|
||||
{"{{1bad}}", nil}, // must start with a letter
|
||||
{"{{ foo }} and {{ bar }}", []string{"{{ foo }}", "{{ bar }}"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := placeholderRegex.FindAllString(tc.in, -1)
|
||||
if len(got) != len(tc.matches) {
|
||||
t.Fatalf("got %d matches, want %d (in=%q)", len(got), len(tc.matches), tc.in)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.matches[i] {
|
||||
t.Errorf("match %d: got %q, want %q", i, got[i], tc.matches[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFamilyOf covers the proceeding-family extraction used by the
|
||||
// template registry's fallback chain.
|
||||
func TestFamilyOf(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"de.inf.lg.erwidg": "de.inf.lg",
|
||||
"upc.inf.cfi.soc": "upc.inf.cfi",
|
||||
"dpma.opp.dpma": "", // only three segments → no family
|
||||
"de.inf.lg": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range tests {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
got := familyOf(in)
|
||||
if got != want {
|
||||
t.Errorf("familyOf(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegalSourcePretty covers the prefix table.
|
||||
func TestLegalSourcePretty(t *testing.T) {
|
||||
tests := []struct {
|
||||
src, lang, want string
|
||||
}{
|
||||
{"DE.ZPO.276.1", "de", "§ 276 Abs. 1 ZPO"},
|
||||
{"DE.ZPO.276.1", "en", "Section 276(1) ZPO"},
|
||||
{"DE.ZPO.253", "de", "§ 253 ZPO"},
|
||||
{"DE.ZPO.253", "en", "Section 253 ZPO"},
|
||||
{"UPC.RoP.23.1", "de", "Regel 23.1 VerfO UPC"},
|
||||
{"UPC.RoP.23.1", "en", "Rule 23.1 RoP UPC"},
|
||||
{"UPC.RoP.198", "de", "Regel 198 VerfO UPC"},
|
||||
{"DE.PatG.83", "de", "§ 83 PatG"},
|
||||
{"EPC.123", "de", "Art. 123 EPÜ"},
|
||||
{"EPC.123", "en", "Art. 123 EPC"},
|
||||
// Unknown prefix → pass-through unchanged.
|
||||
{"FOO.BAR.123", "de", "FOO.BAR.123"},
|
||||
{"", "de", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.src+"/"+tc.lang, func(t *testing.T) {
|
||||
got := legalSourcePretty(tc.src, tc.lang)
|
||||
if got != tc.want {
|
||||
t.Errorf("legalSourcePretty(%q, %q) = %q, want %q", tc.src, tc.lang, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOurSideTranslations pins the our_side enum → DE/EN prose
|
||||
// mapping used by addProjectVars.
|
||||
func TestOurSideTranslations(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, wantDE, wantEN string
|
||||
}{
|
||||
{"claimant", "Klägerin", "Claimant"},
|
||||
{"defendant", "Beklagte", "Defendant"},
|
||||
{"court", "Gericht", "Court"},
|
||||
{"both", "Klägerin und Beklagte", "Claimant and Defendant"},
|
||||
{"", "", ""},
|
||||
{"unknown", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := ourSideDE(tc.in); got != tc.wantDE {
|
||||
t.Errorf("ourSideDE(%q) = %q, want %q", tc.in, got, tc.wantDE)
|
||||
}
|
||||
if got := ourSideEN(tc.in); got != tc.wantEN {
|
||||
t.Errorf("ourSideEN(%q) = %q, want %q", tc.in, got, tc.wantEN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateRegistry_Candidates verifies the fallback-chain order
|
||||
// matches the m-locked Q4 decision (firm → base/code → base/family →
|
||||
// skeleton).
|
||||
func TestTemplateRegistry_Candidates(t *testing.T) {
|
||||
r := NewTemplateRegistry("", "HLC")
|
||||
got := r.candidates("de.inf.lg.erwidg")
|
||||
want := []string{
|
||||
"templates/HLC/de.inf.lg.erwidg.docx",
|
||||
"templates/_base/de.inf.lg.erwidg.docx",
|
||||
"templates/_base/de.inf.lg.docx",
|
||||
"templates/_base/_skeleton.docx",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("candidates = %v, want %v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("candidate[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateRegistry_Candidates_NoFamily covers submission codes
|
||||
// without a family suffix (only three dot-segments).
|
||||
func TestTemplateRegistry_Candidates_NoFamily(t *testing.T) {
|
||||
r := NewTemplateRegistry("", "HLC")
|
||||
got := r.candidates("dpma.opp.dpma")
|
||||
want := []string{
|
||||
"templates/HLC/dpma.opp.dpma.docx",
|
||||
"templates/_base/dpma.opp.dpma.docx",
|
||||
"templates/_base/_skeleton.docx",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("candidates = %v, want %v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("candidate[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateRegistry_Tiers labels each candidate slot. Must stay
|
||||
// 1:1 with candidates().
|
||||
func TestTemplateRegistry_Tiers(t *testing.T) {
|
||||
r := NewTemplateRegistry("", "HLC")
|
||||
codes := []string{"de.inf.lg.erwidg", "dpma.opp.dpma"}
|
||||
for _, code := range codes {
|
||||
c := r.candidates(code)
|
||||
ts := r.tiers(code)
|
||||
if len(c) != len(ts) {
|
||||
t.Fatalf("candidate/tier mismatch for %q: %d vs %d", code, len(c), len(ts))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatentNumberUPC covers the kind-code parenthesisation that UPC
|
||||
// briefs use (t-paliad-215 Slice 2, design §22 Q-S2-4).
|
||||
func TestPatentNumberUPC(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
// EP variants — the common case.
|
||||
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
|
||||
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
|
||||
// DE national number with kind code.
|
||||
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
|
||||
// No kind code → pass-through unchanged.
|
||||
{"EP 1 234 567", "EP 1 234 567"},
|
||||
// Leading + trailing whitespace trimmed.
|
||||
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
|
||||
// Empty input.
|
||||
{"", ""},
|
||||
// Slash-separated forms (WO publication numbers) don't match
|
||||
// the kind-code shape → pass through.
|
||||
{"WO/2023/123456", "WO/2023/123456"},
|
||||
// Two-digit kind code (e.g. B12) doesn't match the single-digit
|
||||
// pattern; pass through. This is intentional — real EP kind
|
||||
// codes are single-letter + single-digit.
|
||||
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := patentNumberUPC(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
442
internal/services/submission_templates.go
Normal file
442
internal/services/submission_templates.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package services
|
||||
|
||||
// Submission template registry — Gitea-backed .docx template loader for
|
||||
// the submission generator (t-paliad-215, design doc
|
||||
// docs/design-submission-generator-2026-05-19.md §5).
|
||||
//
|
||||
// Layout in mWorkRepo:
|
||||
//
|
||||
// templates/{FIRM_NAME}/{submission_code}.docx firm-specific override
|
||||
// templates/_base/{submission_code}.docx cross-firm baseline
|
||||
// templates/_base/{family}.docx proceeding-family fallback
|
||||
// templates/_base/_skeleton.docx ultra-generic fallback
|
||||
//
|
||||
// Lookup is first-match-wins down the chain; this is the m-locked Q4
|
||||
// decision. Templates fetched via Gitea's raw URL endpoint, cached
|
||||
// in-process with a 5-minute SHA refresh check — identical pattern to
|
||||
// the HL Patents Style proxy in internal/handlers/files.go (which the
|
||||
// design doc §1 verified is in production and works).
|
||||
//
|
||||
// Slice 1 ships one template at templates/_base/de.inf.lg.erwidg.docx
|
||||
// (committed to HL/mWorkRepo at SHA 7f97b7f9, the bootstrap demo
|
||||
// authored by the engine for end-to-end testing — HLC ships the
|
||||
// polished version per §14 follow-up).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
templatesGiteaBaseURL = "https://mgit.msbls.de"
|
||||
templatesGiteaRepoOwn = "HL"
|
||||
templatesGiteaRepoName = "mWorkRepo"
|
||||
templatesGiteaBranch = "main"
|
||||
templatesCheckInterval = 5 * time.Minute
|
||||
templatesSkeleton = "_skeleton"
|
||||
)
|
||||
|
||||
// ErrNoTemplate is returned when no template resolves anywhere in the
|
||||
// fallback chain (firm/code → base/code → base/family → skeleton).
|
||||
// Caller maps to 503 + a clear UI hint.
|
||||
var ErrNoTemplate = errors.New("submission template: no template resolved in fallback chain")
|
||||
|
||||
// ErrTemplateUpstream wraps Gitea-side failures (network, 5xx).
|
||||
// Distinct from ErrNoTemplate so the handler can render different UI:
|
||||
// "no template configured" vs "template repo unreachable".
|
||||
var ErrTemplateUpstream = errors.New("submission template: upstream Gitea unreachable")
|
||||
|
||||
// ResolvedTemplate is the result of a fallback-chain lookup: the
|
||||
// template bytes plus the metadata the audit row + UI need.
|
||||
type ResolvedTemplate struct {
|
||||
// Path is the Gitea-relative path that resolved (e.g.
|
||||
// "templates/HLC/de.inf.lg.erwidg.docx"). Persisted in the
|
||||
// system_audit_log row so an admin can trace which template was
|
||||
// used for a given generation.
|
||||
Path string
|
||||
|
||||
// SHA is the commit SHA the template was fetched at. Pinning this
|
||||
// lets audit consumers reproduce the exact bytes that went into
|
||||
// the lawyer's download.
|
||||
SHA string
|
||||
|
||||
// FirmTier reports which level of the fallback chain fired:
|
||||
// "firm", "base_code", "base_family", or "skeleton". Useful for
|
||||
// the variable-contract sidebar (Slice 3) and for ops monitoring
|
||||
// of how often each firm is actually overriding.
|
||||
FirmTier string
|
||||
|
||||
// Bytes is the .docx content; only populated for callers that
|
||||
// need to render (i.e. SubmissionRenderer.Render). Resolve()
|
||||
// returns it populated; Probe() leaves it nil.
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
// templateCacheEntry mirrors the per-file cache shape used by
|
||||
// internal/handlers/files.go. Each cached entry tracks its bytes, the
|
||||
// commit SHA, the last upstream check, and a checking flag so two
|
||||
// concurrent refresh goroutines don't double-fetch.
|
||||
type templateCacheEntry struct {
|
||||
mu sync.RWMutex
|
||||
data []byte
|
||||
sha string
|
||||
lastChecked time.Time
|
||||
checking bool
|
||||
missing bool // true when Gitea returned 404 — short-circuits subsequent lookups
|
||||
}
|
||||
|
||||
// TemplateRegistry resolves submission templates from Gitea using the
|
||||
// fallback chain. Process-wide cache; single-replica deployment (per
|
||||
// docs/design-submission-generator-2026-05-19.md §1) makes in-process
|
||||
// caching sufficient — a future multi-replica rollout would swap this
|
||||
// for a shared cache. Same trade-off the HL Patents Style proxy makes.
|
||||
type TemplateRegistry struct {
|
||||
cache map[string]*templateCacheEntry
|
||||
cacheMu sync.Mutex
|
||||
giteaToken string
|
||||
httpClient *http.Client
|
||||
firmName string
|
||||
}
|
||||
|
||||
// NewTemplateRegistry constructs the registry. firmName is read once
|
||||
// at process start from internal/branding.Name so a runtime FIRM_NAME
|
||||
// rebrand cuts in on the next deploy, not mid-request.
|
||||
func NewTemplateRegistry(giteaToken, firmName string) *TemplateRegistry {
|
||||
return &TemplateRegistry{
|
||||
cache: make(map[string]*templateCacheEntry),
|
||||
giteaToken: giteaToken,
|
||||
firmName: firmName,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// HasTemplate reports whether any template resolves for the given
|
||||
// submission code, without fetching the bytes. Used by the
|
||||
// SubmissionsPanel to decide which "Generate" buttons to enable.
|
||||
//
|
||||
// Cheap path: walks the same fallback chain as Resolve, but stops at
|
||||
// the SHA-probe step (Gitea's contents endpoint, single round-trip per
|
||||
// candidate). The probe results land in the same cache as Resolve so a
|
||||
// subsequent Resolve call reuses the SHA.
|
||||
func (r *TemplateRegistry) HasTemplate(ctx context.Context, submissionCode string) bool {
|
||||
for _, candidate := range r.candidates(submissionCode) {
|
||||
if r.probe(ctx, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Resolve walks the fallback chain and returns the first template that
|
||||
// fetches successfully, with bytes loaded. Returns ErrNoTemplate when
|
||||
// no candidate (including the ultra-generic skeleton) resolves.
|
||||
func (r *TemplateRegistry) Resolve(ctx context.Context, submissionCode string) (*ResolvedTemplate, error) {
|
||||
candidates := r.candidates(submissionCode)
|
||||
tiers := r.tiers(submissionCode)
|
||||
if len(candidates) != len(tiers) {
|
||||
return nil, fmt.Errorf("template registry: candidate/tier mismatch (%d vs %d)", len(candidates), len(tiers))
|
||||
}
|
||||
for i, candidate := range candidates {
|
||||
entry := r.cacheGet(candidate)
|
||||
entry.mu.RLock()
|
||||
hasData := !entry.missing && len(entry.data) > 0
|
||||
needsCheck := time.Since(entry.lastChecked) >= templatesCheckInterval
|
||||
isMissing := entry.missing
|
||||
entry.mu.RUnlock()
|
||||
|
||||
if isMissing && !needsCheck {
|
||||
continue
|
||||
}
|
||||
if !hasData {
|
||||
if err := r.fetchInto(ctx, candidate, entry); err != nil {
|
||||
if errors.Is(err, errTemplate404) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemplateUpstream, err)
|
||||
}
|
||||
} else if needsCheck {
|
||||
go r.refresh(context.Background(), candidate, entry)
|
||||
}
|
||||
|
||||
entry.mu.RLock()
|
||||
out := &ResolvedTemplate{
|
||||
Path: candidate,
|
||||
SHA: entry.sha,
|
||||
FirmTier: tiers[i],
|
||||
Bytes: append([]byte(nil), entry.data...),
|
||||
}
|
||||
entry.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
return nil, ErrNoTemplate
|
||||
}
|
||||
|
||||
// candidates returns the ordered Gitea-relative paths the registry
|
||||
// walks for the given submission code. The order is the m-locked Q4
|
||||
// decision: firm → base/code → base/family → skeleton.
|
||||
func (r *TemplateRegistry) candidates(submissionCode string) []string {
|
||||
family := familyOf(submissionCode)
|
||||
out := []string{
|
||||
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
|
||||
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
|
||||
}
|
||||
if family != "" && family != submissionCode {
|
||||
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
|
||||
}
|
||||
out = append(out, fmt.Sprintf("templates/_base/%s.docx", templatesSkeleton))
|
||||
return out
|
||||
}
|
||||
|
||||
// tiers labels each candidate with its fallback tier. Order is locked
|
||||
// to candidates(); both functions evolve together.
|
||||
func (r *TemplateRegistry) tiers(submissionCode string) []string {
|
||||
family := familyOf(submissionCode)
|
||||
out := []string{"firm", "base_code"}
|
||||
if family != "" && family != submissionCode {
|
||||
out = append(out, "base_family")
|
||||
}
|
||||
out = append(out, "skeleton")
|
||||
return out
|
||||
}
|
||||
|
||||
// familyOf extracts the proceeding-family prefix from a submission
|
||||
// code. The convention (docs/design-proceeding-code-taxonomy-2026-05-18.md)
|
||||
// is jurisdiction.substantive.forum.submission, so the family is the
|
||||
// first three dot-segments.
|
||||
//
|
||||
// de.inf.lg.erwidg → de.inf.lg
|
||||
// upc.inf.cfi.soc → upc.inf.cfi
|
||||
// dpma.opp.dpma → "" (only three segments — no submission suffix)
|
||||
//
|
||||
// Returns "" when the code doesn't carry a submission segment (no
|
||||
// family-level fallback is meaningful).
|
||||
func familyOf(submissionCode string) string {
|
||||
parts := strings.Split(submissionCode, ".")
|
||||
if len(parts) < 4 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts[:3], ".")
|
||||
}
|
||||
|
||||
// cacheGet returns the cache entry for a Gitea path, creating an empty
|
||||
// entry on first lookup.
|
||||
func (r *TemplateRegistry) cacheGet(path string) *templateCacheEntry {
|
||||
r.cacheMu.Lock()
|
||||
defer r.cacheMu.Unlock()
|
||||
entry, ok := r.cache[path]
|
||||
if !ok {
|
||||
entry = &templateCacheEntry{}
|
||||
r.cache[path] = entry
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// errTemplate404 is an internal sentinel: candidate doesn't exist in
|
||||
// Gitea, walk the chain. Distinguished from network/5xx errors so the
|
||||
// registry doesn't wrap every fallback miss as ErrTemplateUpstream.
|
||||
var errTemplate404 = errors.New("template not found in gitea")
|
||||
|
||||
// fetchInto downloads a candidate and populates the cache entry. On
|
||||
// 404 it marks the entry missing so subsequent lookups short-circuit
|
||||
// without hitting the network.
|
||||
func (r *TemplateRegistry) fetchInto(ctx context.Context, path string, entry *templateCacheEntry) error {
|
||||
sha, err := r.giteaSHA(ctx, path)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTemplate404) {
|
||||
entry.mu.Lock()
|
||||
entry.missing = true
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
data, err := r.giteaDownload(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.mu.Lock()
|
||||
entry.data = data
|
||||
entry.sha = sha
|
||||
entry.lastChecked = time.Now()
|
||||
entry.missing = false
|
||||
entry.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// refresh runs in the background after a stale-but-present cache hit.
|
||||
// SHA-checks the candidate; re-downloads on change. Mirrors the same
|
||||
// goroutine pattern as internal/handlers/files.go.
|
||||
func (r *TemplateRegistry) refresh(ctx context.Context, path string, entry *templateCacheEntry) {
|
||||
entry.mu.Lock()
|
||||
if entry.checking {
|
||||
entry.mu.Unlock()
|
||||
return
|
||||
}
|
||||
entry.checking = true
|
||||
entry.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
entry.mu.Lock()
|
||||
entry.checking = false
|
||||
entry.mu.Unlock()
|
||||
}()
|
||||
|
||||
latestSHA, err := r.giteaSHA(ctx, path)
|
||||
if err != nil {
|
||||
log.Printf("submission template: SHA check for %s failed: %v", path, err)
|
||||
entry.mu.Lock()
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
return
|
||||
}
|
||||
entry.mu.RLock()
|
||||
unchanged := latestSHA == entry.sha && entry.sha != ""
|
||||
entry.mu.RUnlock()
|
||||
if unchanged {
|
||||
entry.mu.Lock()
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
return
|
||||
}
|
||||
data, err := r.giteaDownload(ctx, path)
|
||||
if err != nil {
|
||||
log.Printf("submission template: download %s failed: %v", path, err)
|
||||
entry.mu.Lock()
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
return
|
||||
}
|
||||
entry.mu.Lock()
|
||||
entry.data = data
|
||||
entry.sha = latestSHA
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
log.Printf("submission template: updated %s (SHA: %.8s)", path, latestSHA)
|
||||
}
|
||||
|
||||
// probe is the cheap existence-check used by HasTemplate. Reuses the
|
||||
// cache but only fetches the SHA (not the bytes), so the
|
||||
// SubmissionsPanel's per-row HasTemplate calls don't pull a megabyte
|
||||
// of .docx data the user might never download.
|
||||
func (r *TemplateRegistry) probe(ctx context.Context, path string) bool {
|
||||
entry := r.cacheGet(path)
|
||||
entry.mu.RLock()
|
||||
hasData := !entry.missing && len(entry.data) > 0
|
||||
hasSHA := !entry.missing && entry.sha != ""
|
||||
isMissing := entry.missing
|
||||
needsCheck := time.Since(entry.lastChecked) >= templatesCheckInterval
|
||||
entry.mu.RUnlock()
|
||||
if isMissing && !needsCheck {
|
||||
return false
|
||||
}
|
||||
if hasData || hasSHA {
|
||||
return true
|
||||
}
|
||||
sha, err := r.giteaSHA(ctx, path)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTemplate404) {
|
||||
entry.mu.Lock()
|
||||
entry.missing = true
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
return false
|
||||
}
|
||||
entry.mu.Lock()
|
||||
entry.sha = sha
|
||||
entry.lastChecked = time.Now()
|
||||
entry.missing = false
|
||||
entry.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// giteaSHA returns the SHA of the latest commit that touched the
|
||||
// template path. Returns errTemplate404 when Gitea responds with 404 —
|
||||
// the registry distinguishes "no such template" from "Gitea is down".
|
||||
func (r *TemplateRegistry) giteaSHA(ctx context.Context, path string) (string, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits?path=%s&limit=1&sha=%s",
|
||||
templatesGiteaBaseURL,
|
||||
templatesGiteaRepoOwn,
|
||||
templatesGiteaRepoName,
|
||||
url.QueryEscape(path),
|
||||
templatesGiteaBranch,
|
||||
)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.giteaToken != "" {
|
||||
req.Header.Set("Authorization", "token "+r.giteaToken)
|
||||
}
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", errTemplate404
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("gitea sha lookup returned %d", resp.StatusCode)
|
||||
}
|
||||
var commits []struct {
|
||||
SHA string `json:"sha"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&commits); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(commits) == 0 {
|
||||
return "", errTemplate404
|
||||
}
|
||||
return commits[0].SHA, nil
|
||||
}
|
||||
|
||||
// giteaDownload fetches the raw template bytes.
|
||||
func (r *TemplateRegistry) giteaDownload(ctx context.Context, path string) ([]byte, error) {
|
||||
rawURL := fmt.Sprintf("%s/%s/%s/raw/branch/%s/%s",
|
||||
templatesGiteaBaseURL,
|
||||
templatesGiteaRepoOwn,
|
||||
templatesGiteaRepoName,
|
||||
templatesGiteaBranch,
|
||||
path,
|
||||
)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.giteaToken != "" {
|
||||
req.Header.Set("Authorization", "token "+r.giteaToken)
|
||||
}
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, errTemplate404
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gitea raw returned %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// ClearCache drops every cached entry. Exposed for an admin-side
|
||||
// "refresh templates" affordance — paliad's existing /api/files/refresh
|
||||
// has the same shape for the HL Patents Style proxy.
|
||||
func (r *TemplateRegistry) ClearCache() {
|
||||
r.cacheMu.Lock()
|
||||
defer r.cacheMu.Unlock()
|
||||
for k := range r.cache {
|
||||
r.cache[k] = &templateCacheEntry{}
|
||||
}
|
||||
}
|
||||
535
internal/services/submission_vars.go
Normal file
535
internal/services/submission_vars.go
Normal file
@@ -0,0 +1,535 @@
|
||||
package services
|
||||
|
||||
// Submission variable bag — builds the PlaceholderMap that
|
||||
// SubmissionRenderer fills into a template (t-paliad-215, design doc
|
||||
// docs/design-submission-generator-2026-05-19.md §6.2).
|
||||
//
|
||||
// Variables span six namespaces:
|
||||
//
|
||||
// firm.* process-wide (branding.Name)
|
||||
// user.* caller's user row
|
||||
// today.* server time in Europe/Berlin, locale-aware
|
||||
// project.* paliad.projects + joined proceeding type
|
||||
// parties.* paliad.parties grouped by role
|
||||
// rule.* paliad.deadline_rules row keyed by submission_code
|
||||
// deadline.* next open paliad.deadlines row for (project, rule), if any
|
||||
//
|
||||
// Locale handling: every long-form date string is computed in both DE
|
||||
// and EN; the renderer picks based on the user's lang preference. The
|
||||
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
|
||||
//
|
||||
// Visibility: caller passes userID; ProjectService.GetByID enforces
|
||||
// paliad.can_see_project — unauthorised callers get the standard
|
||||
// ErrNotFound before any variable construction runs.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// SubmissionVarsService assembles the placeholder map.
|
||||
type SubmissionVarsService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
parties *PartyService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewSubmissionVarsService wires the service.
|
||||
func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *PartyService, users *UserService) *SubmissionVarsService {
|
||||
return &SubmissionVarsService{
|
||||
db: db,
|
||||
projects: projects,
|
||||
parties: parties,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
// SubmissionVarsContext is the input bundle that produces a render.
|
||||
type SubmissionVarsContext struct {
|
||||
UserID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
SubmissionCode string
|
||||
}
|
||||
|
||||
// SubmissionVarsResult bundles the placeholder map with the lookup
|
||||
// values the handler needs for the audit row + file naming
|
||||
// (rule.Name, project.case_number, etc.).
|
||||
type SubmissionVarsResult struct {
|
||||
Placeholders PlaceholderMap
|
||||
|
||||
// Resolved entities for audit + naming.
|
||||
User *models.User
|
||||
Project *models.Project
|
||||
Rule *models.DeadlineRule
|
||||
ProceedingType *models.ProceedingType
|
||||
Parties []models.Party
|
||||
NextDeadline *models.Deadline
|
||||
|
||||
// Lang is the user's UI language used to pick locale-aware values
|
||||
// during the build. Returned so the renderer can use the matching
|
||||
// missing-marker function.
|
||||
Lang string
|
||||
}
|
||||
|
||||
// ErrSubmissionRuleNotFound is returned when no published deadline_rule
|
||||
// matches the requested submission_code. Maps to 404 in the handler.
|
||||
var ErrSubmissionRuleNotFound = errors.New("submission generator: no rule found for submission_code")
|
||||
|
||||
// Build resolves every entity and assembles the placeholder map.
|
||||
func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsContext) (*SubmissionVarsResult, error) {
|
||||
if s.projects == nil || s.users == nil {
|
||||
return nil, fmt.Errorf("submission vars: required services not wired")
|
||||
}
|
||||
|
||||
user, err := s.users.GetByID(ctx, in.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
|
||||
// Visibility gate — GetByID returns ErrNotFound when the user
|
||||
// can't see the project, which is exactly the 404 the handler
|
||||
// wants to propagate.
|
||||
project, err := s.projects.GetByID(ctx, in.UserID, in.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rule, err := s.loadPublishedRule(ctx, in.SubmissionCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pt, err := s.loadProceedingType(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parties, err := s.parties.ListForProject(ctx, in.UserID, in.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := s.nextOpenDeadline(ctx, in.ProjectID, rule.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lang := user.Lang
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
addFirmVars(bag)
|
||||
addTodayVars(bag, time.Now())
|
||||
addUserVars(bag, user)
|
||||
addProjectVars(bag, project, pt, lang)
|
||||
addPartyVars(bag, parties)
|
||||
addRuleVars(bag, rule, lang)
|
||||
addDeadlineVars(bag, next, project, lang)
|
||||
|
||||
return &SubmissionVarsResult{
|
||||
Placeholders: bag,
|
||||
User: user,
|
||||
Project: project,
|
||||
Rule: rule,
|
||||
ProceedingType: pt,
|
||||
Parties: parties,
|
||||
NextDeadline: next,
|
||||
Lang: lang,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadPublishedRule fetches the deadline_rule that owns the given
|
||||
// submission_code. Restricts to lifecycle_state='published' so drafts
|
||||
// never end up shaping a real submission.
|
||||
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
}
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order
|
||||
LIMIT 1`, submissionCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load rule by submission_code %q: %w", submissionCode, err)
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
// loadProceedingType fetches the proceeding type row for the project's
|
||||
// proceeding_type_id. Tolerates a nil id (returns nil, nil) so projects
|
||||
// without a bound proceeding still render a meaningful template — the
|
||||
// {{project.proceeding.*}} placeholders just resolve to the missing
|
||||
// marker.
|
||||
func (s *SubmissionVarsService) loadProceedingType(ctx context.Context, id *int) (*models.ProceedingType, error) {
|
||||
if id == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var pt models.ProceedingType
|
||||
err := s.db.GetContext(ctx, &pt,
|
||||
`SELECT `+proceedingTypeColumns+`
|
||||
FROM paliad.proceeding_types
|
||||
WHERE id = $1`, *id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load proceeding type %d: %w", *id, err)
|
||||
}
|
||||
return &pt, nil
|
||||
}
|
||||
|
||||
// nextOpenDeadline finds the earliest pending paliad.deadlines row on
|
||||
// the given project that maps to the chosen rule. Returns (nil, nil)
|
||||
// when no matching deadline exists — common when the lawyer is drafting
|
||||
// the submission before the system has computed its deadline row.
|
||||
func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID, ruleID uuid.UUID) (*models.Deadline, error) {
|
||||
var d models.Deadline
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at,
|
||||
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at
|
||||
FROM paliad.deadlines
|
||||
WHERE project_id = $1
|
||||
AND rule_id = $2
|
||||
AND status = 'pending'
|
||||
ORDER BY due_date ASC
|
||||
LIMIT 1`, projectID, ruleID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load next deadline (project=%s rule=%s): %w", projectID, ruleID, err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// addFirmVars populates the firm.* namespace.
|
||||
func addFirmVars(bag PlaceholderMap) {
|
||||
bag["firm.name"] = branding.Name
|
||||
// firm.signature_block is reserved for Phase 2; emit empty so
|
||||
// templates that already reference it don't render the missing
|
||||
// marker (less noisy for the lawyer).
|
||||
bag["firm.signature_block"] = ""
|
||||
}
|
||||
|
||||
// addTodayVars populates today.* in both DE and EN long forms. ISO
|
||||
// short form is the default {{today}}.
|
||||
func addTodayVars(bag PlaceholderMap, now time.Time) {
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
if loc != nil {
|
||||
now = now.In(loc)
|
||||
}
|
||||
bag["today"] = now.Format("2006-01-02")
|
||||
bag["today.iso"] = now.Format("2006-01-02")
|
||||
bag["today.long_de"] = formatLongDateDE(now)
|
||||
bag["today.long_en"] = formatLongDateEN(now)
|
||||
}
|
||||
|
||||
// addUserVars populates user.*.
|
||||
func addUserVars(bag PlaceholderMap, u *models.User) {
|
||||
bag["user.display_name"] = u.DisplayName
|
||||
bag["user.email"] = u.Email
|
||||
bag["user.office"] = u.Office
|
||||
}
|
||||
|
||||
// addProjectVars populates project.* — title / case_number / court /
|
||||
// patent_number / dates / our_side / proceeding metadata.
|
||||
func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
|
||||
bag["project.title"] = p.Title
|
||||
bag["project.reference"] = derefString(p.Reference)
|
||||
bag["project.case_number"] = derefString(p.CaseNumber)
|
||||
bag["project.court"] = derefString(p.Court)
|
||||
bag["project.patent_number"] = derefString(p.PatentNumber)
|
||||
// project.patent_number_upc is the UPC-brief convention — kind code
|
||||
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
|
||||
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
|
||||
// kind code is present so the lawyer's draft never sees a worse
|
||||
// number than the source value.
|
||||
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
|
||||
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
|
||||
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
|
||||
bag["project.our_side"] = derefString(p.OurSide)
|
||||
bag["project.our_side_de"] = ourSideDE(derefString(p.OurSide))
|
||||
bag["project.our_side_en"] = ourSideEN(derefString(p.OurSide))
|
||||
bag["project.instance_level"] = derefString(p.InstanceLevel)
|
||||
bag["project.client_number"] = derefString(p.ClientNumber)
|
||||
bag["project.matter_number"] = derefString(p.MatterNumber)
|
||||
if pt != nil {
|
||||
bag["project.proceeding.code"] = pt.Code
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["project.proceeding.name"] = pt.NameEN
|
||||
} else {
|
||||
bag["project.proceeding.name"] = pt.Name
|
||||
}
|
||||
bag["project.proceeding.name_de"] = pt.Name
|
||||
bag["project.proceeding.name_en"] = pt.NameEN
|
||||
}
|
||||
}
|
||||
|
||||
// addPartyVars populates parties.* using the first row of each role.
|
||||
// Multi-claimant / multi-defendant suits use the first row in Slice 1
|
||||
// per design §13.6; expanded grouping is Phase 2.
|
||||
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
||||
var claimant, defendant, other *models.Party
|
||||
for i := range parties {
|
||||
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
|
||||
switch role {
|
||||
case "claimant", "kläger", "klaeger":
|
||||
if claimant == nil {
|
||||
claimant = &parties[i]
|
||||
}
|
||||
case "defendant", "beklagter", "beklagte":
|
||||
if defendant == nil {
|
||||
defendant = &parties[i]
|
||||
}
|
||||
default:
|
||||
if other == nil {
|
||||
other = &parties[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if claimant != nil {
|
||||
bag["parties.claimant.name"] = claimant.Name
|
||||
bag["parties.claimant.representative"] = derefString(claimant.Representative)
|
||||
}
|
||||
if defendant != nil {
|
||||
bag["parties.defendant.name"] = defendant.Name
|
||||
bag["parties.defendant.representative"] = derefString(defendant.Representative)
|
||||
}
|
||||
if other != nil {
|
||||
bag["parties.other.name"] = other.Name
|
||||
bag["parties.other.representative"] = derefString(other.Representative)
|
||||
}
|
||||
}
|
||||
|
||||
// addRuleVars populates rule.* — submission_code, name(_en),
|
||||
// legal_source (+ pretty form), primary_party, event_type.
|
||||
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
|
||||
bag["rule.submission_code"] = derefString(r.SubmissionCode)
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["rule.name"] = r.NameEN
|
||||
} else {
|
||||
bag["rule.name"] = r.Name
|
||||
}
|
||||
bag["rule.name_de"] = r.Name
|
||||
bag["rule.name_en"] = r.NameEN
|
||||
bag["rule.legal_source"] = derefString(r.LegalSource)
|
||||
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
|
||||
bag["rule.primary_party"] = derefString(r.PrimaryParty)
|
||||
bag["rule.event_type"] = derefString(r.EventType)
|
||||
}
|
||||
|
||||
// addDeadlineVars populates deadline.* from the next pending row. When
|
||||
// no row exists the values fall through to the missing marker — the
|
||||
// lawyer sees [KEIN WERT: deadline.due_date] in Word and knows to fix.
|
||||
func addDeadlineVars(bag PlaceholderMap, d *models.Deadline, p *models.Project, lang string) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
bag["deadline.due_date"] = d.DueDate.Format("2006-01-02")
|
||||
bag["deadline.due_date_long_de"] = formatLongDateDE(d.DueDate)
|
||||
bag["deadline.due_date_long_en"] = formatLongDateEN(d.DueDate)
|
||||
if d.OriginalDueDate != nil {
|
||||
bag["deadline.original_due_date"] = d.OriginalDueDate.Format("2006-01-02")
|
||||
}
|
||||
// computed_from carries the human-readable anchor description
|
||||
// (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen"). Notes is
|
||||
// the closest existing field — the calculator stores anchor
|
||||
// metadata there. If empty we leave the placeholder unresolved.
|
||||
if d.Notes != nil && strings.TrimSpace(*d.Notes) != "" {
|
||||
bag["deadline.computed_from"] = strings.TrimSpace(*d.Notes)
|
||||
}
|
||||
bag["deadline.title"] = d.Title
|
||||
bag["deadline.source"] = d.Source
|
||||
_ = p // reserved for future shape decisions where the deadline
|
||||
// var depends on project context.
|
||||
_ = lang
|
||||
}
|
||||
|
||||
// derefString returns *s or "" when s is nil.
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// formatDatePtr formats a *time.Time, returning "" for nil.
|
||||
func formatDatePtr(t *time.Time, layout string) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format(layout)
|
||||
}
|
||||
|
||||
// ourSideDE returns the German legal-prose form of an our_side value.
|
||||
func ourSideDE(side string) string {
|
||||
switch strings.ToLower(side) {
|
||||
case "claimant":
|
||||
return "Klägerin"
|
||||
case "defendant":
|
||||
return "Beklagte"
|
||||
case "court":
|
||||
return "Gericht"
|
||||
case "both":
|
||||
return "Klägerin und Beklagte"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ourSideEN returns the English legal-prose form of an our_side value.
|
||||
func ourSideEN(side string) string {
|
||||
switch strings.ToLower(side) {
|
||||
case "claimant":
|
||||
return "Claimant"
|
||||
case "defendant":
|
||||
return "Defendant"
|
||||
case "court":
|
||||
return "Court"
|
||||
case "both":
|
||||
return "Claimant and Defendant"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatLongDateDE renders a date in the German long form
|
||||
// ("19. Mai 2026"). Pure function for unit testing.
|
||||
func formatLongDateDE(t time.Time) string {
|
||||
months := []string{
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
}
|
||||
idx := int(t.Month()) - 1
|
||||
if idx < 0 || idx >= len(months) {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
return fmt.Sprintf("%d. %s %d", t.Day(), months[idx], t.Year())
|
||||
}
|
||||
|
||||
// formatLongDateEN renders a date in the English long form
|
||||
// ("19 May 2026").
|
||||
func formatLongDateEN(t time.Time) string {
|
||||
return t.Format("2 January 2006")
|
||||
}
|
||||
|
||||
// legalSourcePretty rewrites the shorthand stored on deadline_rules
|
||||
// (DE.ZPO.276.1, UPC.RoP.23.1, …) into the form a lawyer would type
|
||||
// in a brief ("§ 276 Abs. 1 ZPO", "Rule 23.1 RoP UPC"). Unknown
|
||||
// prefixes pass through unchanged — preferring the raw shorthand over
|
||||
// an incorrect prettification.
|
||||
//
|
||||
// Lang controls the language of connective words (Abs / Section,
|
||||
// Regel / Rule, …). The pretty table covers the prefixes used by the
|
||||
// 254 published rules in the corpus today; new prefixes default to
|
||||
// pass-through and a follow-up CL extends the table.
|
||||
func legalSourcePretty(src, lang string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
en := strings.EqualFold(lang, "en")
|
||||
|
||||
switch {
|
||||
case len(parts) == 4 && parts[0] == "DE" && parts[1] == "ZPO":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s(%s) ZPO", parts[2], parts[3])
|
||||
}
|
||||
return fmt.Sprintf("§ %s Abs. %s ZPO", parts[2], parts[3])
|
||||
case len(parts) == 3 && parts[0] == "DE" && parts[1] == "ZPO":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s ZPO", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("§ %s ZPO", parts[2])
|
||||
case len(parts) == 4 && parts[0] == "UPC" && parts[1] == "RoP":
|
||||
if en {
|
||||
return fmt.Sprintf("Rule %s.%s RoP UPC", parts[2], parts[3])
|
||||
}
|
||||
return fmt.Sprintf("Regel %s.%s VerfO UPC", parts[2], parts[3])
|
||||
case len(parts) == 3 && parts[0] == "UPC" && parts[1] == "RoP":
|
||||
if en {
|
||||
return fmt.Sprintf("Rule %s RoP UPC", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("Regel %s VerfO UPC", parts[2])
|
||||
case len(parts) >= 3 && parts[0] == "DE" && parts[1] == "PatG":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s PatG", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("§ %s PatG", parts[2])
|
||||
case len(parts) == 2 && parts[0] == "EPC":
|
||||
if en {
|
||||
return fmt.Sprintf("Art. %s EPC", parts[1])
|
||||
}
|
||||
return fmt.Sprintf("Art. %s EPÜ", parts[1])
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// patentNumberKindCodeRegex matches a trailing kind code on a patent
|
||||
// number: a whitespace-separated single uppercase letter followed by
|
||||
// a single digit (B1, A1, A2, B2, B9, C1, T2, U1, …). Capturing
|
||||
// groups split the base from the kind code so the formatter can
|
||||
// parenthesise the kind without touching the rest of the number.
|
||||
var patentNumberKindCodeRegex = regexp.MustCompile(`^(.*?)\s+([A-Z]\d)$`)
|
||||
|
||||
// patentNumberUPC reformats a patent number from the DE convention
|
||||
// ("EP 1 234 567 B1") to the UPC-brief convention
|
||||
// ("EP 1 234 567 (B1)"). The kind code is parenthesised; everything
|
||||
// else is preserved verbatim. Numbers without a recognised trailing
|
||||
// kind code pass through unchanged so a lawyer's draft never sees a
|
||||
// number worse than the source value.
|
||||
//
|
||||
// Recognised inputs:
|
||||
//
|
||||
// "EP 1 234 567 B1" → "EP 1 234 567 (B1)"
|
||||
// "EP 4 056 049 A1" → "EP 4 056 049 (A1)"
|
||||
// "DE 10 2020 123 456 A1" → "DE 10 2020 123 456 (A1)"
|
||||
// " EP 1 234 567 B1 " → "EP 1 234 567 (B1)" (trimmed)
|
||||
//
|
||||
// Pass-through:
|
||||
//
|
||||
// "EP 1 234 567" → "EP 1 234 567"
|
||||
// "WO/2023/123456" → "WO/2023/123456" (no kind code shape)
|
||||
// "" → ""
|
||||
//
|
||||
// Pure function; unit-tested in submission_vars_test.go.
|
||||
func patentNumberUPC(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if m := patentNumberKindCodeRegex.FindStringSubmatch(s); m != nil {
|
||||
base := strings.TrimSpace(m[1])
|
||||
kind := m[2]
|
||||
if base == "" {
|
||||
return s
|
||||
}
|
||||
return base + " (" + kind + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -569,6 +569,11 @@ func approvalRowSubtitle(r ApprovalRequestView) string {
|
||||
return "Abgelehnt"
|
||||
case "revoked":
|
||||
return "Widerrufen"
|
||||
case "changes_requested":
|
||||
if r.DeciderName != nil {
|
||||
return fmt.Sprintf("Abgelehnt mit Vorschlag von %s", *r.DeciderName)
|
||||
}
|
||||
return "Abgelehnt mit Vorschlag"
|
||||
}
|
||||
return r.Status
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user