Compare commits

..

1 Commits

Author SHA1 Message Date
mAi
d6caa490dc docs(submission-generator): t-paliad-215 inventor design
DESIGN READY FOR REVIEW — copernicus inventor pass on the submission
generator (t-paliad-215). 5 questions answered with m's picks captured
in §2; awaiting head's go/no-go on coder shift.

Locked decisions:
- Scope: template-render to .docx (no LLM in v1)
- Template registry: Gitea (mWorkRepo proxy, same pattern as
  HL Patents Style)
- Output: direct download, no server-side binary persistence
- Mapping: fallback chain (firm → base/code → base/family → skeleton)
- Slice 1: one template end-to-end on one project
  (de.inf.lg.erwidg / Klageerwiderung)

No code, no migrations, no schema additions. Read-only design phase
per inventor SKILL.md.
2026-05-19 13:20:10 +02:00
50 changed files with 92 additions and 6528 deletions

View File

@@ -183,20 +183,6 @@ func main() {
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):

View File

@@ -1,332 +0,0 @@
# 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.

View File

@@ -1,425 +0,0 @@
# Slice 2 — project-subtree sync export (t-paliad-214)
Design: archimedes (inventor), 2026-05-20.
Task: **t-paliad-214 Slice 2**.
Branch: `mai/archimedes/inventor-excel-data` (continuation from Slice 1).
Status: READY FOR REVIEW — no code yet, awaiting m go/no-go on §10 open decisions.
Builds on `docs/design-paliad-data-export-2026-05-19.md` (Slice 1 design + §12 m's decisions) which is now merged + shipped. **This doc covers only what changes for Slice 2.** Cross-reference §2.2 of the Slice 1 doc for the original project-scope sketch — this Slice 2 doc refines it with live-state verification + explicit picks on the questions Slice 1 left open.
---
## 0. Premise check (live state, 2026-05-20)
Verified directly against the youpc Postgres `paliad` schema + branch state.
**Slice 1 status:** merged at `bf31935` (Slice 1 main) + `f758537` (xlsx fix). System-audit-log table + `ExportService.WritePersonal` + `GET /api/me/export` + Datenexport tab on /settings are live on paliad.de.
**ExportService is scope-agnostic.** The Slice 1 implementation deliberately threaded the scope-aware predicate through `personalSheetQueries(actorID)`. Slice 2 adds a parallel `projectSheetQueries(actorID, rootProjectID)` and a new handler — the writer + zip-assembly + audit-row plumbing are all reused as-is. No refactor needed before adding scope #2.
**Subtree size at firm-scale today.** The largest single project subtree in the org (Siemens AG, the only one with a meaningful tree) carries:
| entity | rows (subtree) |
|-------------------|---------------:|
| deadlines | 29 |
| appointments | 4 |
| notes | 1 |
| project_events | 80 |
Smallest non-trivial subtree (Mandant vs Gegner) is 1 + 1 + 1 + 26. **At firm-scale today every project subtree fits comfortably in a sub-megabyte synchronous response.** A "big firm with 1000 active projects each with 50 deadlines" would generate workbooks under 20MB — still synchronously serveable with a 30s watchdog.
**Migration tracker** at `106_add_madrid_office`; next free = `107`. Slice 2 does not need a new migration (system_audit_log already covers project scope via `scope='project' + scope_root=<root_id>`).
**Project responsibility enum** (`internal/services/approval_levels.go:29-32`) is the locked set: `lead` / `member` / `observer` / `external`. m's Slice 1 Q2 decision was "any team member with responsibility ∈ {lead, member}" — observers + externals see but don't extract.
**Visibility predicate.** `visibilityPredicatePositional(alias, $1)` is the canonical RLS-mirror used by every list endpoint. `projectDescendantPredicate(alias)` is the ltree subtree filter for sqlx-named queries. Slice 2 needs both: visibility gates the *caller's right to extract*; descendant filter gates *which rows belong in the export*.
---
## 1. Why Slice 2
Use cases that came up in Slice 1's design pass but couldn't be served by personal scope alone:
1. **Archival handover.** A matter closes; the partner wants a single artifact representing the entire project tree (Client → Litigation → Patent → Case) to drop into NetDocuments / Highvail.
2. **Due-diligence package.** Outside counsel asks for "everything paliad knows about Acme v. Beta". The partner runs the project export, attaches the .zip to an email, done.
3. **Per-matter audit response.** Compliance asks "what did paliad record about this proceeding between dates X and Y?" The export carries the audit trail (`project_events` + relevant `system_audit_log` rows) for the subtree, untouched.
4. **Inter-firm handover** when a matter migrates to a different firm — the no-lock-in promise from Slice 1's framing.
Personal scope is *user-centric* ("everything I can see"). Project scope is *matter-centric* ("everything about this matter"). They are complementary, not redundant.
---
## 2. Scope definition (precise)
**Root:** the project whose UUID is passed in the URL path (`/api/projects/{id}/export`).
**Subtree:** root + all descendants via ltree path (`paliad.projects.path @> root.path` or, in the application-layer mirror, `projectDescendantPredicate("p")` bound to `:project_id = root_id`).
**Caller filter:** Visibility predicate is implicit because the caller must already pass `can_see_project(root_id)` to use the endpoint at all — but we additionally narrow the user-disclosure sheets (see "Restricted users sheet" below).
Per-sheet inclusion:
| sheet name | source table(s) | filter |
|-----------------------|----------------------------------------------------------|--------|
| `projects` | `paliad.projects` | `path @> root.path` (root + descendants) |
| `project_teams` | `paliad.project_teams` | `project_id IN subtree` |
| `project_partner_units` | `paliad.project_partner_units` | `project_id IN subtree` |
| `deadlines` | `paliad.deadlines` | `project_id IN subtree` |
| `appointments` | `paliad.appointments` | `project_id IN subtree` |
| `parties` | `paliad.parties` | `project_id IN subtree` |
| `notes` | `paliad.notes` (4-way polymorphic, resolved to project) | the note's effective project ∈ subtree |
| `documents` | `paliad.documents` (metadata only — `ai_extracted` jsonb dropped) | `project_id IN subtree` |
| `project_events` | `paliad.project_events` (audit) | `project_id IN subtree` |
| `approval_requests` | `paliad.approval_requests` | `project_id IN subtree`, including completed + rejected |
| `approval_policies` | `paliad.approval_policies` | union of: (project rows for subtree) + (ancestor rows of root) + (partner-unit defaults attached to any subtree project), with a `source` attribution column |
| `checklist_instances` | `paliad.checklist_instances` | `project_id IN subtree` |
| `partner_units` | `paliad.partner_units` | only units attached to any subtree project (via `project_partner_units`) |
| `partner_unit_members`| `paliad.partner_unit_members` | only members of the attached units |
| `users_referenced` | restricted id/email/display_name/office/profession | only users referenced as FK anywhere in the export |
| `system_audit_log_subset` | `paliad.system_audit_log` | rows with `scope_root IN subtree` — captures who has exported this subtree (and when) historically |
**`__meta` sheet** + `__meta.json` + `README.txt`: identical shape to Slice 1, with `scope=project` + `scope_root_id=<root>` + `scope_root_label=<root.title>` added.
**Reference sheets (`ref__*`).** Same set as Slice 1: `proceeding_types`, `event_types`, `event_categories`, `deadline_rules`, `deadline_concepts`, `courts`, `countries`, `holidays`. Identical bytes across all exports of the same `__meta.generated_at` (reference tables don't change per-project).
**Explicit exclusions:**
- `users` (full user roster) — replaced by `users_referenced` (restricted).
- `partner_units` (org-wide list) — replaced by attached-only subset.
- Personal sidecars (`user_views`, `user_caldav_config`, `user_pinned_projects`, `user_card_layouts`, `paliadin_turns`) — these are per-user, not per-project. Calling user's caldav config + views do NOT belong in a project handover.
- `invitations` — org-wide invite pipeline, not project-data.
- `auth.*` schema — not paliad's.
- Migration shadow tables (`*_pre_NNN`) — Slice 1 same.
- Credential-shaped columns — same PII deny-regex as Slice 1.
---
## 3. Endpoint shape
```
GET /api/projects/{id}/export
```
**Auth:** existing protected mux middleware (`auth.Middleware` + `auth.WithUserID`).
**Path param:** `{id}` is the root project's UUID. Service errors → handler maps to 404 (`ErrNotVisible`) / 400 (`ErrInvalidInput`) per the existing `writeServiceError` pattern.
**Query params:**
| param | default | values | meaning |
|---------------|---------|--------|---------|
| `direct_only` | `false` | `0`/`1` | When `1`, narrow the export to the root project only (no descendants). Mirrors the existing `?direct_only=` on `/api/projects/{id}/events`. Default = subtree-inclusive. |
| `format` | `zip` | `zip` only (v1) | Reserved for future `xlsx-only` / `json-only` flags. Documented in README only. |
**Response:**
- `200 OK`, `Content-Type: application/zip`, `Content-Disposition: attachment; filename="paliad-export-project-<slug>-<ts>.zip"`, `Content-Length: <size>`, `X-Paliad-Export-Audit-Id: <uuid>`.
- `403 Forbidden` with `{code, message}` when caller fails the §4 profession + responsibility gate.
- `404 Not Found` when `can_see_project(root_id)` returns false.
- `500` on internal error (audit row marked `data_export_failed`).
- `503` if DB / ExportService is unavailable (same `requireDB` pattern as every other handler).
**Filename:**
```
paliad-export-project-<slug>-<short-uuid>-<timestamp>.zip
slug = slugifyFilename(root.title), capped 40 chars
short-uuid = last 8 hex chars of root.id (disambiguator for similar titles)
timestamp = YYYY-MM-DDTHHMMZ UTC
```
Example: `paliad-export-project-Siemens-AG-69e2cacb-2026-05-20T1042Z.zip`.
The short-uuid is new compared to Slice 1's `paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip`. **Reasoning:** two projects can have identical titles (a partner running a long-lived "Standard NDA" project per client would produce filename collisions when archived together). 8 hex chars give 4 billion-class disambiguation space — overkill, but cheap.
---
## 4. Permission gate
Per Slice 1's Q2 lock-in (m's call 2026-05-19), the gate is **purely responsibility-based**, no profession floor:
```
caller MUST satisfy ALL of:
(a) auth.UserIDFromContext(r.Context()) — i.e. authenticated
(b) can_see_project(root_id) — RLS visibility
(c) EXISTS (paliad.project_teams pt
WHERE pt.user_id = caller
AND pt.project_id = root_id
AND pt.responsibility IN ('lead', 'member'))
OR caller is global_admin
```
**Why a `project_teams` direct-membership check (and not effective-role via derivation)?** Derivation grants visibility (you can SEE the project) but not extraction authority. A PA member of an attached Partner Unit who is *derived* into the project via `project_partner_units.derive_grants_authority=true` can approve writes, but extracting the matter file is a different sovereignty axis — partner & lead/member explicitly committed to the matter own the data, derived-only viewers shouldn't be able to walk away with the bundle.
If m wants to loosen this to "anyone who can write is allowed to extract" (i.e. include derived-authority users), it's a one-line change on the SQL. Flagged as Q1 in §10.
**Observers + Externals:** read-only, no extraction. They can still see the project at runtime; they cannot walk away with the workbook.
**Global admins:** can extract anything anywhere — same as `/admin/*`. The audit row records this.
**Edge case — caller is on the root's team but not on a descendant's team.** Still allowed — the gate is at the *root*, not per-descendant. This mirrors how `can_see_project` extends visibility down the tree once you're on any ancestor. Pulling-the-tree from the root is the whole point.
---
## 5. Reused vs new code
What gets reused from Slice 1 (zero changes):
- `ExportService.writeBundle(ctx, w, sheets, &meta)` — scope-agnostic.
- `buildXLSX`, `buildJSON`, `buildCSV`, `buildREADME`, `metaToKeyValueRows`, `byteBuf`.
- `formatCellValue` — value coercion.
- `piiColumnDenyRegex` + per-sheet `DropColumns` mechanism.
- `WriteAuditRow` / `PatchAuditRowSuccess` / `PatchAuditRowFailure` — audit-chain.
- `ExportFilename` — adds project-scope-specific behavior (already a switch on scope).
- The `__meta` sheet + `__meta.json` shape.
- The 30s context watchdog from Slice 1's handler.
What's new:
1. **`projectSheetQueries(actorID, rootID uuid.UUID, directOnly bool) []sheetQuery`** in `export_service.go` — returns the project-scope sheet registry. ~250 LoC of SQL recipes.
2. **`ExportService.WriteProject(ctx, w, spec ExportSpec, directOnly bool) (ExportMeta, error)`** — mirror of `WritePersonal`, calls `writeBundle` with the new sheet set.
3. **`handleProjectExport(w, r *http.Request)`** in `internal/handlers/export.go` — handler with the §4 gate. ~80 LoC of route plumbing + auth checks.
4. **Route registration** in `handlers.go`:
```go
protected.HandleFunc("GET /api/projects/{id}/export", handleProjectExport)
```
5. **UI affordance** on `/projects/{id}` — a "Daten dieses Projekts exportieren" entry in the project's settings menu (the cog icon, or whatever menu the project-detail page already has). Triggers the same transient-`<a download>` pattern as Slice 1.
6. **`ExportFilename` extension** — accept the short-uuid + slug. One-line change.
Estimated total: **~600 LoC backend + ~50 LoC frontend + ~10 i18n keys DE+EN**.
No new migration (system_audit_log already supports `scope='project'`).
---
## 6. Edge cases
### 6.1 Cross-project references
`paliad.projects.counterclaim_of` is a self-FK that can point at a project *outside* the subtree (a counterclaim under one matter referencing the parent matter elsewhere). Two policy options:
- **Inventor pick: keep the FK column with the foreign UUID; add a warning row in `__meta.warnings` listing every cross-subtree FK so the consumer knows.**
Reasoning: silently severing references is the *opposite* of the no-lock-in promise. Importers can choose to keep the reference (resolving via UUID join) or strip it.
- Alternative: NULL the column out. Simpler but lossier.
Same policy applies to any future self-FK column on `projects` or polymorphic FKs that escape the subtree.
### 6.2 Notes' 4-way polymorphism
`paliad.notes` has `project_id`, `deadline_id`, `appointment_id`, `project_event_id` — exactly one is non-NULL. To filter, resolve each to its effective `project_id` and intersect with the subtree:
```sql
SELECT * FROM paliad.notes
WHERE COALESCE(
project_id,
(SELECT d.project_id FROM paliad.deadlines d WHERE d.id = notes.deadline_id),
(SELECT a.project_id FROM paliad.appointments a WHERE a.id = notes.appointment_id),
(SELECT pe.project_id FROM paliad.project_events pe WHERE pe.id = notes.project_event_id)
) IN <subtree>
```
Same pattern as Slice 1's personal-scope notes query. No new code.
### 6.3 Partner-unit data
`partner_units` is org-wide (11 rows today). `project_partner_units` attaches specific units to specific projects, optionally with `derive_grants_authority=true` to extend approval power. For project export:
- `project_partner_units` rows for subtree projects → included.
- `partner_units` → only the units referenced by those attachments.
- `partner_unit_members` → only members of those units.
- `partner_unit_events` (audit) → excluded (it's org-meta, not project-data; the user export from Slice 1 already gates this to admin-only).
This lets a recipient reconstruct "who could approve writes on this matter at the time of export" without dumping the full org chart.
### 6.4 Approval policies — full chain with attribution
A project's effective approval policy can come from three sources (per t-paliad-154 design):
1. Project-row policy on this project.
2. Project-row policy on an ancestor.
3. Partner-unit-default policy attached to this project.
For the export, we ship **all three sources** as separate rows in the `approval_policies` sheet, each tagged with a `source` column (`'project'` / `'ancestor'` / `'partner_unit_default'`). The recipient can reconstruct the effective policy by applying the same MAX-of-sources logic the live app uses.
Without all three sources, an importer asks "why is this approval required?" and has no answer.
### 6.5 paliadin_turns
Excluded from project scope. They are user-AI conversations, person-specific, not project-data. (Same hard-exclude as m's Q5 decision for org scope.)
### 6.6 Caller's `direct_only=true` semantics
When `?direct_only=1`:
- `projects` sheet contains exactly one row (the root).
- All entity sheets filter by `project_id = root.id` (no IN-subquery).
- `project_partner_units` + `partner_units` filter to those attached directly to the root.
- Cross-project warnings for descendants don't apply (since descendants aren't in scope).
- Filename slug stays unchanged (still derived from root.title).
Use case: an associate wants just this case's data, not the parent client or sibling matters. Useful for handover of one specific proceeding.
### 6.7 Concurrent edits during export
The export runs in a single Postgres transaction (default read-committed isolation). Inserts that land mid-export may or may not appear depending on the snapshot. We don't ship REPEATABLE READ or SERIALIZABLE — at sub-megabyte scope it doesn't matter, and adding transaction-level juggling for a corner case isn't worth the complexity. The `__meta.generated_at` is the snapshot anchor.
---
## 7. Audit row shape
Existing `paliad.system_audit_log` table from Slice 1's mig 102. The Slice 2 handler writes:
```
event_type = 'data_export'
actor_id = caller's uuid
actor_email = caller's email captured at write time
scope = 'project'
scope_root = root project's uuid
metadata = { "requested_at": "<rfc3339>",
"direct_only": false,
"root_label": "Siemens AG",
"root_path": "00000000_..._.61e3fb9e_..." // ltree path for posterity
}
```
On success, `PatchAuditRowSuccess` adds:
```
metadata.row_counts = { "projects": 1, "deadlines": 29, ... }
metadata.file_size_bytes = <int>
metadata.warnings = [ "subtree references project <uuid> via counterclaim_of",
"sheet=foo column=token dropped (PII deny-list)", ... ]
metadata.completed_at = "<rfc3339>"
```
On failure, `event_type` flips to `data_export_failed`, `metadata.error = "<stringified error>"`.
The `system_audit_log` already surfaces on `/admin/audit-log` (6th union branch added in Slice 1). Project leads will see the export rows for *their* projects (because `scope_root` is forwarded as `project_id` in the union projection). Global admins see everything.
---
## 8. Trade-offs flagged
1. **Synchronous-only for now.** A pathological 1M-row subtree would block a request goroutine for >30s; the watchdog kicks in and the user gets a 503. We could lift to async (Slice 3 territory) when this actually happens. Not now.
2. **Reference data ships with every project export.** ~1000 rows of `deadline_rules` + `event_types` + … = ~70KB compressed in every workbook. Acceptable cost for self-interpretability. A later optimization could split reference into a separate `paliad-reference-snapshot.zip` and have the project export `README` link to it.
3. **Cross-subtree FK retention adds a warning surface.** Recipients of an export with cross-subtree counterclaim_of refs see warnings in `__meta` but no resolution path. That's correct behavior — but future "diff two exports" tooling will need to handle FK-to-non-present-row gracefully. Slice 6+ concern, not blocking.
4. **The §4 gate is stricter than visibility.** A derived-only user can `GET /api/projects/{id}` but not `GET /api/projects/{id}/export`. They'll see a 403. Worth surfacing in the UI as a tooltip: "Datenexport ist nur Team-Mitgliedern (Lead / Member) vorbehalten." Otherwise users hit the 403 and don't know why.
5. **`direct_only` is a power-user knob.** No UI for it in v1 — only accessible via query param. Documented in `README.txt` only. Avoids a confusing toggle on the export menu when 90% of exports want the subtree.
6. **No streaming.** We buffer the whole bundle in memory before sending headers (so audit-row patch + `Content-Length` can be set before flush). At firm-scale today this is sub-megabyte. At firm-scale-100x this would still fit; at firm-scale-10000x we'd need to switch to chunked + skip the precise `Content-Length`.
7. **`approval_policies` triple-source carries some redundancy.** A project with no own policy + an ancestor policy will show one row tagged `source='ancestor'`. A project with both will show two rows (one per source) with separate `required_role` values. Slightly more rows but it makes the workbook honest about provenance.
---
## 9. Slice scope vs deferred
**v1 (this slice ships):**
- `GET /api/projects/{id}/export` with `?direct_only=` query param.
- UI affordance on `/projects/{id}` cog menu.
- Subtree-inclusive xlsx + JSON + CSV bundle.
- All §2 sheets including reference + restricted users + partner-unit subset.
- Audit row in `system_audit_log` with row_counts + warnings.
**Deferred to Slice 3 (org export, async):**
- Async with job-tracking + on-disk artifact.
- Cleanup goroutine + retention env.
- Scope=`org` sheet registry (full schema dump).
**Deferred to later slices (no change from Slice 1's plan):**
- Slice 4 — scheduled exports.
- Slice 5 — API ergonomics (PATs).
- Slice 6 — DSR helper UI.
- Slice 7 — document binary inclusion.
---
## 10. Open decisions for m
Per the head's instruction (2026-05-20 brief): **NO AskUserQuestion this round.** Head batches m's picks across 4 inventors today. These are listed for m to ratify in one combined session.
Each item: inventor pick first, alternative(s) after, with reasoning.
### Q1 — Authority gate: responsibility-only (lead/member) or include derived-authority users?
**Inventor pick: responsibility ∈ {lead, member} only.** A direct team commitment is the sovereignty axis for extraction. Derived-via-partner-unit users have approval authority but aren't matter owners.
Alternative: union `(responsibility ∈ {lead, member})` with `(EffectiveProjectRole returns DerivedPeer)`. Slightly broader; lets a PA on the Munich Lit unit extract every Munich Lit matter they're derived into.
This is the question Slice 1's Q2 locked at the surface level ("any team member with responsibility ∈ {lead, member}") but didn't address the derivation interaction. Confirming here.
### Q2 — `direct_only` query param: ship in v1 or defer?
**Inventor pick: ship in v1 as a query-only knob, no UI.** It's a one-line code path (predicate switch); deferring forces a follow-up slice for a power-user need.
Alternative: defer; v1 is subtree-always. Marginal UI simplicity gain (no `?direct_only=` mention in `README.txt`). Costs: future support tickets ("how do I export just this one case?").
### Q3 — Cross-subtree FK handling: keep with warning or NULL out?
**Inventor pick: keep the FK column, add a warning row in `__meta`.** Preserves the no-lock-in promise (an importer can choose to keep or strip the reference). NULL-ing is silent data loss.
Alternative: NULL the column on export. Simpler workbook; rejects "keep references for integrity" use case.
### Q4 — `approval_policies` sheet: include all 3 source-attributed rows, or just project rows?
**Inventor pick: all 3 sources, each tagged with `source` column.** A recipient needs to know "why is this approval required" without re-running paliad's MAX-resolver. Slice 1's design §2.2 already proposed this; Slice 2 lands it.
Alternative: project-row policies only. Recipient sees `required_role=NULL` and has no recourse to discover the ancestor / partner-unit-default policy that actually applies.
### Q5 — Filename short-uuid disambiguator: include 8-hex-suffix or just slug?
**Inventor pick: include short-uuid suffix.** Two projects with identical titles (common: "Standard NDA" per client) would otherwise produce filename collisions when archived together. 4 billion-class disambiguation is cheap.
Alternative: just the title slug. Cleaner-looking filename; collision-risk per long-lived firm.
### Q6 — System audit row: include the project's ltree path in metadata?
**Inventor pick: yes, include `metadata.root_path`.** The audit row outlives the project deletion; preserving the path lets a future audit query reconstruct ancestry even after the matter is closed.
Alternative: just `scope_root` (the UUID). Tighter audit row; ancestry recoverable only while the project still exists.
### Q7 — 403 messaging: bilingual or English only?
**Inventor pick: bilingual.** Paliad is German-first; the gate copy needs both languages. The pattern matches `mapApprovalError` (handlers/projects.go:96-101) which already emits bilingual error text.
Alternative: English. Smaller code; misaligned with paliad's product language.
---
## 11. Recommended implementer
Continuity matters here. Slice 1's writer abstraction is mine; Slice 2 generalises it. Same hands.
- archimedes (this worker) for the backend + UI + tests.
- Fresh Sonnet coder is OK but would re-discover the writer-abstraction seams.
**NOT cronus** per memory directive 2026-05-06 (retired from paliad).
---
## 12. Adjacent work
- **Slice 1** is shipped + live on paliad.de (`/api/me/export`).
- **Slice 3** (org async) — designed in Slice 1's §7; remains deferred until Slice 2 ships.
- **t-paliad-215** (submission generator) — separate workstream; no overlap.
- **t-paliad-216** (suggest-changes) — Slice C merged to main; no overlap.
- The new `paliad.system_audit_log` table from Slice 1 is the audit substrate; Slice 2 reuses it untouched.
---
## 13. References
- `docs/design-paliad-data-export-2026-05-19.md` — Slice 1 design + §12 m's decisions.
- `internal/services/export_service.go` — current ExportService impl (scope-agnostic).
- `internal/services/visibility.go` — `visibilityPredicatePositional` + `projectDescendantPredicate`.
- `internal/services/approval_levels.go:29-32` — responsibility enum.
- `internal/services/team_service.go:47-95` — `AddMember` + `legacyRoleFromResponsibility`.
- `internal/handlers/handlers.go` — protected-mux route registration.
- `internal/db/migrations/102_system_audit_log.up.sql` — audit table.
---
**END OF DESIGN. Status: READY FOR REVIEW.**
Inventor parks until m's batched picks come back. No code touches the tree from this branch in this shift.

View File

@@ -1,52 +0,0 @@
# 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 16 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.

View File

@@ -1,126 +0,0 @@
<!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> &rarr; Gruppe <em>Manage</em> &rarr; <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 &amp; 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> &middot; 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>

View File

@@ -1,255 +0,0 @@
// 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"))}">&times;</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 "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case '"': return "&quot;";
default: return c;
}
});
}

View File

@@ -162,11 +162,10 @@ 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: "changes_requested", key: "views.bar.approval_status.changes_requested" },
{ 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" },
];
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {

View File

@@ -57,19 +57,6 @@ 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
@@ -404,8 +391,8 @@ function renderProcedureResults(data: DeadlineResponse) {
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true, showNotes })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
? renderColumnsBody(data, { editable: true })
: renderTimelineBody(data, { showParty: true, editable: true });
container.innerHTML = headerHtml + bodyHtml;
printBtn.style.display = "block";
@@ -674,18 +661,6 @@ 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();

View File

@@ -300,7 +300,6 @@ 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",
@@ -972,22 +971,18 @@ 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",
@@ -1261,18 +1256,6 @@ 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.export.button": "Daten exportieren",
"projects.detail.export.tooltip": "Daten dieses Projekts (mit Unter-Projekten) als Excel + JSON + CSV herunterladen.",
"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).
@@ -2231,21 +2214,10 @@ 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",
@@ -2257,12 +2229,9 @@ 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?",
@@ -2430,7 +2399,6 @@ 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",
@@ -2931,7 +2899,6 @@ 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",
@@ -3591,22 +3558,18 @@ 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",
@@ -3880,18 +3843,6 @@ 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.export.button": "Export data",
"projects.detail.export.tooltip": "Download this project's data (including sub-projects) as Excel + JSON + CSV.",
"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.",
@@ -4846,21 +4797,10 @@ 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",
@@ -4872,12 +4812,9 @@ 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?",
@@ -5044,7 +4981,6 @@ 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",

View File

@@ -4,7 +4,6 @@ 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.
//
@@ -124,20 +123,11 @@ function paint(
function wireApprovalActions(host: HTMLElement): void {
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
const action = btn.dataset.action as
| "approve"
| "reject"
| "revoke"
| "suggest_changes"
| undefined;
const action = btn.dataset.action as "approve" | "reject" | "revoke" | 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")) || "";
@@ -151,8 +141,8 @@ function wireApprovalActions(host: HTMLElement): void {
body: JSON.stringify({ note }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({} as { error?: string; code?: string }));
alert(mapApprovalError(body.code || body.error || "internal"));
const body = await r.json().catch(() => ({} as { error?: string }));
alert(mapApprovalError(body.error || "internal"));
btn.disabled = false;
return;
}
@@ -166,97 +156,14 @@ 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");
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;
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;
}
}

View File

@@ -12,7 +12,6 @@ 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;
@@ -159,8 +158,7 @@ type TabId =
| "deadlines"
| "appointments"
| "notes"
| "checklists"
| "submissions";
| "checklists";
const VALID_TABS: TabId[] = [
"history",
@@ -171,7 +169,6 @@ const VALID_TABS: TabId[] = [
"appointments",
"notes",
"checklists",
"submissions",
];
// Legacy German tab slugs that may appear in bookmarked URLs after the
@@ -1613,9 +1610,6 @@ function showTab(tab: TabId) {
if (tab === "checklists" && project) {
void loadAndRenderChecklistInstances(project.id);
}
if (tab === "submissions" && project) {
void loadAndRenderSubmissions(project.id);
}
}
let checklistInstancesInited = false;
@@ -2064,7 +2058,6 @@ async function main() {
initAttachUnitForm(id);
initNotesContainer(id);
mountVerlaufFilterBar(id);
wireExportButton(id);
showTab(parseTab());
}
@@ -2687,41 +2680,6 @@ function canManagePartnerUnits(): boolean {
);
}
// canExportProject mirrors the §4 server-side gate for /api/projects/{id}/export:
// global_admin OR direct team responsibility ∈ {lead, member}. Used to
// reveal the export button — server still re-enforces on the request.
function canExportProject(): boolean {
if (!me || !project) return false;
if (me.global_role === "global_admin") return true;
return teamMembers.some(
(m) =>
m.user_id === me!.id &&
m.project_id === project!.id &&
(m.responsibility === "lead" || m.responsibility === "member"),
);
}
// wireExportButton reveals + hooks up the project-export button on the
// tabs nav. Triggers a download via a transient <a download> — same
// pattern as the personal export in client/settings.ts.
function wireExportButton(projectID: string): void {
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
if (!btn) return;
if (!canExportProject()) {
btn.style.display = "none";
return;
}
btn.style.display = "";
btn.addEventListener("click", () => {
const a = document.createElement("a");
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
}
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
if (!me) return false;
if (m.user_id === me.id) return true;

View File

@@ -1,208 +0,0 @@
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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);
}

View File

@@ -25,19 +25,6 @@ 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 /
@@ -180,8 +167,8 @@ function renderResults(data: DeadlineResponse) {
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { showNotes })
: renderTimelineBody(data, { showParty: true, showNotes });
? renderColumnsBody(data)
: renderTimelineBody(data);
container.innerHTML = headerHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
@@ -312,18 +299,6 @@ 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(() => {

View File

@@ -196,12 +196,6 @@ 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
@@ -210,11 +204,6 @@ 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";
@@ -273,20 +262,13 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
actions.className = "inbox-row-actions";
if (detail.status === "pending") {
// All four actions are stamped on every pending row; the per-viewer
// All three 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) {
@@ -303,22 +285,6 @@ 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;
@@ -355,24 +321,17 @@ function renderDiff(detail: ApprovalDetail): HTMLElement | null {
}
function approvalActionBtn(
action: ApprovalAction,
action: "approve" | "reject" | "revoke",
detail: ApprovalDetail,
): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.action = action;
// 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";
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
btn.textContent = t(("approvals.action." + action) as I18nKey);
// approve / reject / suggest_changes share the canApprove eligibility
// gate; revoke is requester-only.
// approve / reject share the eligibility gate; revoke is requester-only.
const reason = disabledReasonFor(action, detail);
if (reason) {
btn.disabled = true;
@@ -382,13 +341,13 @@ function approvalActionBtn(
}
function disabledReasonFor(
action: ApprovalAction,
action: "approve" | "reject" | "revoke",
detail: ApprovalDetail,
): I18nKey | null {
if (action === "revoke") {
return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester";
}
// approve / reject / suggest_changes — same gate as the server's canApprove.
// approve + reject — 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";

View File

@@ -219,13 +219,6 @@ 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 {
@@ -271,19 +264,14 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
}
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const showNotes = opts.showNotes === true;
const notesBlock = noteText && showNotes
const notes = noteText
? `<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 || noteHint)
const meta = (opts.showParty || ruleRef)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${ruleRef}
${noteHint}
</div>`
: "";
@@ -296,7 +284,7 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
</div>
${meta}
${adjustedNote}
${notesBlock}`;
${notes}`;
}
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
@@ -370,7 +358,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
unscheduledKeys.sort();
const keys = [...datedKeys, ...unscheduledKeys];
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {

View File

@@ -546,10 +546,6 @@ 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">

View File

@@ -583,7 +583,6 @@ 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"
@@ -596,7 +595,6 @@ 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"
@@ -607,8 +605,6 @@ 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"
@@ -635,21 +631,11 @@ 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"
@@ -1083,7 +1069,6 @@ 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"
@@ -1301,7 +1286,6 @@ export type I18nKey =
| "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"
@@ -1310,7 +1294,6 @@ 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"
@@ -1326,7 +1309,6 @@ 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"
@@ -1341,7 +1323,6 @@ 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"
@@ -1954,8 +1935,6 @@ export type I18nKey =
| "projects.detail.edit"
| "projects.detail.edit.modal.title"
| "projects.detail.edit.type_change_warning.title"
| "projects.detail.export.button"
| "projects.detail.export.tooltip"
| "projects.detail.firmwide.off"
| "projects.detail.firmwide.on"
| "projects.detail.kinder.add"
@@ -2051,21 +2030,11 @@ 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"
@@ -2330,7 +2299,6 @@ 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"

View File

@@ -80,21 +80,6 @@ export function renderProjectsDetail(): string {
<a className="entity-tab" data-tab="appointments" href="#" data-i18n="projects.detail.tab.termine">Termine</a>
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
{/* t-paliad-214 Slice 2 — project-subtree export button.
Sits at the end of the tab nav. Hidden by default; the
client unhides it after /api/me confirms the caller can
extract (responsibility ∈ {lead, member} OR global_admin). */}
<button
type="button"
id="project-export-btn"
className="entity-tab entity-tab-action"
style="display:none"
title=""
data-i18n-title="projects.detail.export.tooltip"
data-i18n="projects.detail.export.button">
Daten exportieren
</button>
</nav>
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
@@ -586,38 +571,6 @@ 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

View File

@@ -3441,49 +3441,6 @@ 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. */
@@ -5247,40 +5204,6 @@ 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;

View File

@@ -225,10 +225,6 @@ 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">

View File

@@ -1,27 +0,0 @@
-- 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'));

View File

@@ -1,57 +0,0 @@
-- 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;

View File

@@ -1,52 +0,0 @@
-- 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';

View File

@@ -1,89 +0,0 @@
-- 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)';

View File

@@ -1,31 +0,0 @@
-- 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;

View File

@@ -1,211 +0,0 @@
-- 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;

View File

@@ -1,28 +0,0 @@
-- 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'
));

View File

@@ -1,42 +0,0 @@
-- 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'
));

View File

@@ -270,8 +270,7 @@ func isValidInboxStatus(s string) bool {
services.RequestStatusApproved,
services.RequestStatusRejected,
services.RequestStatusRevoked,
services.RequestStatusSuperseded,
services.RequestStatusChangesRequested:
services.RequestStatusSuperseded:
return true
}
return false
@@ -326,67 +325,6 @@ 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

View File

@@ -82,44 +82,6 @@ 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
@@ -135,7 +97,6 @@ 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)

View File

@@ -2,19 +2,16 @@ package handlers
// Data-export handlers (t-paliad-214).
//
// Slice 1: personal scope
// Slice 1 ships the personal scope only:
//
// GET /api/me/export → streams a personal-scope export .zip
//
// Slice 2: project subtree scope
// GET /api/projects/{id}/export?direct_only=0|1 → streams a project-subtree
// export .zip
//
// Slice 3 (org, async) lands in a follow-up.
// 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. Slice 1 gates
// only on authentication; Slice 2 adds a §4 responsibility + global_admin
// check via handleProjectExportGate.
// 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"
@@ -25,8 +22,6 @@ import (
"strconv"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -107,7 +102,7 @@ func handleMeExport(w http.ResponseWriter, r *http.Request) {
return
}
filename := services.ExportFilename(services.ExportScopePersonal, "", uuid.Nil, spec.GeneratedAt)
filename := services.ExportFilename(services.ExportScopePersonal, "", spec.GeneratedAt)
size := int64(buf.Len())
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
@@ -128,163 +123,3 @@ func handleMeExport(w http.ResponseWriter, r *http.Request) {
log.Printf("export: response write failed for %s (audit=%s): %v", uid, auditID, err)
}
}
// handleProjectExport streams the project-subtree export .zip for the
// project named in the URL path.
//
// Authorization (Slice 2 §4):
//
// - caller must be authenticated (handled by the mux middleware),
// - caller must pass paliad.can_see_project(rootID) — enforced via
// ProjectService.GetByID returning ErrNotVisible → 404,
// - caller must be on paliad.project_teams for the root with
// responsibility ∈ {lead, member}, OR be a global_admin.
// Observers + Externals see but cannot extract — 403 bilingual.
//
// Query params:
// - ?direct_only=1 narrows the export to the root project only (no
// descendants). Default = subtree-inclusive.
func handleProjectExport(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
}
rootID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid project id",
})
return
}
directOnly := false
if q := r.URL.Query().Get("direct_only"); q == "1" || q == "true" {
directOnly = true
}
ctx, cancel := context.WithTimeout(r.Context(), exportRequestTimeout)
defer cancel()
// Visibility gate (a + b): GetByID returns ErrNotVisible when the
// caller can't see the project, which we map to 404. The handler
// stays oblivious to whether the project doesn't exist or simply
// isn't visible — that's by design (RLS-style opacity).
project, err := dbSvc.projects.GetByID(ctx, uid, rootID)
if err != nil {
writeServiceError(w, err)
return
}
// Authority gate (c): direct-team responsibility ∈ {lead, member} OR
// global_admin. Derived-only-via-partner-unit users (DerivedPeer)
// don't qualify for extraction — m's Q1 lock-in.
allowed, err := callerCanExportProject(ctx, uid, rootID)
if err != nil {
log.Printf("export: authority check failed for user=%s project=%s: %v", uid, rootID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "authority check failed",
})
return
}
if !allowed {
// Bilingual 403 per Q7. Pattern matches mapApprovalError style.
writeJSON(w, http.StatusForbidden, map[string]string{
"code": "export_not_authorized",
"message": "Datenexport ist nur Team-Mitgliedern (Lead / Member) vorbehalten. / Data export is restricted to project team members (lead / member).",
})
return
}
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.ExportScopeProject,
ScopeRoot: &rootID,
ScopeRootLabel: project.Title,
ScopeRootPath: project.Path,
DirectOnly: directOnly,
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/project=%s: %v", uid, rootID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "audit write failed",
})
return
}
var buf bytes.Buffer
meta, err := dbSvc.export.WriteProject(ctx, &buf, spec)
if err != nil {
dbSvc.export.PatchAuditRowFailure(context.Background(), auditID, err.Error())
log.Printf("export: WriteProject failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "export generation failed",
})
return
}
filename := services.ExportFilename(services.ExportScopeProject, project.Title, rootID, spec.GeneratedAt)
size := int64(buf.Len())
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
log.Printf("export: audit patch failed for %s/project=%s (audit=%s): %v", uid, rootID, 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 {
log.Printf("export: response write failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
}
}
// callerCanExportProject is the §4 authority check:
//
// - global_admin can extract anything anywhere.
// - else: caller must be on paliad.project_teams for the root with
// responsibility ∈ {lead, member}.
//
// One query, parameterised; returns the boolean. Errors surface to the
// handler as 500.
func callerCanExportProject(ctx context.Context, userID, projectID uuid.UUID) (bool, error) {
const q = `
SELECT
EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = $1 AND u.global_role = 'global_admin'
) OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = $2
AND pt.responsibility IN ('lead', 'member')
)
`
var ok bool
if err := dbSvc.projects.DB().QueryRowContext(ctx, q, userID, projectID).Scan(&ok); err != nil {
return false, err
}
return ok, nil
}

View File

@@ -3,7 +3,6 @@ package handlers
import (
"encoding/json"
"net/http"
"strings"
"mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/services"
@@ -22,19 +21,6 @@ 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
@@ -87,15 +73,6 @@ type Services struct {
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
// (remote → mRiver via SSH) or local tmux availability. Stays nil
@@ -112,14 +89,6 @@ 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,
@@ -207,11 +176,8 @@ 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. 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"))))))
// is never stale after a release.
mux.Handle("GET /patentstyle/", noCacheAssets(http.StripPrefix("/patentstyle/", http.FileServer(http.Dir("dist/patentstyle")))))
// Protected routes
protected := http.NewServeMux()
@@ -284,18 +250,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/projects/{id}/timeline", handleGetProjectTimeline)
// t-paliad-177 Slice 2 — iCal feed (deadlines + appointments only).
protected.HandleFunc("GET /api/projects/{id}/timeline.ics", handleGetProjectTimelineICS)
// t-paliad-214 Slice 2 — project-subtree data export. ?direct_only=1
// narrows to the root project only; default = root + descendants.
// Permission gate: responsibility ∈ {lead, member} OR global_admin.
protected.HandleFunc("GET /api/projects/{id}/export", handleProjectExport)
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)
@@ -569,7 +526,6 @@ 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 +

View File

@@ -170,18 +170,6 @@ 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
}

View File

@@ -1,387 +0,0 @@
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
}

View File

@@ -805,15 +805,6 @@ 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"`
// 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"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -1,8 +1,7 @@
// Package offices is the single source of truth for the firm's office list.
//
// 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.
// The keys here must stay in sync with the CHECK constraint on
// paliad.users.office and paliad.akten.owning_office (migration 001).
package offices
// Office is a single firm office with its i18n-ready labels.
@@ -21,7 +20,6 @@ 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.

View File

@@ -3,7 +3,7 @@ package offices
import "testing"
func TestIsValid(t *testing.T) {
for _, key := range []string{"munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"} {
for _, key := range []string{"munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"} {
if !IsValid(key) {
t.Errorf("IsValid(%q) = false, want true", key)
}

View File

@@ -61,12 +61,11 @@ const (
// RequestStatus values on paliad.approval_requests.status.
const (
RequestStatusPending = "pending"
RequestStatusApproved = "approved"
RequestStatusRejected = "rejected"
RequestStatusRevoked = "revoked"
RequestStatusSuperseded = "superseded"
RequestStatusChangesRequested = "changes_requested"
RequestStatusPending = "pending"
RequestStatusApproved = "approved"
RequestStatusRejected = "rejected"
RequestStatusRevoked = "revoked"
RequestStatusSuperseded = "superseded"
)
// DecisionKind discriminates 'peer' (normal in-team sign-off) from
@@ -159,14 +158,12 @@ 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")
ErrSuggestionRequiresChange = errors.New("suggestion requires a counter_payload diff or a note")
ErrSuggestionLifecycleInvalid = errors.New("suggest-changes is only valid for update / complete lifecycles")
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")
)
// PendingApprovalError wraps ErrConcurrentPending with the in-flight

View File

@@ -35,7 +35,6 @@ package services
// pool, so the deadlock path can't be silently bypassed.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@@ -364,267 +363,6 @@ 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:
@@ -954,8 +692,6 @@ 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
@@ -1080,20 +816,14 @@ 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"`
// 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"`
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"`
}
// approvalEligibilitySQL is the SELECT-and-WHERE-compatible boolean
@@ -1145,7 +875,6 @@ 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
@@ -1156,11 +885,7 @@ 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,
(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`
(ar.requested_by = $1) AS viewer_is_requester`
const approvalRequestViewJoins = `
paliad.approval_requests ar

View File

@@ -946,393 +946,3 @@ 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)
}
}

View File

@@ -1,266 +0,0 @@
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))
}

View File

@@ -1,215 +0,0 @@
package services
// Tests for the Slice 2 (project-subtree) sheet registry. Pure-function
// shape tests — live-DB integration coverage of the SQL itself stays in
// the existing query patterns the personal-scope tests already cover.
import (
"strings"
"testing"
"time"
"github.com/google/uuid"
)
// TestProjectSheetQueries_RegistryShape pins the sheet inventory + the
// design's §2 contract: every entity sheet binds rootID as $1, and the
// approval_policies sheet ships with all three sources (project +
// ancestor + partner_unit_default).
func TestProjectSheetQueries_RegistryShape(t *testing.T) {
rootID := uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb")
qs := projectSheetQueries(rootID, false)
wantSheets := []string{
"projects",
"project_teams",
"project_partner_units",
"deadlines",
"appointments",
"parties",
"notes",
"documents",
"project_events",
"approval_requests",
"approval_policies",
"checklist_instances",
"partner_units",
"partner_unit_members",
"users_referenced",
"system_audit_log_subset",
"ref__proceeding_types",
"ref__event_types",
"ref__event_categories",
"ref__deadline_rules",
"ref__deadline_concepts",
"ref__courts",
"ref__countries",
"ref__holidays",
}
gotSheets := []string{}
for _, q := range qs {
gotSheets = append(gotSheets, q.SheetName)
}
if len(gotSheets) != len(wantSheets) {
t.Fatalf("sheet count = %d, want %d (got %v)", len(gotSheets), len(wantSheets), gotSheets)
}
for i, want := range wantSheets {
if gotSheets[i] != want {
t.Errorf("sheet[%d] = %q, want %q", i, gotSheets[i], want)
}
}
// Every NON-reference sheet binds rootID as $1.
for _, q := range qs {
if strings.HasPrefix(q.SheetName, "ref__") {
if len(q.Args) != 0 {
t.Errorf("ref sheet %q has %d args, want 0", q.SheetName, len(q.Args))
}
continue
}
if len(q.Args) != 1 {
t.Errorf("entity sheet %q has %d args, want 1", q.SheetName, len(q.Args))
continue
}
if got, ok := q.Args[0].(uuid.UUID); !ok || got != rootID {
t.Errorf("entity sheet %q first arg = %v, want rootID %v", q.SheetName, q.Args[0], rootID)
}
}
}
// TestProjectSheetQueries_ApprovalPoliciesTripleSource verifies that the
// approval_policies sheet's SQL carries all three source tags so an
// importer can reconstruct the effective gate (Q4 lock-in).
func TestProjectSheetQueries_ApprovalPoliciesTripleSource(t *testing.T) {
qs := projectSheetQueries(uuid.New(), false)
var found *sheetQuery
for i := range qs {
if qs[i].SheetName == "approval_policies" {
found = &qs[i]
break
}
}
if found == nil {
t.Fatal("approval_policies sheet missing from registry")
}
for _, src := range []string{
`'project'::text AS source`,
`'ancestor'::text AS source`,
`'partner_unit_default'::text AS source`,
} {
if !strings.Contains(found.SQL, src) {
t.Errorf("approval_policies SQL missing %q tag — Q4 triple-source attribution broken.\nSQL:\n%s",
src, found.SQL)
}
}
}
// TestProjectSheetQueries_DirectOnlyNarrowsSubtree pins that direct_only=true
// produces a subtree subquery resolving to exactly the root (no LIKE-walk).
func TestProjectSheetQueries_DirectOnlyNarrowsSubtree(t *testing.T) {
subtreeAll := projectSubtreeProjectIDsSQL(false)
subtreeRoot := projectSubtreeProjectIDsSQL(true)
if !strings.Contains(subtreeAll, `LIKE r.path`) {
t.Errorf("default subtree SQL missing path-LIKE descendant walk:\n%s", subtreeAll)
}
if strings.Contains(subtreeRoot, `LIKE`) {
t.Errorf("direct_only subtree SQL still has LIKE walk — should be root-only:\n%s", subtreeRoot)
}
if !strings.Contains(subtreeRoot, `$1::uuid`) {
t.Errorf("direct_only subtree SQL missing $1::uuid root reference:\n%s", subtreeRoot)
}
}
// TestProjectSheetQueries_NoPersonalSidecars guards against an accidental
// inclusion of personal sidecars (caldav config, views, pins, paliadin
// turns) in the project-scope export. These are per-user, not per-project,
// and don't belong in a matter handover.
func TestProjectSheetQueries_NoPersonalSidecars(t *testing.T) {
qs := projectSheetQueries(uuid.New(), false)
for _, q := range qs {
switch q.SheetName {
case "my_caldav_config", "my_views", "my_pinned_projects", "my_card_layouts", "my_paliadin_turns", "me":
t.Errorf("project-scope export must not include personal sidecar sheet %q", q.SheetName)
}
// Also defence-in-depth on the SQL: no SELECT from
// user_caldav_config or paliadin_turns from project scope.
if strings.Contains(q.SQL, "user_caldav_config") {
t.Errorf("sheet %q SQL touches user_caldav_config — never in project scope", q.SheetName)
}
if strings.Contains(q.SQL, "paliadin_turns") {
t.Errorf("sheet %q SQL touches paliadin_turns — never in project scope", q.SheetName)
}
}
}
// TestProjectSheetQueries_AttachedPartnerUnitsOnly pins that the
// partner_units sheet is filtered to attached units only (not the full
// org chart).
func TestProjectSheetQueries_AttachedPartnerUnitsOnly(t *testing.T) {
qs := projectSheetQueries(uuid.New(), false)
for _, q := range qs {
if q.SheetName != "partner_units" {
continue
}
if !strings.Contains(q.SQL, "project_partner_units") {
t.Errorf("partner_units sheet SQL must filter via project_partner_units (got attached-only requirement):\n%s",
q.SQL)
}
return
}
t.Fatal("partner_units sheet missing from registry")
}
// TestShortUUIDSuffix_ReturnsLast8Hex pins the §3 filename disambiguator
// shape — Q5 lock-in.
func TestShortUUIDSuffix_ReturnsLast8Hex(t *testing.T) {
cases := []struct {
in uuid.UUID
want string
}{
{uuid.Nil, ""},
{uuid.MustParse("11111111-1111-1111-1111-aaaaaaaaaaaa"), "aaaaaaaaaaaa"},
{uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb"), "a89469e2cacb"},
}
for _, c := range cases {
got := shortUUIDSuffix(c.in)
if got != c.want {
t.Errorf("shortUUIDSuffix(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// TestMetaToKeyValueRows_ProjectScopeRows verifies that project-scope
// meta picks up scope_root_label + scope_root_path + direct_only rows
// (so the __meta sheet carries Q6 lock-in details).
func TestMetaToKeyValueRows_ProjectScopeRows(t *testing.T) {
rootID := uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb")
m := ExportMeta{
SchemaVersion: 1,
FirmName: "HLC",
Scope: ExportScopeProject,
ScopeRootID: &rootID,
ScopeRootLabel: "Siemens AG",
ScopeRootPath: "61e3fb9e_29fb_44aa_867e_a89469e2cacb",
DirectOnly: false,
GeneratedAt: time.Date(2026, 5, 20, 14, 23, 0, 0, time.UTC),
RowCounts: map[string]int{},
}
rows := metaToKeyValueRows(m)
want := map[string]string{
"scope_root_label": "Siemens AG",
"scope_root_path": "61e3fb9e_29fb_44aa_867e_a89469e2cacb",
"direct_only": "FALSE",
}
seen := map[string]string{}
for _, r := range rows {
seen[r[0]] = r[1]
}
for k, v := range want {
if seen[k] != v {
t.Errorf("meta key %q = %q, want %q (full rows: %v)", k, seen[k], v, rows)
}
}
}

View File

@@ -93,16 +93,6 @@ type ExportMeta struct {
FirmName string `json:"firm_name"`
Scope string `json:"scope"`
ScopeRootID *uuid.UUID `json:"scope_root_id,omitempty"`
// ScopeRootLabel is the project title (project scope only). Empty
// for personal + org scope.
ScopeRootLabel string `json:"scope_root_label,omitempty"`
// ScopeRootPath is the ltree path of the root project (project scope
// only). Preserved in the audit row so closed-out projects retain a
// usable ancestry pointer (Q6 lock-in).
ScopeRootPath string `json:"scope_root_path,omitempty"`
// DirectOnly is true when ?direct_only=1 was passed (project scope
// only) — narrows the export to the root project, no descendants.
DirectOnly bool `json:"direct_only,omitempty"`
GeneratedAt time.Time `json:"generated_at"`
GeneratedByID uuid.UUID `json:"generated_by_user_id"`
GeneratedByEml string `json:"generated_by_user_email"`
@@ -117,14 +107,6 @@ type ExportMeta struct {
type ExportSpec struct {
Scope string
ScopeRoot *uuid.UUID // project_id when Scope==ExportScopeProject; nil otherwise
// ScopeRootLabel + ScopeRootPath are populated by the project-export
// handler (resolved from the root project row) so the audit + __meta
// carry stable labels even if the project is later renamed.
ScopeRootLabel string
ScopeRootPath string
// DirectOnly narrows the export to the root project only (project
// scope, ?direct_only=1).
DirectOnly bool
ActorID uuid.UUID
ActorEmail string
ActorLabel string // display_name for the audit + meta
@@ -191,102 +173,6 @@ func (s *ExportService) WritePersonal(ctx context.Context, w io.Writer, spec Exp
return meta, nil
}
// WriteProject streams the project-subtree bundle for the project named
// in spec.ScopeRoot into w. Returns the meta (incl. row_counts) for the
// audit-row patch.
//
// Behavior contract (per Slice 2 design §2):
//
// - Every entity sheet is filtered to the subtree (project + descendants
// via ltree path). When spec.DirectOnly is true, narrows to the root
// project only (no descendants).
// - approval_policies carries all 3 sources (project rows + ancestor
// rows + partner-unit-default rows) tagged with a `source` column —
// m's Q4 lock-in lets recipients reconstruct the effective gate.
// - users_referenced restricts the user disclosure to FK-referenced
// users only (avoids dumping the full firm roster into a per-matter
// handover).
// - Cross-subtree FKs (projects.counterclaim_of pointing outside the
// subtree) are kept but warned about in __meta.warnings — m's Q3
// lock-in preserves the no-lock-in promise.
//
// Permission gate (§4) lives on the handler, NOT here — the service
// trusts the caller has already authorised. Wiring is in handlers/export.go.
func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec ExportSpec) (ExportMeta, error) {
if spec.Scope == "" {
spec.Scope = ExportScopeProject
}
if spec.GeneratedAt.IsZero() {
spec.GeneratedAt = time.Now().UTC()
}
if spec.ScopeRoot == nil {
return ExportMeta{}, fmt.Errorf("WriteProject: ScopeRoot is required")
}
meta := ExportMeta{
SchemaVersion: ExportSchemaVersion,
FirmName: s.firmName,
Scope: spec.Scope,
ScopeRootID: spec.ScopeRoot,
ScopeRootLabel: spec.ScopeRootLabel,
ScopeRootPath: spec.ScopeRootPath,
DirectOnly: spec.DirectOnly,
GeneratedAt: spec.GeneratedAt,
GeneratedByID: spec.ActorID,
GeneratedByEml: spec.ActorEmail,
GeneratedByLbl: spec.ActorLabel,
RowCounts: map[string]int{},
}
sheets := projectSheetQueries(*spec.ScopeRoot, spec.DirectOnly)
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
return meta, err
}
// Cross-subtree FK detection (Q3 lock-in: keep FK + warn). After the
// bundle is built we run one lightweight scan to surface
// counterclaim_of references that escape the subtree. The result
// gets appended to meta.Warnings so it lands in __meta + the audit
// row + the README's warning list.
if warns, err := s.detectCrossSubtreeFKs(ctx, *spec.ScopeRoot, spec.DirectOnly); err == nil && len(warns) > 0 {
meta.Warnings = append(meta.Warnings, warns...)
sort.Strings(meta.Warnings)
}
return meta, nil
}
// detectCrossSubtreeFKs scans subtree-resident projects for FKs that
// point outside the subtree (today: only projects.counterclaim_of). One
// warning row per outbound reference. Best-effort: a query error here
// degrades silently (the export still ships) since the warning is
// informational, not load-bearing.
func (s *ExportService) detectCrossSubtreeFKs(ctx context.Context, rootID uuid.UUID, directOnly bool) ([]string, error) {
subtreeSQL := projectSubtreeProjectIDsSQL(directOnly)
q := `
SELECT p.id, p.title, p.counterclaim_of
FROM paliad.projects p
WHERE p.id IN ` + subtreeSQL + `
AND p.counterclaim_of IS NOT NULL
AND p.counterclaim_of NOT IN ` + subtreeSQL + `
ORDER BY p.id`
type row struct {
ID uuid.UUID `db:"id"`
Title string `db:"title"`
CounterclaimOf uuid.UUID `db:"counterclaim_of"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, q, rootID); err != nil {
return nil, err
}
out := make([]string, 0, len(rows))
for _, r := range rows {
out = append(out, fmt.Sprintf(
"cross-subtree FK: project %q (%s).counterclaim_of → %s (not in this export)",
r.Title, r.ID, r.CounterclaimOf,
))
}
return out, nil
}
// collectedSheet holds one sheet's data after column-discovery + row
// materialisation. Used to hand data from writeBundle to buildXLSX +
// buildJSON + buildCSV.
@@ -388,24 +274,15 @@ func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []s
sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name })
zw := zip.NewWriter(w)
// Stamp every zip entry's Modified with the export's GeneratedAt so
// the extracted files carry a meaningful timestamp in Windows
// Explorer / Finder (instead of "01.01.2000" or the build time).
// This is still deterministic-within-an-export: two calls with the
// same ExportMeta produce identical bytes (m's Q6 contract is
// "same row state at same generation time → identical bytes",
// modulo __meta.generated_at — and now the file mtimes too).
mod := meta.GeneratedAt.UTC()
if mod.IsZero() {
// Defensive: a zero time would cause archive/zip to write 1980-01-01
// (the DOS epoch) which would re-surface the original bug.
mod = time.Now().UTC()
}
// Force a fixed Modified time on every entry so the zip header bytes
// don't drift between runs. archive/zip otherwise stamps Modified
// with time.Now() which would defeat the deterministic guarantee.
fixedMod := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
for _, e := range entries {
hdr := &zip.FileHeader{
Name: e.name,
Method: zip.Deflate,
Modified: mod,
Modified: fixedMod,
}
fw, err := zw.CreateHeader(hdr)
if err != nil {
@@ -537,43 +414,10 @@ func formatCellValue(v any) string {
// buildXLSX assembles the workbook from the collected sheets + meta. Uses
// excelize's row-by-row writer; at personal/project scale the dataset
// fits comfortably in memory. Returns the xlsx-file bytes.
//
// Two non-obvious things this function gets right (because past versions
// got them wrong and Excel complained):
//
// 1. excelize's default core.xml carries Created=Modified="2006-09-16T00:00:00Z"
// (xuri's first commit date) until SetDocProps is called. We overwrite
// both with meta.GeneratedAt so Excel's File→Info shows the real time
// and Windows Explorer shows a sensible Modified column.
//
// 2. A frozen header row needs a complete <pane> definition or Excel
// pops the "Repairs required" prompt on open. excelize's Panes struct
// requires Freeze + YSplit + TopLeftCell + ActivePane; passing just
// Freeze + YSplit (the obvious-but-wrong form) emits invalid XML that
// excelize itself accepts on re-read but Excel rejects.
func buildXLSX(sheets []collectedSheet, meta ExportMeta) ([]byte, error) {
f := excelize.NewFile()
defer f.Close()
// Replace the hardcoded "Author: xuri / Created: 2006-09-16" defaults
// with real per-export metadata. Modified == Created on first write
// (no editing has happened by the time the user downloads).
tsISO := meta.GeneratedAt.UTC().Format(time.RFC3339)
creator := "Paliad"
if meta.FirmName != "" {
creator = "Paliad (" + meta.FirmName + ")"
}
if err := f.SetDocProps(&excelize.DocProperties{
Created: tsISO,
Modified: tsISO,
Creator: creator,
LastModifiedBy: creator,
Title: fmt.Sprintf("Paliad export (%s)", meta.Scope),
Description: fmt.Sprintf("Paliad data export, scope=%s, generated_by=%s", meta.Scope, meta.GeneratedByEml),
}); err != nil {
return nil, fmt.Errorf("excelize SetDocProps: %w", err)
}
// excelize creates a default "Sheet1" we want to rename to __meta.
const metaName = "__meta"
first := f.GetSheetName(0)
@@ -609,6 +453,10 @@ func buildXLSX(sheets []collectedSheet, meta ExportMeta) ([]byte, error) {
if _, err := f.NewSheet(sheetName); err != nil {
return nil, err
}
// Stream rows via the row-by-row API (NewStreamWriter is faster
// but it forbids re-opening sheets and silently truncates writes
// past the streamer's offset — at our scale the simple API is
// safer and the perf cost is negligible).
// Header row
for ci, col := range sh.columns {
cell, _ := excelize.CoordinatesToCellName(ci+1, 1)
@@ -624,34 +472,21 @@ func buildXLSX(sheets []collectedSheet, meta ExportMeta) ([]byte, error) {
}
}
}
// Freeze the header row. The complete <pane> shape Excel insists
// on for a Y-only freeze: TopLeftCell="A2" (cell below the frozen
// row), ActivePane="bottomLeft", Selection on bottomLeft. The
// obvious-but-incomplete form {Freeze: true, YSplit: 1} produces
// invalid pane XML that triggers Excel's repair prompt on open.
if err := f.SetPanes(sheetName, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
Selection: []excelize.Selection{
{SQRef: "A2", ActiveCell: "A2", Pane: "bottomLeft"},
},
}); err != nil {
return nil, fmt.Errorf("excelize SetPanes(%q): %w", sheetName, err)
}
// Freeze the header row.
_ = f.SetPanes(sheetName, &excelize.Panes{
Freeze: true,
YSplit: 1,
})
}
// Set the active sheet to the __meta sheet (index 0). Without this,
// excelize's default active-sheet index can point at a sheet that no
// longer exists at that ordinal — also a "repair required" trigger.
f.SetActiveSheet(0)
// Write to buffer.
var buf strings.Builder
// excelize writes to an io.Writer via WriteTo
bw := &byteBuf{}
if _, err := f.WriteTo(bw); err != nil {
return nil, err
}
_ = buf // silence unused (kept for clarity that we considered a strings.Builder)
return bw.Bytes(), nil
}
@@ -679,20 +514,6 @@ func metaToKeyValueRows(m ExportMeta) [][2]string {
} else {
rows = append(rows, [2]string{"scope_root_id", ""})
}
// Project-scope-only rows (Slice 2 §2.4). Surface as empty rows for
// other scopes so the __meta layout stays stable + Excel users can
// see "this field exists but doesn't apply here".
rows = append(rows,
[2]string{"scope_root_label", m.ScopeRootLabel},
[2]string{"scope_root_path", m.ScopeRootPath},
)
if m.Scope == ExportScopeProject {
if m.DirectOnly {
rows = append(rows, [2]string{"direct_only", "TRUE"})
} else {
rows = append(rows, [2]string{"direct_only", "FALSE"})
}
}
rows = append(rows,
[2]string{"generated_at", m.GeneratedAt.UTC().Format(time.RFC3339)},
[2]string{"generated_by_user_id", m.GeneratedByID.String()},
@@ -765,19 +586,6 @@ func buildREADME(m ExportMeta) string {
fmt.Fprintf(&b, "Erstellt am : %s\n", m.GeneratedAt.UTC().Format(time.RFC3339))
fmt.Fprintf(&b, "Erstellt von : %s <%s>\n", m.GeneratedByLbl, m.GeneratedByEml)
fmt.Fprintf(&b, "Umfang : %s\n", m.Scope)
if m.Scope == ExportScopeProject {
if m.ScopeRootLabel != "" {
fmt.Fprintf(&b, "Projekt : %s\n", m.ScopeRootLabel)
}
if m.ScopeRootID != nil {
fmt.Fprintf(&b, "Projekt-ID : %s\n", m.ScopeRootID.String())
}
if m.DirectOnly {
fmt.Fprintf(&b, "Hinweis : nur das Root-Projekt (?direct_only=1), keine Unter-Projekte.\n")
} else {
fmt.Fprintf(&b, "Hinweis : Root-Projekt + alle Unter-Projekte.\n")
}
}
fmt.Fprintf(&b, "Schema-Version: %d\n", m.SchemaVersion)
fmt.Fprintf(&b, "\n")
fmt.Fprintf(&b, "Inhalt\n------\n")
@@ -822,16 +630,7 @@ func buildREADME(m ExportMeta) string {
// ExportFilename returns the canonical filename for a download. Slugify is
// minimal — only the project-scope variant has a free-text component to
// sanitise.
//
// Project-scope filenames include an 8-hex-char disambiguator derived from
// the root project's UUID (Slice 2 §3 Q5). Two projects with identical
// titles (common: "Standard NDA" per client) would otherwise produce
// filename collisions when archived together; 4-billion-class disambiguation
// is cheap insurance.
//
// rootID is consumed only for ExportScopeProject; pass uuid.Nil for the
// other scopes.
func ExportFilename(scope string, scopeLabel string, rootID uuid.UUID, generatedAt time.Time) string {
func ExportFilename(scope string, scopeLabel string, generatedAt time.Time) string {
ts := generatedAt.UTC().Format("2006-01-02T1504Z")
switch scope {
case ExportScopePersonal:
@@ -843,30 +642,12 @@ func ExportFilename(scope string, scopeLabel string, rootID uuid.UUID, generated
if slug == "" {
slug = randomSlug()
}
short := shortUUIDSuffix(rootID)
if short == "" {
return fmt.Sprintf("paliad-export-project-%s-%s.zip", slug, ts)
}
return fmt.Sprintf("paliad-export-project-%s-%s-%s.zip", slug, short, ts)
return fmt.Sprintf("paliad-export-project-%s-%s.zip", slug, ts)
default:
return fmt.Sprintf("paliad-export-%s.zip", ts)
}
}
// shortUUIDSuffix returns the last 8 hex chars of the UUID's canonical
// representation (the trailing block after the final dash). Empty string
// for uuid.Nil so callers can fall back to the slug-only variant.
func shortUUIDSuffix(id uuid.UUID) string {
if id == uuid.Nil {
return ""
}
s := id.String()
if i := strings.LastIndex(s, "-"); i != -1 && i+1 < len(s) {
return s[i+1:]
}
return ""
}
var filenameSafeRegex = regexp.MustCompile(`[^A-Za-z0-9-]+`)
func slugifyFilename(s string) string {
@@ -1111,25 +892,10 @@ func personalSheetQueries(actorID uuid.UUID) []sheetQuery {
// WriteAuditRow inserts a system_audit_log row before the export runs and
// returns the new row id. The handler PATCHes the row with file_size_bytes
// + final row_counts on success or marks it failed on error.
//
// For project-scope exports the metadata jsonb carries the ltree path
// (Q6 lock-in) so the audit row remains interpretable after a project
// deletion: scope_root → just the UUID; metadata.root_path → the
// ancestry. Same goes for root_label + direct_only so dashboards don't
// need to round-trip back to paliad.projects on render.
func (s *ExportService) WriteAuditRow(ctx context.Context, spec ExportSpec) (uuid.UUID, error) {
meta := map[string]any{
"requested_at": spec.GeneratedAt.UTC().Format(time.RFC3339),
}
if spec.Scope == ExportScopeProject {
if spec.ScopeRootLabel != "" {
meta["root_label"] = spec.ScopeRootLabel
}
if spec.ScopeRootPath != "" {
meta["root_path"] = spec.ScopeRootPath
}
meta["direct_only"] = spec.DirectOnly
}
mb, _ := json.Marshal(meta)
var id uuid.UUID
err := s.db.QueryRowContext(ctx,
@@ -1188,285 +954,3 @@ func (s *ExportService) PatchAuditRowFailure(ctx context.Context, id uuid.UUID,
id, string(mb),
)
}
// ---------------------------------------------------------------------------
// Project-scope sheet registry (Slice 2).
// ---------------------------------------------------------------------------
//
// Subtree-aware queries via paliad.projects.path (ltree as text). The
// subtree predicate works on the materialised path column:
//
// p.path LIKE root.path || '%' -- descendants + self
// p.path = root.path -- self only (direct_only=true)
//
// We use the path-prefix-LIKE form instead of ltree `<@` because the
// schema stores path as text (the underlying ltree is materialised in
// the projects.path column). The LIKE pattern is anchored at the start
// and uses indexes built on path.
//
// Ordering: every SELECT uses ORDER BY id (or another stable tuple) so
// byte-determinism holds across runs.
// projectSubtreeProjectIDsSQL returns a SQL subquery expression that
// resolves to "the set of project ids in the subtree of $1". Use as the
// right-hand side of `IN`. The $1 placeholder must bind the root
// project's UUID.
//
// When directOnly is true, narrows to the root project itself only.
func projectSubtreeProjectIDsSQL(directOnly bool) string {
if directOnly {
// Tighter: just the root, no descendants. Still framed as a
// subquery so the outer SQL can be uniformly composed.
return `(SELECT $1::uuid AS id)`
}
// Subtree = root + descendants. The materialised path column on
// every project includes its own UUID as the trailing label, so the
// LIKE pattern matches both the root and every descendant in one
// expression. r.path is read from the root row keyed by $1.
return `(
SELECT p.id
FROM paliad.projects p
JOIN paliad.projects r ON r.id = $1::uuid
WHERE p.path = r.path
OR p.path LIKE r.path || '.%'
)`
}
// projectSheetQueries returns the sheet registry for a project-scope
// export. rootID is bound to $1 in every query; directOnly narrows the
// subtree to just the root project.
//
// Sheet inclusion follows design §2.2. Same shape as personalSheetQueries
// but with subtree filtering instead of RLS-visibility and a tighter
// users-disclosure profile.
func projectSheetQueries(rootID uuid.UUID, directOnly bool) []sheetQuery {
subtree := projectSubtreeProjectIDsSQL(directOnly)
queries := []sheetQuery{
// --- entity sheets (subtree-scoped) ---
{
SheetName: "projects",
SQL: `SELECT * FROM paliad.projects
WHERE id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "project_teams",
SQL: `SELECT * FROM paliad.project_teams
WHERE project_id IN ` + subtree + `
ORDER BY project_id, user_id`,
Args: []any{rootID},
},
{
SheetName: "project_partner_units",
SQL: `SELECT * FROM paliad.project_partner_units
WHERE project_id IN ` + subtree + `
ORDER BY project_id, partner_unit_id`,
Args: []any{rootID},
},
{
SheetName: "deadlines",
SQL: `SELECT * FROM paliad.deadlines
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "appointments",
SQL: `SELECT * FROM paliad.appointments
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "parties",
SQL: `SELECT * FROM paliad.parties
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "notes",
SQL: `SELECT * FROM paliad.notes
WHERE COALESCE(project_id,
(SELECT d.project_id FROM paliad.deadlines d WHERE d.id = notes.deadline_id),
(SELECT a.project_id FROM paliad.appointments a WHERE a.id = notes.appointment_id),
(SELECT pe.project_id FROM paliad.project_events pe WHERE pe.id = notes.project_event_id)
) IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "documents",
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
FROM paliad.documents
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "project_events",
SQL: `SELECT * FROM paliad.project_events
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "approval_requests",
SQL: `SELECT * FROM paliad.approval_requests
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
// Approval policies — m's Q4 lock: ship all three sources with
// `source` attribution column so an importer can reconstruct
// "what gate applies" without re-running paliad's resolver.
//
// Source 1: project rows for any project in the subtree.
// Source 2: project rows for ancestors of the root (so a
// descendant export still sees the gate inherited
// from above the subtree).
// Source 3: partner-unit-default rows for units attached to
// any subtree project.
//
// One UNION query, with a `source` column tagged per branch.
// We hand-pick the columns to keep the shape stable across the
// three sources (approval_policies.project_id is nullable when
// the row is a partner-unit-default, etc.).
{
SheetName: "approval_policies",
SQL: `
SELECT 'project'::text AS source,
id, project_id, partner_unit_id, entity_type, lifecycle_event,
required_role, requires_approval, min_role,
created_by, created_at, updated_at
FROM paliad.approval_policies
WHERE project_id IN ` + subtree + `
UNION ALL
SELECT 'ancestor'::text AS source,
ap.id, ap.project_id, ap.partner_unit_id, ap.entity_type, ap.lifecycle_event,
ap.required_role, ap.requires_approval, ap.min_role,
ap.created_by, ap.created_at, ap.updated_at
FROM paliad.approval_policies ap
JOIN paliad.projects r ON r.id = $1::uuid
WHERE ap.project_id IS NOT NULL
AND ap.project_id <> $1::uuid
AND ap.project_id IN (
SELECT pa.id
FROM paliad.projects pa
WHERE r.path LIKE pa.path || '.%'
)
UNION ALL
SELECT 'partner_unit_default'::text AS source,
ap.id, ap.project_id, ap.partner_unit_id, ap.entity_type, ap.lifecycle_event,
ap.required_role, ap.requires_approval, ap.min_role,
ap.created_by, ap.created_at, ap.updated_at
FROM paliad.approval_policies ap
WHERE ap.partner_unit_id IS NOT NULL
AND ap.partner_unit_id IN (
SELECT ppu.partner_unit_id
FROM paliad.project_partner_units ppu
WHERE ppu.project_id IN ` + subtree + `
)
ORDER BY source, id`,
Args: []any{rootID},
},
{
SheetName: "checklist_instances",
SQL: `SELECT * FROM paliad.checklist_instances
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
// --- attached partner-unit subset ---
// Only units attached to any subtree project (avoids dumping
// the full org chart into a per-matter handover).
{
SheetName: "partner_units",
SQL: `SELECT * FROM paliad.partner_units pu
WHERE pu.id IN (
SELECT ppu.partner_unit_id
FROM paliad.project_partner_units ppu
WHERE ppu.project_id IN ` + subtree + `
)
ORDER BY pu.id`,
Args: []any{rootID},
},
{
SheetName: "partner_unit_members",
SQL: `SELECT * FROM paliad.partner_unit_members pum
WHERE pum.partner_unit_id IN (
SELECT ppu.partner_unit_id
FROM paliad.project_partner_units ppu
WHERE ppu.project_id IN ` + subtree + `
)
ORDER BY partner_unit_id, user_id`,
Args: []any{rootID},
},
// --- restricted users sheet ---
// Limit user disclosure to those referenced by some FK in the
// export. Keeps a per-matter handover from leaking the full
// firm roster (47 users → typically 3-5 per matter).
{
SheetName: "users_referenced",
SQL: `SELECT id, email, display_name, office, profession
FROM paliad.users u
WHERE u.id IN (
SELECT created_by FROM paliad.projects WHERE id IN ` + subtree + `
UNION SELECT created_by FROM paliad.deadlines WHERE project_id IN ` + subtree + `
UNION SELECT created_by FROM paliad.appointments WHERE project_id IN ` + subtree + `
UNION SELECT created_by FROM paliad.project_events WHERE project_id IN ` + subtree + `
UNION SELECT user_id FROM paliad.project_teams WHERE project_id IN ` + subtree + `
UNION SELECT requested_by FROM paliad.approval_requests WHERE project_id IN ` + subtree + `
UNION SELECT decided_by FROM paliad.approval_requests WHERE project_id IN ` + subtree + ` AND decided_by IS NOT NULL
UNION SELECT created_by FROM paliad.notes WHERE COALESCE(project_id,
(SELECT d.project_id FROM paliad.deadlines d WHERE d.id = notes.deadline_id),
(SELECT a.project_id FROM paliad.appointments a WHERE a.id = notes.appointment_id),
(SELECT pe.project_id FROM paliad.project_events pe WHERE pe.id = notes.project_event_id)
) IN ` + subtree + `
UNION SELECT uploaded_by FROM paliad.documents WHERE project_id IN ` + subtree + ` AND uploaded_by IS NOT NULL
UNION SELECT user_id FROM paliad.partner_unit_members pum
WHERE pum.partner_unit_id IN (
SELECT ppu.partner_unit_id
FROM paliad.project_partner_units ppu
WHERE ppu.project_id IN ` + subtree + `
)
)
ORDER BY id`,
Args: []any{rootID},
},
// --- system_audit_log subset (the export's own audit trail) ---
// Includes prior export events scoped to this subtree's
// projects — lets a recipient see "who has previously
// exported this matter".
{
SheetName: "system_audit_log_subset",
SQL: `SELECT * FROM paliad.system_audit_log
WHERE scope_root IN ` + subtree + `
ORDER BY created_at, id`,
Args: []any{rootID},
},
// --- reference data (same set as personal scope) ---
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
}
return queries
}

View File

@@ -269,51 +269,22 @@ func TestMetaToKeyValueRows_StableOrder(t *testing.T) {
func TestExportFilename_PerScope(t *testing.T) {
ts := time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC)
// Project-scope filenames carry an 8-hex disambiguator (last UUID
// block); personal + org omit it.
rootID := uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb")
cases := []struct {
scope, label string
id uuid.UUID
want string
scope, label, want string
}{
{ExportScopePersonal, "", uuid.Nil, "paliad-export-personal-2026-05-19T1423Z.zip"},
{ExportScopeOrg, "", uuid.Nil, "paliad-export-org-2026-05-19T1423Z.zip"},
{ExportScopeProject, "Siemens AG", rootID, "paliad-export-project-Siemens-AG-a89469e2cacb-2026-05-19T1423Z.zip"},
{ExportScopeProject, "Hügel & Söhne", rootID, "paliad-export-project-H-gel-S-hne-a89469e2cacb-2026-05-19T1423Z.zip"},
// Nil UUID falls back to the slug-only variant — same as Slice 1's
// pre-disambiguator filename. Useful for unit tests of label-only
// behaviour.
{ExportScopeProject, "Siemens AG", uuid.Nil, "paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip"},
{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, c.id, ts)
got := ExportFilename(c.scope, c.label, ts)
if got != c.want {
t.Errorf("ExportFilename(%q, %q, %q) → %q, want %q", c.scope, c.label, c.id, got, c.want)
t.Errorf("ExportFilename(%q, %q) → %q, want %q", c.scope, c.label, got, c.want)
}
}
}
func TestExportFilename_ShortUUIDDisambiguator(t *testing.T) {
// Two projects with identical titles must produce different filenames
// when the UUID suffix is present — that's the whole point of Q5's
// disambiguator.
ts := time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC)
idA := uuid.MustParse("11111111-1111-1111-1111-aaaaaaaaaaaa")
idB := uuid.MustParse("22222222-2222-2222-2222-bbbbbbbbbbbb")
a := ExportFilename(ExportScopeProject, "Standard NDA", idA, ts)
b := ExportFilename(ExportScopeProject, "Standard NDA", idB, ts)
if a == b {
t.Fatalf("same-title same-ts filenames collide: %q", a)
}
if !strings.Contains(a, "aaaaaaaaaaaa") {
t.Errorf("filename missing UUID-A suffix: %q", a)
}
if !strings.Contains(b, "bbbbbbbbbbbb") {
t.Errorf("filename missing UUID-B suffix: %q", b)
}
}
func TestSlugifyFilename_StripsUnsafe(t *testing.T) {
cases := []struct{ in, want string }{
{"Siemens AG", "Siemens-AG"},
@@ -445,15 +416,9 @@ func assembleBundleForTest(t *testing.T, sheets []collectedSheet, meta ExportMet
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()
}
fixedMod := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
for _, e := range entries {
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate, Modified: mod}
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate, Modified: fixedMod}
fw, err := zw.CreateHeader(hdr)
if err != nil {
t.Fatalf("zip create %q: %v", e.name, err)

View File

@@ -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", "changes_requested"}
validRequestStatuses = []string{"pending", "approved", "rejected", "revoked"}
validApprovalEntityTypes = []string{"deadline", "appointment"}
validApprovalViewerRoles = []string{"approver_eligible", "self_requested", "any_visible"}
validDeadlineStatuses = []string{"pending", "completed"}

View File

@@ -1,315 +0,0 @@
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, "&lt;", "<")
s = strings.ReplaceAll(s, "&gt;", ">")
s = strings.ReplaceAll(s, "&quot;", `"`)
s = strings.ReplaceAll(s, "&apos;", "'")
s = strings.ReplaceAll(s, "&amp;", "&")
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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}

View File

@@ -1,354 +0,0 @@
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 &amp; Söhne &lt;GmbH&gt; &quot;Special&quot;") {
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))
}
}
}

View File

@@ -1,442 +0,0 @@
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{}
}
}

View File

@@ -1,484 +0,0 @@
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"
"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)
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
}

View File

@@ -569,11 +569,6 @@ 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
}