Compare commits
82 Commits
mai/copern
...
mai/gauss/
| Author | SHA1 | Date | |
|---|---|---|---|
| 96aef9b5dd | |||
| 1c021ed515 | |||
| 35217fab4f | |||
| 225204cf1c | |||
| ea0715a8c7 | |||
| 3fdc969902 | |||
| 5dea0a703b | |||
| cc23e9e537 | |||
| ca770636f7 | |||
| ea9823db80 | |||
| 111c7c39e8 | |||
| 25cee32d01 | |||
| 2ed0ef3177 | |||
| a5ae2148fa | |||
| 5a0674a2cf | |||
| 13bb01ec96 | |||
| 072b3d0c3d | |||
| e39c4eb62d | |||
| dc5f11ddef | |||
| e343b759da | |||
| 7288cf3c9c | |||
| 7f9e2ce7ed | |||
| bbb8c962a1 | |||
| 3966394a39 | |||
| 5dacc97a6b | |||
| 15bcba5d7c | |||
| 48f78a713b | |||
| a421bff856 | |||
| 0aa81139a3 | |||
| fbd087e0cd | |||
| 8bac1b4f88 | |||
| 1fcfab7791 | |||
| 12ed8bb8da | |||
| 7654ce6833 | |||
| f3b947e3ad | |||
| f0b08e9d06 | |||
| 760a0de931 | |||
| bc8dc9d048 | |||
| 694c7a53ad | |||
| 81cb89f68e | |||
| a6b2979a94 | |||
| 8f1f88b517 | |||
| d5c80febb1 | |||
| 1765d5e55f | |||
| c85c382b1b | |||
| 7a359989a9 | |||
| 1a8eee2a10 | |||
| 4472faf224 | |||
| 2504e50f29 | |||
| d244ff5158 | |||
| 741cab4d25 | |||
| 0263a0e932 | |||
| 0fd02bf033 | |||
| dce98e273b | |||
| c1c5532d52 | |||
| ee837815e1 | |||
| e035512e70 | |||
| 6401a8198d | |||
| 6a202411f6 | |||
| d924ab9743 | |||
| fb2896c836 | |||
| 705e1a2e79 | |||
| d8acbd613c | |||
| c01f3f2db8 | |||
| 2fa47278ce | |||
| 6c7e9ef44d | |||
| 17cd5b3b0c | |||
| d127c768f7 | |||
| dab06e068f | |||
| defa516e4f | |||
| 6ff26e8a6e | |||
| 2c94420a4b | |||
| 3677c81fbe | |||
| 8ea3509b98 | |||
| 5ff637ab70 | |||
| 265f240151 | |||
| 1039680878 | |||
| 773654523e | |||
| f7585376df | |||
| f9ff7b93e8 | |||
| 86d20ed6d4 | |||
| 1639b3919a |
@@ -1,6 +1,3 @@
|
||||
# Project-specific mai configuration
|
||||
# Auto-generated by 'mai init' — run 'mai setup' to customize
|
||||
|
||||
provider: claude
|
||||
providers:
|
||||
claude:
|
||||
@@ -47,21 +44,13 @@ worker:
|
||||
name_scheme: role
|
||||
default_level: standard
|
||||
auto_discard: false
|
||||
max_workers: 5
|
||||
max_workers: 7
|
||||
persistent: true
|
||||
head:
|
||||
name: "paliadin"
|
||||
max_loops: 50
|
||||
infinity_mode: false
|
||||
max_idle_duration: 2h0m0s
|
||||
backoff_intervals:
|
||||
- 5
|
||||
- 10
|
||||
- 15
|
||||
- 30
|
||||
name: paliadin
|
||||
capacity:
|
||||
global:
|
||||
max_workers: 5
|
||||
max_workers: 7
|
||||
max_heads: 3
|
||||
per_worker:
|
||||
max_tasks_lifetime: 0
|
||||
|
||||
@@ -117,7 +117,9 @@ func main() {
|
||||
}
|
||||
|
||||
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
|
||||
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
|
||||
bindingSvc := services.NewCalendarBindingService(pool)
|
||||
targetSvc := services.NewAppointmentTargetService(pool)
|
||||
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc, bindingSvc, targetSvc)
|
||||
// Wire the push hook so user-driven mutations sync to the external
|
||||
// calendar without waiting for the next 60-second tick.
|
||||
appointmentSvc.SetCalDAVPusher(caldavSvc)
|
||||
@@ -126,6 +128,20 @@ func main() {
|
||||
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
|
||||
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
|
||||
|
||||
// t-paliad-223 Slice B (#49) — Supabase Admin API client for the
|
||||
// new "Konto direkt anlegen" path on /admin/team. The key is
|
||||
// optional: when unset the client still wires (so dependents
|
||||
// don't panic) but every call short-circuits with
|
||||
// ErrSupabaseAdminUnavailable so the rest of the server stays
|
||||
// runnable.
|
||||
supabaseAdminClient := services.LoadSupabaseAdminClient()
|
||||
if supabaseAdminClient.Enabled() {
|
||||
log.Println("supabase admin API configured — /admin/team Add-User path active")
|
||||
} else {
|
||||
log.Println("SUPABASE_SERVICE_ROLE_KEY not set — /admin/team Add-User path will return 503")
|
||||
}
|
||||
users.SetAddUserDeps(supabaseAdminClient, mailSvc, baseURL)
|
||||
|
||||
// Wire EmailTemplateService onto the MailService so DB-backed admin
|
||||
// edits propagate without a process restart. The constructor is split
|
||||
// from MailService creation because the DB pool isn't available yet
|
||||
@@ -143,6 +159,7 @@ func main() {
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
CalDAVBindings: bindingSvc,
|
||||
Rules: rules,
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
@@ -175,14 +192,37 @@ func main() {
|
||||
UserView: services.NewUserViewService(pool),
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
DashboardLayout: services.NewDashboardLayoutService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
// t-paliad-214 Slice 1 — personal-scope data export. firm name
|
||||
// is captured into __meta of every export and printed in the
|
||||
// embedded README.
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
}
|
||||
|
||||
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
|
||||
// for the inbox-approvals widget. Done post-construction to avoid
|
||||
// a circular constructor dependency (ApprovalService doesn't need
|
||||
// the dashboard, and DashboardService can render its other widgets
|
||||
// without approvals — so keeping this a setter keeps both
|
||||
// constructors simple).
|
||||
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
|
||||
|
||||
// t-paliad-215 Slice 1 — submission generator. Three services
|
||||
// stitched together by handlers/submissions.go: registry pulls
|
||||
// templates from Gitea (reuses GITEA_TOKEN env), vars builds
|
||||
// the placeholder map from project + parties + rule, renderer
|
||||
// merges {{placeholder}} tokens into the .docx.
|
||||
svcBundle.SubmissionRegistry = services.NewTemplateRegistry(giteaToken, branding.Name)
|
||||
svcBundle.SubmissionVars = services.NewSubmissionVarsService(
|
||||
pool,
|
||||
svcBundle.Project,
|
||||
svcBundle.Party,
|
||||
svcBundle.Users,
|
||||
)
|
||||
svcBundle.SubmissionRenderer = services.NewSubmissionRenderer()
|
||||
|
||||
// Paliadin backend selection.
|
||||
//
|
||||
// PALIADIN_BACKEND (t-paliad-194 / m/paliad#38):
|
||||
|
||||
@@ -3,20 +3,23 @@
|
||||
// Three checks against TEST_DATABASE_URL:
|
||||
//
|
||||
// 1. db.ApplyMigrations does not panic and returns nil.
|
||||
// 2. The migration tracker (public.paliad_schema_migrations) advances to
|
||||
// the highest *.up.sql version on disk — no migrations were silently
|
||||
// skipped, no "dirty=true" stragglers left behind.
|
||||
// 2. paliad.applied_migrations covers every on-disk *.up.sql — no
|
||||
// migration was silently skipped, no version is missing. The set
|
||||
// contract is stronger than the old single-counter check: applied
|
||||
// set must EQUAL on-disk set, not just reach the max version.
|
||||
// 3. The handler mux (with /healthz mounted) responds 200 to GET /healthz.
|
||||
//
|
||||
// This is the lightweight cousin of the migration dry-run gate
|
||||
// (internal/db/migrate_test.go): the dry-run catches per-migration syntax
|
||||
// errors before merge; this smoke confirms the apply+bind path the
|
||||
// container actually runs at boot. Together they cover the mig-098 /
|
||||
// mig-099 class of crash-loops end-to-end.
|
||||
// mig-099 class of crash-loops end-to-end, plus the mig-103 parallel-merge
|
||||
// skip-hole that t-paliad-218 closed (m/paliad#44).
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL — matches the rest of the live-DB tests.
|
||||
//
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1.
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
|
||||
// docs/design-migration-runner-applied-set-2026-05-20.md §6.
|
||||
|
||||
package main
|
||||
|
||||
@@ -51,19 +54,23 @@ func TestBootSmoke(t *testing.T) {
|
||||
t.Fatalf("db.ApplyMigrations: %v", err)
|
||||
}
|
||||
|
||||
// (2) Assert the tracker advanced to the highest *.up.sql version we
|
||||
// embed. If a migration was silently skipped or the tracker is dirty,
|
||||
// the prod container would crash-loop — this turns that into a test
|
||||
// failure with a precise reason.
|
||||
expected := highestEmbeddedMigrationVersion(t)
|
||||
got, dirty := readTrackerVersion(t, url)
|
||||
if dirty {
|
||||
t.Errorf("tracker reports dirty=true at version %d — investigate before deploying", got)
|
||||
// (2) Assert the applied set equals the on-disk set. The new runner
|
||||
// tracks applied state per-migration; a silently-skipped version
|
||||
// would surface as a row missing from paliad.applied_migrations even
|
||||
// though max(version) matches. Comparing sets — not just max —
|
||||
// catches the failure mode the t-paliad-218 post-mortem documented.
|
||||
onDisk := embeddedMigrationVersions(t)
|
||||
applied := appliedMigrationVersions(t, url)
|
||||
|
||||
if missing := setDiff(onDisk, applied); len(missing) > 0 {
|
||||
t.Errorf("paliad.applied_migrations missing %d on-disk versions: %v "+
|
||||
"(a migration was skipped — investigate before deploying)",
|
||||
len(missing), missing)
|
||||
}
|
||||
if got != expected {
|
||||
t.Errorf("tracker at version %d; expected %d (highest *.up.sql on disk). "+
|
||||
"A migration was skipped or applied out of order.",
|
||||
got, expected)
|
||||
if extra := setDiff(applied, onDisk); len(extra) > 0 {
|
||||
t.Errorf("paliad.applied_migrations has %d versions with no on-disk file: %v "+
|
||||
"(orphan rows — either restore the file or DELETE the row)",
|
||||
len(extra), extra)
|
||||
}
|
||||
|
||||
// (3) Mount the public handlers (the same Register call main() makes,
|
||||
@@ -93,11 +100,16 @@ func TestBootSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// highestEmbeddedMigrationVersion finds max(N) over every NNN_*.up.sql
|
||||
// file in internal/db/migrations/ on disk. Used as the expected tracker
|
||||
// version after a clean apply. We read from disk (not the embed.FS in
|
||||
// the db package — it's unexported) since the test runs from the repo.
|
||||
func highestEmbeddedMigrationVersion(t *testing.T) int {
|
||||
// embeddedMigrationVersions returns every N where N_*.up.sql exists in
|
||||
// internal/db/migrations/ on disk. The boot smoke compares this set
|
||||
// against paliad.applied_migrations to detect skipped or orphan
|
||||
// migrations.
|
||||
//
|
||||
// Read from disk (not the embed.FS inside the db package — it's unexported)
|
||||
// since the test runs from the repo. The two views must agree for the
|
||||
// build to be self-consistent; if they diverge, the smoke test is the
|
||||
// wrong place to learn about it (the build is). We trust them to match.
|
||||
func embeddedMigrationVersions(t *testing.T) []int {
|
||||
t.Helper()
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
@@ -129,24 +141,52 @@ func highestEmbeddedMigrationVersion(t *testing.T) int {
|
||||
t.Fatalf("no *.up.sql files found in %s", dir)
|
||||
}
|
||||
sort.Ints(versions)
|
||||
return versions[len(versions)-1]
|
||||
return versions
|
||||
}
|
||||
|
||||
// readTrackerVersion fetches the lone row from the tracker. golang-migrate
|
||||
// keeps exactly one row; if we ever see zero or more, that's the dirty-state
|
||||
// the test is designed to flag.
|
||||
func readTrackerVersion(t *testing.T, url string) (version int, dirty bool) {
|
||||
// appliedMigrationVersions reads paliad.applied_migrations and returns
|
||||
// the sorted list of versions. Fails the test if the table doesn't exist —
|
||||
// db.ApplyMigrations is supposed to have created it by this point.
|
||||
func appliedMigrationVersions(t *testing.T, url string) []int {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
row := conn.QueryRow(`SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`)
|
||||
if err := row.Scan(&version, &dirty); err != nil {
|
||||
t.Fatalf("read tracker: %v", err)
|
||||
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations ORDER BY version`)
|
||||
if err != nil {
|
||||
t.Fatalf("read applied_migrations: %v", err)
|
||||
}
|
||||
return version, dirty
|
||||
defer rows.Close()
|
||||
var out []int
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// setDiff returns the elements of a that are not in b. Inputs are sorted
|
||||
// ascending; output preserves that ordering.
|
||||
func setDiff(a, b []int) []int {
|
||||
bset := make(map[int]bool, len(b))
|
||||
for _, v := range b {
|
||||
bset[v] = true
|
||||
}
|
||||
var out []int
|
||||
for _, v := range a {
|
||||
if !bset[v] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// repoRoot walks upward from the test binary's working directory until it
|
||||
|
||||
332
docs/design-approval-suggest-changes-2026-05-19.md
Normal file
332
docs/design-approval-suggest-changes-2026-05-19.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# Design — "Suggest changes" action on approval flow
|
||||
|
||||
**Author:** hertz (inventor)
|
||||
**Date:** 2026-05-19
|
||||
**Task:** t-paliad-216 (m/paliad in-flight)
|
||||
**Branch:** `mai/hertz/inventor-suggest-changes`
|
||||
**Status:** DESIGN — open questions await m before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
Add a fourth action **"Änderungen vorschlagen"** ("Suggest changes") to the approval flow, alongside Approve / Reject / Revoke. Use case: the approver doesn't want to accept the proposed change as-is, but doesn't want to reject outright — they edit the proposed values into a counter-proposal and submit it back into the same approval flow.
|
||||
|
||||
**Mental model (m, 2026-05-19):** suggest-changes is not "ping the requester to fix it" — it's the approver **authoring a counter-proposal** that gets re-injected into the approval flow as a fresh `pending` row. The original requester (now potentially an eligible approver of the counter, since they're no longer the requested_by) sees:
|
||||
- the **old row** in their /inbox as `changes_requested` ("Abgelehnt mit Vorschlag" / "Declined with changes") — historical record of their original attempt;
|
||||
- the **new row** in /inbox as `pending` — the counter, which they can approve, reject, revoke (n/a, not theirs), or suggest changes back on. Everyone else eligible sees the new row too. 4-Augen still holds: the counter's requested_by (the approver who suggested it) cannot self-approve.
|
||||
|
||||
Click flow:
|
||||
1. Approver opens an editable modal on the pending row showing the requester's proposed values. Edits any field. Writes a free-text note ("Bitte den Termin um 9:00 statt 8:00, weil der Raum sonst kollidiert").
|
||||
2. POST `/api/approval-requests/{id}/suggest-changes` with `{note, counter_payload}`.
|
||||
3. Server, in one tx: closes the old row (`changes_requested`, `decision_note=note`), reverts the entity from `pre_image`, then immediately inserts a **new** `pending` approval_requests row authored by the approver with `payload=counter_payload`, re-applies the counter to the entity, marks `pending_request_id` to the new row, emits two events (`*_approval_changes_suggested` + `*_approval_requested`). `previous_request_id` FK links new → old for chain traversal.
|
||||
|
||||
The pending audience for the new row is the same as any fresh `Submit*` — the existing notification + visibility plumbing handles it without special-casing.
|
||||
|
||||
---
|
||||
|
||||
## 0a. m's decisions (2026-05-19)
|
||||
|
||||
| # | Header | m picked | Reasoning note (when different from recommendation) |
|
||||
|---|---|---|---|
|
||||
| Q1 | State machine | **(a) New status `changes_requested`.** | As recommended. |
|
||||
| Q2 | Entity state | **(a) Reverts to pre_image, same as Reject.** | As recommended. The counter is then re-applied in the same tx by the new approval row's write-then-approve cycle. |
|
||||
| Q3 | Chain depth | **(a) Yes, across chained rows.** | As recommended. |
|
||||
| Q4 | Note shape | **Hybrid: approver can edit the proposed values (counter-proposal) AND/OR leave free-text in `decision_note`.** | Differs from (a). Inventor picked free-text-only; m's twist: the suggestion should ALSO carry concrete edits. This adds a `counter_payload jsonb` column on `approval_requests` and turns "suggest-changes" into an action that authors a real counter-proposal, not just a hint. |
|
||||
| Q5 | Surface | **(a) /inbox only — v1.** | As recommended. Email + entity-detail badge are Phase 2. |
|
||||
| Q6 | Requester actions | **Different model: the counter is a NEW pending approval_request row, not an "edit + resubmit" CTA on the requester side.** | Differs from (a). m's reframing: instead of routing back to the requester to act on, the suggestion IS the next request. Original requester sees the old row as `changes_requested` (status pill "Abgelehnt mit Vorschlag" or similar). Original requester then sees the NEW row in /inbox like any pending — and **may approve it themselves**, because they are no longer the row's requested_by (the suggesting approver is). Everyone else eligible sees it too. Cleaner workflow, removes the "edit-and-resubmit CTA" from the requester role entirely. |
|
||||
| Q7 | Notifications | **(b) Notify all eligible approvers + the original requester for the NEW pending row.** | Consistent with Q6. The counter is a fresh `pending` request, so the existing Submit*-notification audience applies. The original requester needs the ping because they're now an eligible approver of the counter — no special-case path. |
|
||||
| Q8 | Audit shape | **(a) New event_type `*_approval_changes_suggested` per entity.** | As recommended. The new row also emits a normal `*_approval_requested` event, so the Verlauf chronology naturally captures the chain. |
|
||||
|
||||
The decisions above lock the design. §3 has been rewritten to reflect them; §2 (open questions) is retained as the historical record of what was open before the decisions.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — what's already in the code (verified 2026-05-19)
|
||||
|
||||
- **State machine** in `internal/services/approval_service.go`:
|
||||
- `paliad.approval_requests.status` CHECK is already `('pending', 'approved', 'rejected', 'revoked', 'superseded')` — the `superseded` value is defined as a Go constant `RequestStatusSuperseded` but never written by the live service (reserved).
|
||||
- `paliad.{deadlines,appointments}.approval_status` CHECK is `('approved', 'pending', 'legacy')` — three values only.
|
||||
- Shared kernel `decide(requestID, callerID, finalStatus, note)` powers Approve / Reject / Revoke. Approve invokes `applyApproved`; Reject + Revoke invoke `applyRevert` (restores entity from `pre_image`).
|
||||
- Self-approval blocked at 3 layers: `canApprove` Go gate, `approval_requests_no_self_approval` DB CHECK, deadlock-check excludes requester from pool.
|
||||
- **Handlers** in `internal/handlers/approvals.go`:
|
||||
- `POST /api/approval-requests/{id}/approve`
|
||||
- `POST /api/approval-requests/{id}/reject`
|
||||
- `POST /api/approval-requests/{id}/revoke`
|
||||
- `GET /api/approval-requests/{id}` — single hydrated request
|
||||
- **Per-viewer flags** (t-paliad-202, shipped): every row carries `viewer_can_approve` + `viewer_is_requester` resolved server-side so the UI can grey out buttons the server would reject. Server still enforces — the flags are a UX hint.
|
||||
- **Frontend**:
|
||||
- `frontend/src/client/inbox.ts` wires three buttons per pending row (approve/reject/revoke). Reject opens `window.prompt()` for the note; approve+revoke don't.
|
||||
- `frontend/src/client/views/shape-list.ts` (row_action="approve") stamps the row with action buttons + diff + `decision_note` display if present.
|
||||
- **Audit**: event types `*_approval_requested`, `*_approval_approved`, `*_approval_rejected`, `*_approval_revoked` emitted to `paliad.project_events` (one per entity_type prefix).
|
||||
- **Decision note**: `paliad.approval_requests.decision_note text` — a single free-text column, last-write-wins. Already populated on Reject (Approve also accepts an optional note).
|
||||
|
||||
---
|
||||
|
||||
## 2. Design questions (the open list — see §6 for answered)
|
||||
|
||||
Pre-recommendations from inventor. m will pick via AskUserQuestion.
|
||||
|
||||
### State machine
|
||||
|
||||
**Q1 — Where does "suggest changes" sit on the lifecycle?**
|
||||
- **(a) New status `changes_requested` (RECOMMENDED).** The approval_requests row transitions pending → changes_requested. Sibling of approved/rejected/revoked/superseded. The row is terminal in that status; a re-submit creates a fresh row (linked via `previous_request_id`).
|
||||
- (b) Reuse `rejected` with `is_revisable=true` flag. Cheap, but conflates two semantically distinct outcomes ("we'll never want this" vs. "tweak X and try again").
|
||||
- (c) Auto-revoke the current row, mark the entity for edit, requester creates a new approval row when ready. Reuses existing plumbing — but loses the approver's note as a first-class thing (it'd just be a comment on the project_events row).
|
||||
- (d) Other (you'll tell us).
|
||||
|
||||
Recommend (a) — keeps the audit lifecycle clear, gives us a clean place to hang the suggestion note, and is the smallest schema change (one new value in a CHECK constraint).
|
||||
|
||||
**Q2 — What happens to the entity (deadline/appointment) while in "changes requested"?**
|
||||
- **(a) Entity reverts to pre_image — same as Reject (RECOMMENDED).** approval_status flips back to `approved`. The requester edits the entity in the normal flow; saving fires a fresh `Submit*` cycle.
|
||||
- (b) Entity stays at `approval_status=pending` carrying the proposed values; requester edits "in place" through a new "amend the pending request" endpoint that mutates the same approval_request row + entity fields.
|
||||
- (c) Entity goes to a new `approval_status=draft` (would require a new value on the entity-level CHECK + UI work to handle a third entity state).
|
||||
|
||||
Recommend (a) — minimum schema change, reuses every existing path (entity edit, Submit*, applyRevert, project_events emission). The trade-off is one extra approval_requests row per cycle; we link via `previous_request_id` so the chain stays inspectable.
|
||||
|
||||
**Q3 — Can the approver suggest changes multiple times (across a chain)?**
|
||||
- **(a) Yes, across chained rows (RECOMMENDED).** Each row is terminal after suggest-changes; the requester resubmits → new pending row → approver can suggest changes again. Chain depth unbounded.
|
||||
- (b) No — one chance per entity-lifecycle; if the requester comes back, the only options are approve or reject (the suggest-changes button is hidden for the second submission).
|
||||
|
||||
Recommend (a) — bounded by the requester's patience, not by the system. Multi-round review is the norm in legal-doc workflows.
|
||||
|
||||
**Q4 — Note shape on the suggestion**
|
||||
- **(a) Free-text — reuse `decision_note` (RECOMMENDED).** Same column the existing Reject path already populates. Last-write-wins per row (but rows are terminal after suggest-changes, so there's no real "last write").
|
||||
- (b) Thread of notes — new `paliad.approval_notes` table, ordered, multi-author. Lets the requester respond inline, the approver clarify, etc.
|
||||
- (c) Structured per-field suggestions (`[{"field": "due_date", "current": "...", "suggested": "..."}]`) — a "diff-style" view.
|
||||
|
||||
Recommend (a) — matches the existing Reject UX, no new schema. (b) is right if the team wants to discuss; (c) is over-engineered for v1.
|
||||
|
||||
### UX
|
||||
|
||||
**Q5 — Where does the requester see the suggestion?**
|
||||
- **(a) /inbox under `a_role=self_requested` (RECOMMENDED for v1).** Same surface they already use to see rejected. New status pill "Änderungen vorgeschlagen" + the note + a CTA "Bearbeiten und erneut einreichen".
|
||||
- (b) A new badge on the entity's detail page (e.g. on the deadline detail page itself).
|
||||
- (c) Email + push notification.
|
||||
- (d) All of the above.
|
||||
|
||||
Recommend (a) for v1. Email reminder is a natural Phase-2 add-on (it'd reuse the existing reminder-mail plumbing). The entity-detail badge is nice but the user is already seeing the row in /inbox.
|
||||
|
||||
**Q6 — What action(s) does the requester have on a `changes_requested` row?**
|
||||
- **(a) Edit and resubmit (RECOMMENDED).** Primary action. Opens the entity's edit form pre-populated with the original `payload`. Saving fires `Submit*` → new pending request with `previous_request_id` linking back.
|
||||
- (b) Withdraw (= dismiss the row from inbox, no DB change). Mostly UI-only — the row is already terminal; "withdraw" would just be a "mark as not-pursuing" toggle.
|
||||
- (c) Both.
|
||||
|
||||
Recommend (a). The row is already terminal once status=`changes_requested`; the requester either acts on the suggestion (a) or lets the row sit in their inbox history (no action needed). Adding a "dismiss" button is a UI nice-to-have but doesn't change the data model; can defer.
|
||||
|
||||
### Notifications
|
||||
|
||||
**Q7 — Who gets notified when "suggest changes" fires?**
|
||||
- **(a) Just the requester (RECOMMENDED for v1).** Email-reminder path is reused: requester gets a mail "X hat Änderungen vorgeschlagen für …" with the note inline + a link to /inbox.
|
||||
- (b) Requester + any other potential approvers (they need to know the request is closed, not pending).
|
||||
- (c) Requester + approval-policy-defined watchers (would require a new `approval_policies.watchers` column).
|
||||
|
||||
Recommend (a). The request is terminal so other approvers don't need a "this is now your problem" ping — they wouldn't have anything to act on. They see it in /inbox under "Alle sichtbaren" anyway if curious.
|
||||
|
||||
### Audit
|
||||
|
||||
**Q8 — Audit row shape on `project_events`**
|
||||
- **(a) New event_type `*_approval_changes_suggested` per entity (RECOMMENDED).** Parallel to the existing 4 (requested/approved/rejected/revoked). Two new event types: `deadline_approval_changes_suggested`, `appointment_approval_changes_suggested`. Note text goes in metadata.
|
||||
- (b) Bundle with the resubmission — single composite event "approved-with-revisions" when the chain eventually approves.
|
||||
|
||||
Recommend (a). Each transition gets its own event row — that's how the existing audit chain already works (one event per state change). It also gives the Verlauf timeline a row to render the approver's note.
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation sketch (decisions-locked, see §0a)
|
||||
|
||||
### 3.1 Migration `103_approval_suggest_changes.up.sql`
|
||||
|
||||
```sql
|
||||
-- 1. Extend approval_requests.status CHECK to allow 'changes_requested'.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD CONSTRAINT approval_requests_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded', 'changes_requested'));
|
||||
|
||||
-- 2. Add counter_payload — the approver's edited values, becomes the
|
||||
-- `payload` of the NEW pending row spawned in the same tx as the
|
||||
-- suggest-changes call. Stored on the OLD (now changes_requested) row
|
||||
-- too so the audit chain can show "approver edited X, Y, Z" without
|
||||
-- joining to the next row.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN counter_payload jsonb NULL;
|
||||
|
||||
-- 3. Add previous_request_id FK so the new row links back to its origin.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN previous_request_id uuid NULL
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX approval_requests_previous_idx
|
||||
ON paliad.approval_requests (previous_request_id)
|
||||
WHERE previous_request_id IS NOT NULL;
|
||||
```
|
||||
|
||||
`.down.sql`: drop the index + columns, restore the original CHECK (would reject existing `changes_requested` rows — that's normal for a breaking-change down).
|
||||
|
||||
### 3.2 Service layer
|
||||
|
||||
`SuggestChanges` is the only new public method on `ApprovalService`. It runs in **one transaction** and does five things:
|
||||
|
||||
```go
|
||||
const RequestStatusChangesRequested = "changes_requested"
|
||||
|
||||
var ErrSuggestionRequiresChange = errors.New("suggestion_requires_change")
|
||||
|
||||
// SuggestChanges closes the pending request as `changes_requested`,
|
||||
// reverts the entity, then immediately inserts a new pending
|
||||
// approval_request authored by the caller carrying `counterPayload` as
|
||||
// its new payload. The new row enters the standard pending flow — anyone
|
||||
// eligible (including the original requester) can approve, reject,
|
||||
// suggest-changes-again, etc.
|
||||
//
|
||||
// Authorization: caller satisfies canApprove on the OLD row (same gate
|
||||
// as Approve / Reject). For the NEW row, the caller is the requested_by
|
||||
// — self-approval is blocked by the standard 3-layer guard. Deadlock
|
||||
// check (qualified-approver-exists-other-than-caller) runs on the new
|
||||
// row to avoid spawning an unapprovable request.
|
||||
//
|
||||
// counterPayload must differ from the old row's payload OR a non-empty
|
||||
// note must be present. A no-op suggest (same values, no note) is
|
||||
// indistinguishable from "I have no opinion" and gets rejected with
|
||||
// ErrSuggestionRequiresChange.
|
||||
func (s *ApprovalService) SuggestChanges(
|
||||
ctx context.Context,
|
||||
requestID, callerID uuid.UUID,
|
||||
counterPayload []byte, // jsonb-marshaled
|
||||
note string,
|
||||
) (newRequestID *uuid.UUID, err error) {
|
||||
// 1. Begin tx, lock old row, validate status=pending + canApprove.
|
||||
// 2. Validate: counterPayload differs from old payload OR note != "".
|
||||
// 3. Update old row: status='changes_requested', decided_by=callerID,
|
||||
// decision_note=note, counter_payload=counterPayload.
|
||||
// 4. applyRevert on the entity (uses old row's pre_image).
|
||||
// 5. Deadlock-check on the new row's required_role + projectID,
|
||||
// excluding callerID.
|
||||
// 6. INSERT new approval_requests row: requested_by=callerID,
|
||||
// pre_image=<entity-state-as-just-reverted> (= old.pre_image),
|
||||
// payload=counterPayload, required_role=old.required_role,
|
||||
// lifecycle_event=old.lifecycle_event, entity_type=old.entity_type,
|
||||
// entity_id=old.entity_id, status='pending',
|
||||
// previous_request_id=requestID.
|
||||
// 7. Re-apply the new payload to the entity (write-then-approve):
|
||||
// apply the counter_payload's field updates + mark
|
||||
// approval_status='pending' + pending_request_id=newRequestID.
|
||||
// 8. Emit *_approval_changes_suggested project_events row
|
||||
// (metadata: note, counter_payload diff vs original).
|
||||
// 9. Emit *_approval_requested project_events row for the new
|
||||
// request (same shape Submit* normally emits).
|
||||
// 10. Commit.
|
||||
}
|
||||
```
|
||||
|
||||
Steps 6 + 7 reuse the existing `Submit*` plumbing structurally — the cleanest implementation factors out an "insert approval row + apply payload to entity" helper that both `Submit*` and `SuggestChanges` call. **decide()** does not need to know about `changes_requested` because suggest-changes is not a decision-kernel transition — it's its own end-to-end action.
|
||||
|
||||
### 3.3 HTTP layer
|
||||
|
||||
```
|
||||
POST /api/approval-requests/{id}/suggest-changes
|
||||
Body: {
|
||||
"counter_payload": { ...same shape as Submit*'s payload... },
|
||||
"note": "free-text explanation, optional iff counter_payload differs from original"
|
||||
}
|
||||
Returns: 200 { "new_request_id": "uuid" }
|
||||
Errors:
|
||||
400 "suggestion_requires_change" — counter_payload == old payload AND note empty
|
||||
400 "invalid_counter_payload" — schema validation failure
|
||||
403 "self_approval_blocked" — caller == old row's requested_by
|
||||
403 "not_authorized" — caller doesn't satisfy canApprove
|
||||
404 — request not found / not visible
|
||||
409 "request_not_pending" — old row already decided
|
||||
409 "no_qualified_approver" — deadlock on the new row (only caller is eligible)
|
||||
```
|
||||
|
||||
Register in `internal/handlers/handlers.go` alongside the existing three:
|
||||
|
||||
```go
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
|
||||
```
|
||||
|
||||
### 3.4 Frontend
|
||||
|
||||
`frontend/src/client/views/shape-list.ts` — extend the pending-row action group to four buttons:
|
||||
|
||||
```ts
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
```
|
||||
|
||||
The `action` union type gains `"suggest_changes"`. Disabled-reason logic is identical to approve/reject (`viewer_can_approve` gate). i18n: `approvals.action.suggest_changes` → DE "Änderungen vorschlagen" / EN "Suggest changes".
|
||||
|
||||
`frontend/src/client/inbox.ts` — clicking the suggest-changes button opens a **modal**, not a `window.prompt` (the existing reject prompt is OK because reject only needs a note; suggest-changes needs an editable form). The modal:
|
||||
- Renders the same fields the entity edit form would show, pre-populated from `detail.payload` (the requester's proposed values).
|
||||
- Adds a free-text "Vorschlagskommentar" textarea at the bottom (the note).
|
||||
- On submit: POST `/api/approval-requests/{id}/suggest-changes` with `{counter_payload: {...editedFields}, note}`.
|
||||
- On success: refresh the bar — the old row flips to `changes_requested`, the new row appears as `pending`.
|
||||
|
||||
Where the modal's field-editor lives: a new `client/components/approval-edit-modal.ts` that takes `entity_type` + `payload` + `pre_image` and returns the edited payload. For v1 it can be a thin wrapper over the existing entity-edit form components (Frist date picker, Termin start/end pickers). Don't build a generic field-editor framework — just deadlines + appointments, hard-coded fields per entity_type.
|
||||
|
||||
**Status pill for `changes_requested`** — i18n keys + colour:
|
||||
- `approvals.status.changes_requested` → DE "Abgelehnt mit Vorschlag" / EN "Declined with changes"
|
||||
- Reuse the existing `approval-pill--historic` style; no new colour token needed for v1.
|
||||
|
||||
**The "Edit and resubmit" CTA on the requester's row is NOT needed** (m's Q6 reframing) — the requester just sees the new pending row in /inbox, same as any other.
|
||||
|
||||
### 3.5 Inbox filter
|
||||
|
||||
The /inbox `approval_status` filter chip cluster gains `changes_requested`. The `self_requested` viewer-role default already includes terminal statuses, so the original requester sees their `changes_requested` row without changing the default filter.
|
||||
|
||||
### 3.6 Linkage from old row to new row in /inbox
|
||||
|
||||
When showing a `changes_requested` row in /inbox, add a small "→ Neuer Vorschlag von {approver}" link below the note that scrolls / filters to the new pending row (it'll be visible to anyone eligible, including the original requester). The new row has `previous_request_id` pointing at the old one — so the API response for the old row can hydrate `next_request_id` (computed: `SELECT id FROM approval_requests WHERE previous_request_id = $1 LIMIT 1`).
|
||||
|
||||
### 3.7 Email notification (Phase 2 — defer until v1 ships)
|
||||
|
||||
The new row triggers the existing `*_approval_requested` notification path (whatever that is for Submit*) — same audience, same template. No new code. The old row's transition to `changes_requested` doesn't need its own mail; the new-row mail already tells the audience "X suggested changes to your earlier submission" through the body.
|
||||
|
||||
Out of scope for v1: a bespoke "your submission was declined with a counter-proposal" email aimed at the original requester. The new-row mail covers it functionally.
|
||||
|
||||
---
|
||||
|
||||
## 4. Slice plan
|
||||
|
||||
Three reviewable slices, each one PR. Combined scope is small/medium.
|
||||
|
||||
1. **Slice A — backend.** Migration 103 (CHECK extension + `counter_payload jsonb` + `previous_request_id` FK + index) + `SuggestChanges` service method + HTTP handler + service tests (happy path, no-op-suggestion guard, deadlock on new row, self-approval block, request_not_pending). Migration is non-blocking on Postgres; safe for live deploy.
|
||||
2. **Slice B — frontend.** 4th button on /inbox + the edit modal (deadline-fields variant + appointment-fields variant) + status pill `changes_requested` ("Abgelehnt mit Vorschlag") + i18n keys (DE + EN) + the "→ Neuer Vorschlag" link from old row to new row. End-to-end browser smoke test via Playwright.
|
||||
3. **Slice C — Verlauf integration.** Make sure the `*_approval_changes_suggested` event renders on the project / deadline / appointment Verlauf timeline alongside the existing 4 approval event types. May or may not need code change depending on how generic the Verlauf row renderer is — likely just an i18n key + an icon mapping.
|
||||
|
||||
Don't ship a chain-traversal UI in v1. The `previous_request_id` FK is captured so the data is there; surfacing the full chain history (n hops back) is a Phase-2 polish.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risks / open considerations
|
||||
|
||||
- **Chain depth runaway.** Nothing stops an "I keep suggesting / they keep counter-suggesting" loop. Same risk as comment threads on GitHub PRs. Out of scope to cap; the social pressure (each round is a 4-Augen action with a name attached) is the natural brake.
|
||||
- **Concurrent suggestions on the same pending row.** Two approvers click "suggest changes" at the same time? The existing `getRequestForUpdate` row-lock serialises them; the second caller gets `ErrRequestNotPending` (the first already flipped it). Same guarantee as Approve/Reject today.
|
||||
- **Deadlock on the new row.** If the suggesting approver is the only qualified approver other than the original requester, the new row's deadlock check returns "no qualified approver" — because the original requester IS now eligible (they're no longer the requested_by), but might not have a high-enough role. The check needs to recognise: caller's pool = "anyone other than the new requester who can canApprove". Original requester counts if they hit the required-role bar. This is just the existing deadlock predicate run against the new (requester, role) tuple; no special-case logic. Surfaced as `409 "no_qualified_approver"` to the suggesting approver, with the standard global_admin override path still available.
|
||||
- **Counter-payload schema validation.** Server must validate `counter_payload` against the same schema as a normal `Submit*` for that entity_type + lifecycle_event. Otherwise a malicious approver could write garbage values via the suggestion path that wouldn't fly through `Submit*`. Reuse the existing payload-schema validator from the entity services; don't write a parallel.
|
||||
- **No-op suggestion guard.** Approver clicks suggest-changes but doesn't actually edit anything AND leaves the note empty? Server rejects with `ErrSuggestionRequiresChange`. UI guards too (the submit button stays disabled until either the form is dirty OR the note has text).
|
||||
- **Migration safety.** Non-blocking. Adding a value to a CHECK constraint is a metadata-only change; adding a NULLable column + a NULLable FK is also metadata-only.
|
||||
- **What about a structured per-field suggestion (Q4c)?** The `counter_payload` jsonb IS structured — each entity_type has fixed fields. There's no need for a separate "{field, current, suggested}" shape because the diff is computable from `pre_image → counter_payload` on the new row.
|
||||
- **What about thread-of-notes (Q4b)?** Implicit in the chain — each row's `decision_note` is one "note" by one author; following `previous_request_id` backwards reconstructs the full back-and-forth. A future "thread view" UI is layered on top of this without schema change.
|
||||
|
||||
---
|
||||
|
||||
## 6. m's decisions
|
||||
|
||||
See §0a (decisions table) — filled in after the AskUserQuestion phase on 2026-05-19.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope for this design
|
||||
|
||||
- Email + push notifications (Phase 2; see §3.7).
|
||||
- Structured per-field suggestion shape (Phase 2 enhancement).
|
||||
- Approval-policy `watchers` column for notification fan-out.
|
||||
- "Dismiss this row from my inbox" UI toggle (UX-only, not a data-model change).
|
||||
- Cross-entity suggest-changes (e.g. project, party). Same as the original approval scope — deadlines + appointments only.
|
||||
|
||||
686
docs/design-project-metadata-rework-2026-05-20.md
Normal file
686
docs/design-project-metadata-rework-2026-05-20.md
Normal file
@@ -0,0 +1,686 @@
|
||||
# Project metadata rework — Client Role + auto-derived project codes
|
||||
|
||||
Status: design, ready for head review (2026-05-20)
|
||||
Task: t-paliad-222
|
||||
Issues: m/paliad#47 (Client Role) + m/paliad#50 (project codes)
|
||||
Branch: `mai/kepler/inventorcoder-project`
|
||||
|
||||
Pairs two related changes because both touch `paliad.projects` schema, the
|
||||
project form, and downstream consumers (Fristenrechner Determinator,
|
||||
submission templates, Verlauf, picker / breadcrumb surfaces). One design,
|
||||
two migrations, one coder shift.
|
||||
|
||||
---
|
||||
|
||||
## §1 Scope & non-goals
|
||||
|
||||
In scope:
|
||||
|
||||
- Drop "Wir vertreten" entirely on `type='client'`, `'litigation'`, `'patent'`.
|
||||
- Rename to "Client Role" / "Mandantenrolle" on `type='case'` with new
|
||||
option set (Active / Reactive / Third Party / Other).
|
||||
- Widen `paliad.projects.our_side` CHECK to the new sub-role values; drop
|
||||
`'court'` and `'both'`; backfill existing rows to NULL.
|
||||
- Add `paliad.projects.opponent_code text` on `type='litigation'` rows
|
||||
(segment source for project codes).
|
||||
- New Go helper `services.BuildProjectCode(ctx, projectID) (string, error)`
|
||||
that walks the ancestor chain via the existing ltree `path` and assembles
|
||||
the dotted code. Custom `paliad.projects.reference` on the project itself
|
||||
wins.
|
||||
- Wire the helper into project header, breadcrumb, picker labels, the
|
||||
submission-template variable bag (`{{project.code}}`), and the Excel
|
||||
export `__meta` sheet.
|
||||
|
||||
Out of scope (handled separately or dropped):
|
||||
|
||||
- Reshaping `paliad.parties` (per-party role rows are unchanged).
|
||||
- New analytics / reports breaking out sub-roles.
|
||||
- Bulk-renaming user-facing copy that says "Klägerseite" /
|
||||
"Beklagtenseite" outside the project form.
|
||||
- Reverse lookup (project by code) — already works via `reference`.
|
||||
- Audit-history for who changed an override and when — not requested.
|
||||
- Bulk regeneration of existing `reference` strings — manual entries stay
|
||||
intact; auto-derive only fills empty slots.
|
||||
- Renaming the `our_side` DB column — see §2.2 / Q1.
|
||||
|
||||
---
|
||||
|
||||
## §2 Issue #47 — Client Role rework
|
||||
|
||||
### §2.1 Current state (verified 2026-05-20)
|
||||
|
||||
- Column: `paliad.projects.our_side text`, CHECK constraint
|
||||
`projects_our_side_check` allows `('claimant','defendant','court','both',NULL)`
|
||||
(mig 072).
|
||||
- Live data audit (`SELECT our_side, count(*) FROM paliad.projects
|
||||
GROUP BY our_side`): **all 12 rows are NULL**. Zero rows on
|
||||
`'court'` or `'both'` — backfill is a no-op. The migration is risk-free
|
||||
on the current dataset.
|
||||
- Form: rendered for every project type by
|
||||
`frontend/src/components/ProjectFormFields.tsx:156-168` (one
|
||||
`<select id="project-our-side">` with five static `<option>`s, no
|
||||
conditional render).
|
||||
- Downstream consumers (verified by grep on `our_side` /
|
||||
`OurSide` in `internal/` and `frontend/src/`):
|
||||
- `frontend/src/client/fristenrechner.ts:2187,2734,3754-3776` —
|
||||
Determinator Slice 3c, `ourSideToPerspective()` maps
|
||||
`claimant → claimant`, `defendant → defendant`, anything else
|
||||
(incl. `'court'`, `'both'`, NULL) → `null` (chip free-pick).
|
||||
- `internal/services/submission_vars.go:276-278,390-418` —
|
||||
`{{project.our_side_de}}` / `_en` legal-prose forms. `ourSideDE` /
|
||||
`ourSideEN` switch on the 4 enum values.
|
||||
- `internal/services/project_service.go:1083-1104` —
|
||||
`our_side_changed` project-event row on writes.
|
||||
- `internal/services/project_service.go:1228,1372,1955-` — CCR
|
||||
counterclaim child default-inverts `our_side`; `nullableOurSide()`
|
||||
and `isValidOurSide()` (`project_service.go:1915`) gate writes.
|
||||
|
||||
### §2.2 Decisions
|
||||
|
||||
**Q1 — Rename column `our_side → client_role`?**
|
||||
**Pick: NO. Keep `our_side`.** Renaming forces churn in eleven Go files,
|
||||
the Determinator client bundle (`fristenrechner.ts` type literal +
|
||||
`ourSideToPerspective`), all submission-template tests
|
||||
(`submission_render_test.go:275`), the project-event title key
|
||||
(`event.title.our_side_changed`), and every `{{project.our_side*}}` template
|
||||
that exists in the wild on user systems. The label is purely UI; the column
|
||||
name is internal. Future grep stays clean because the new label
|
||||
("Client Role") and the column (`our_side`) describe the same concept from
|
||||
different perspectives ("which side the firm represents" =
|
||||
"what role the client plays"). Keeping the column avoids a 200-line
|
||||
mechanical rename with non-trivial risk for zero functional gain. The
|
||||
i18n keys *do* rename (`projects.field.our_side` → `projects.field.client_role`)
|
||||
so user-facing copy stays clean.
|
||||
|
||||
**Q2 — Sub-role granularity (7 distinct values vs 3 groups)?**
|
||||
**Pick: 7 sub-roles** — `claimant, defendant, applicant, appellant,
|
||||
respondent, third_party, other`. Lawyers care about the specific
|
||||
procedural posture; Applicant ≠ Claimant in some UPC contexts (e.g. PI
|
||||
applications use "Applicant"). Group-level aggregation is trivial at
|
||||
display time (`switch role { case claimant, applicant, appellant:
|
||||
return "Active" }`). Storing the group only would be a lossy choice we
|
||||
cannot reconstruct from.
|
||||
|
||||
**Q3 — Project types where the field is visible?**
|
||||
**Pick: ONLY `type='case'`.** m's wording is unambiguous ("only plays a
|
||||
role in case projects — and even there the question should be 'Client
|
||||
Role'"). Hide on `client`, `litigation`, `patent`, and the generic
|
||||
`project` type. The client-level "industry / country" block stays as is
|
||||
(those are client-attributes, not procedural roles). The form already
|
||||
has `projekt-fields-case` conditional render (`ProjectFormFields.tsx:143`)
|
||||
— moving the role select into that block is a 4-line change.
|
||||
|
||||
**Q4 — Existing `'court'` / `'both'` row backfill?**
|
||||
**Pick: backfill to NULL** in the same migration that widens the CHECK.
|
||||
Zero rows in production (verified 2026-05-20), so the backfill is a
|
||||
no-op today; it's there for safety if any test fixture or
|
||||
not-yet-deployed instance has them. No audit-event emission for the
|
||||
backfill (it's schema cleanup, not user action).
|
||||
|
||||
**Q5 — Determinator perspective mapping for new sub-roles?**
|
||||
**Pick: Active group → `claimant`, Reactive group → `defendant`, Third
|
||||
Party / Other → `null` (chip free-pick).** Concretely:
|
||||
|
||||
- `claimant`, `applicant`, `appellant` → perspective `'claimant'`
|
||||
- `defendant`, `respondent` → perspective `'defendant'`
|
||||
- `third_party`, `other`, NULL → perspective `null`
|
||||
|
||||
This keeps the Determinator's existing claimant-rule / defendant-rule
|
||||
filter logic unchanged; only `ourSideToPerspective()`'s switch widens.
|
||||
|
||||
**Q6 — Submission template `_de` / `_en` prose for new sub-roles?**
|
||||
|
||||
| value | `_de` (Nominativ) | `_en` |
|
||||
|---------------|-------------------------------|---------------|
|
||||
| `claimant` | Klägerin | Claimant |
|
||||
| `defendant` | Beklagte | Defendant |
|
||||
| `applicant` | Antragstellerin | Applicant |
|
||||
| `appellant` | Berufungsklägerin | Appellant |
|
||||
| `respondent` | Antragsgegnerin | Respondent |
|
||||
| `third_party` | Streithelferin | Third Party |
|
||||
| `other` | sonstige Verfahrensbeteiligte | other party |
|
||||
|
||||
Existing `'court'`/`'both'` switch arms get deleted (no live rows; if a
|
||||
stale `our_side='court'` slipped through somehow, the function returns
|
||||
`""` — same fallback as today for unknown values).
|
||||
|
||||
### §2.3 Migration `112_client_role_rework`
|
||||
|
||||
```sql
|
||||
-- 112_client_role_rework.up.sql (renumbered 2026-05-20 — mig 110 was claimed by m/paliad#51, mig 111 by m/paliad#48)
|
||||
-- t-paliad-222 / m/paliad#47.
|
||||
-- Widens projects.our_side CHECK to seven sub-role values and drops
|
||||
-- the legacy 'court' / 'both' entries. Backfill is a no-op on the
|
||||
-- current dataset (verified 2026-05-20: all 12 rows are NULL), but
|
||||
-- runs defensively in case any test fixture / staging instance still
|
||||
-- carries the old values.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Backfill any 'court' / 'both' rows to NULL. Idempotent.
|
||||
UPDATE paliad.projects
|
||||
SET our_side = NULL
|
||||
WHERE our_side IN ('court', 'both');
|
||||
|
||||
-- 2. Drop the old CHECK, add the widened one. Both are idempotent
|
||||
-- against partially-applied state.
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_our_side_check;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_our_side_check
|
||||
CHECK (our_side IS NULL OR our_side IN (
|
||||
'claimant', 'defendant',
|
||||
'applicant', 'appellant',
|
||||
'respondent',
|
||||
'third_party', 'other'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.our_side IS
|
||||
'Which side the firm represents on this case project (renamed in '
|
||||
'the UI to "Client Role" — t-paliad-222 / m/paliad#47). Allowed '
|
||||
'sub-roles, grouped at display time: Active (claimant, applicant, '
|
||||
'appellant); Reactive (defendant, respondent); Third Party / Other '
|
||||
'(third_party, other). NULL = unknown. Hidden in the form on '
|
||||
'non-case project types. Drives the Fristenrechner Determinator '
|
||||
'perspective chip (Active→claimant, Reactive→defendant, else null).';
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
The down migration restores the original 4-value CHECK and, for
|
||||
defensive symmetry, backfills any new sub-role values to NULL (so the
|
||||
schema is internally consistent when stepped down).
|
||||
|
||||
### §2.4 Frontend changes
|
||||
|
||||
`frontend/src/components/ProjectFormFields.tsx`:
|
||||
|
||||
1. Move the `<div className="form-field">` containing
|
||||
`#project-our-side` from the always-visible block (line 156) into
|
||||
the `projekt-fields-case` block (after the court / case-number
|
||||
row).
|
||||
2. Rename label `data-i18n="projects.field.our_side"` →
|
||||
`projects.field.client_role`.
|
||||
3. Replace the five flat `<option>`s with three `<optgroup>`s + the
|
||||
seven new options + an "Unbekannt" empty option.
|
||||
4. Update the hint text to mention the Determinator group mapping
|
||||
(Active/Reactive).
|
||||
|
||||
`frontend/src/client/i18n.ts` — add new keys (DE + EN):
|
||||
|
||||
```
|
||||
projects.field.client_role → "Mandantenrolle" / "Client Role"
|
||||
projects.field.client_role.hint → "..."
|
||||
projects.field.client_role.unset → "Unbekannt" / "Unknown"
|
||||
projects.field.client_role.group.active → "Aktiv (wir greifen an)" / "Active (we initiate)"
|
||||
projects.field.client_role.group.reactive → "Reaktiv (wir verteidigen)" / "Reactive (we defend)"
|
||||
projects.field.client_role.group.other → "Dritte / Sonstige" / "Third Party / Other"
|
||||
projects.field.client_role.claimant → "Klägerseite" / "Claimant"
|
||||
projects.field.client_role.applicant → "Antragsteller" / "Applicant"
|
||||
projects.field.client_role.appellant → "Berufungsführer" / "Appellant"
|
||||
projects.field.client_role.defendant → "Beklagtenseite" / "Defendant"
|
||||
projects.field.client_role.respondent → "Antragsgegner" / "Respondent"
|
||||
projects.field.client_role.third_party → "Streithelfer / Dritter" / "Third Party"
|
||||
projects.field.client_role.other → "Sonstige Beteiligte" / "Other party"
|
||||
```
|
||||
|
||||
The legacy `projects.field.our_side.*` keys stay deprecated-but-present
|
||||
for one release so any cached browser bundle keeps rendering. They get
|
||||
deleted in a follow-up housekeeping shift once the rollout is confirmed.
|
||||
|
||||
`frontend/src/client/project-form.ts:182-230` — adjust the payload
|
||||
read/write to only include `our_side` when the field is in the DOM
|
||||
(non-case forms no longer emit it). The current code does
|
||||
`if (v) payload.our_side = v` which already handles the "field absent"
|
||||
case gracefully (osSel becomes `null`, no payload key set).
|
||||
|
||||
`frontend/src/client/fristenrechner.ts:3754-3776` —
|
||||
`ourSideToPerspective` switch widens:
|
||||
|
||||
```ts
|
||||
function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||||
switch (os) {
|
||||
case "claimant":
|
||||
case "applicant":
|
||||
case "appellant":
|
||||
return "claimant";
|
||||
case "defendant":
|
||||
case "respondent":
|
||||
return "defendant";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`frontend/src/projects-detail.tsx` Verlauf — the `our_side_changed`
|
||||
event description currently renders the raw enum. Update the renderer
|
||||
to use a label lookup so "Mandant: Beklagte → Antragsteller" reads
|
||||
correctly. Same `event.title.our_side_changed` key stays (the *title*
|
||||
is "Vertretene Seite geändert" / "Represented side changed", which is
|
||||
still accurate semantically).
|
||||
|
||||
### §2.5 Backend changes
|
||||
|
||||
`internal/services/project_service.go:1915` — `isValidOurSide()` widens
|
||||
its allowlist:
|
||||
|
||||
```go
|
||||
case "", "claimant", "defendant",
|
||||
"applicant", "appellant",
|
||||
"respondent",
|
||||
"third_party", "other":
|
||||
return nil
|
||||
```
|
||||
|
||||
`internal/services/project_service.go:1372` —
|
||||
`derivedCounterclaimOurSide()` (CCR flip logic): widen the flip map to
|
||||
mirror the Determinator grouping:
|
||||
|
||||
- claimant ↔ defendant (current behaviour)
|
||||
- applicant ↔ respondent
|
||||
- appellant → defendant (CCR against an appellant is rare; pick
|
||||
the most-likely procedural posture; can be overridden by
|
||||
explicit `flip_our_side=false`)
|
||||
- third_party / other / NULL → keep as-is (no flip)
|
||||
|
||||
`internal/services/submission_vars.go:391-418` — `ourSideDE` /
|
||||
`ourSideEN` switch arms add the five new values per the table in
|
||||
§2.2 Q6. `'court'` and `'both'` arms get deleted.
|
||||
|
||||
`internal/services/project_service.go:1083-1104` — `our_side_changed`
|
||||
audit emission unchanged (it just records old → new on the column).
|
||||
|
||||
`frontend/build.ts` — no change; bundling already picks up
|
||||
`projects.field.client_role.*` i18n keys via `i18n-keys.ts` regeneration.
|
||||
|
||||
`frontend/src/i18n-keys.ts` — regenerate via existing scripted path
|
||||
(adds the new keys, keeps the legacy ones as deprecated entries until
|
||||
the housekeeping pass).
|
||||
|
||||
### §2.6 Tests
|
||||
|
||||
- `internal/services/submission_render_test.go:275` —
|
||||
`TestOurSideTranslations` widens the table to cover the 7 new values
|
||||
in both DE and EN.
|
||||
- `internal/services/projection_service_unit_test.go:319` —
|
||||
`TestDerivedCounterclaimOurSide` widens to cover the new flip map.
|
||||
- New: `TestProjectFormHidesOurSideForNonCase` — unit test on the
|
||||
project-form payload reader confirms `our_side` is silently dropped
|
||||
when the form renders for a non-case project type.
|
||||
|
||||
### §2.7 Acceptance (issue #47)
|
||||
|
||||
- [x] Creating a project of `type='client'`, `'litigation'`, `'patent'`,
|
||||
`'project'` does **not** show the field.
|
||||
- [x] Creating a project of `type='case'` shows the field labelled
|
||||
"Mandantenrolle" (DE) / "Client Role" (EN) with three optgroups
|
||||
and seven options.
|
||||
- [x] Existing `'court'` / `'both'` rows (none in prod, but defensive)
|
||||
are migrated to NULL.
|
||||
- [x] Submission templates referencing `{{project.our_side_de}}` /
|
||||
`_en` render coherent prose for the five new values.
|
||||
- [x] Determinator perspective chip pre-fills correctly from each
|
||||
sub-role (Active→claimant, Reactive→defendant, Other→null).
|
||||
- [x] CCR counterclaim flip yields a sensible child role for the new
|
||||
sub-roles.
|
||||
- [x] `go build && go test ./internal/... && cd frontend && bun run
|
||||
build` clean.
|
||||
|
||||
---
|
||||
|
||||
## §3 Issue #50 — Auto-derived project codes
|
||||
|
||||
### §3.1 Current state (verified 2026-05-20)
|
||||
|
||||
- `paliad.projects.reference text` exists and is informally used (live
|
||||
values: `EXMPL` on a client, `L-2026-001` on a litigation, `C-UPC-0001`
|
||||
on a case, `P-EP1111222` on a patent). No format enforcement.
|
||||
- `paliad.projects.path ltree` is maintained by a Postgres trigger
|
||||
(`projects.path` joined UUIDs root-to-self). Walking ancestors in Go
|
||||
is straightforward: `SELECT * FROM paliad.projects WHERE path @>
|
||||
$1::ltree ORDER BY nlevel(path)`.
|
||||
- No `opponent` field exists anywhere. Opponent text lives only inside
|
||||
the litigation `title` (e.g. "Siemens AG ./. Huawei Technologies").
|
||||
- `paliad.proceeding_types.code` is dot-separated:
|
||||
`upc.inf.cfi`, `upc.rev.cfi`, `de.inf.lg`, `upc.apl.merits`, etc.
|
||||
Splitting on `.` and upper-casing yields `INF`, `REV`, `LG`,
|
||||
`APL.MERITS`. Suitable as the case segment.
|
||||
- `paliad.projects.court text` is free-text on cases (live values:
|
||||
`UPC`, `UPC CoA`, `LG München I`). Not normalised; use the
|
||||
proceeding_type code instead — it carries the same info structurally.
|
||||
|
||||
### §3.2 Decisions
|
||||
|
||||
**Q1 — Litigation opponent source: new column or regex on title?**
|
||||
**Pick: new column `paliad.projects.opponent_code text` on litigation
|
||||
rows.** Regex on title is brittle ("./.", "v.", "vs", "—", varying
|
||||
order) and the user already knows the short code at creation time. New
|
||||
field with explicit validation (slug-cased, max 16 chars) is clean and
|
||||
takes one form field + one migration. Title stays as the human-readable
|
||||
caption; `opponent_code` is the machine-readable segment source.
|
||||
NULL → segment skipped silently.
|
||||
|
||||
**Q2 — Patent segment: always last 3, or last-N variable?**
|
||||
**Pick: last 3 digits when the digit-stream is ≥ 4 digits long; full
|
||||
digit-stream when shorter.** m's example (`EP3456789 → 789`) is 7
|
||||
digits last-3 = 789 ✓. UPC publication numbers (10+ digits) collapse to
|
||||
their last 3 just fine — uniqueness inside the same litigation tree is
|
||||
near-certain because the same litigation tree won't hold two patents
|
||||
sharing the same last-3. If it ever does, the user can set a custom
|
||||
`reference` (Q5). No need for last-4 / last-N logic.
|
||||
|
||||
The patent-number regex extracts the digit-stream from any common
|
||||
format (`EP1234567`, `EP 1 234 567`, `EP1234567A1`, `WO2020/123456A1`):
|
||||
strip non-digits, take last 3 (or whole if shorter), upper-cased.
|
||||
|
||||
**Q3 — Case segment from `proceeding_types.code`?**
|
||||
**Pick: take `proceeding_types.code` (e.g. `upc.inf.cfi`), split on `.`,
|
||||
drop the leading jurisdiction segment, uppercase the rest, join with
|
||||
`.`.** Examples:
|
||||
|
||||
- `upc.inf.cfi` → `INF.CFI`
|
||||
- `upc.rev.cfi` → `REV.CFI`
|
||||
- `upc.pi.cfi` → `PI.CFI`
|
||||
- `upc.apl.merits` → `APL.MERITS`
|
||||
- `de.inf.lg` → `INF.LG`
|
||||
- `de.inf.olg` → `INF.OLG` (appeal instance → segment already
|
||||
encodes "OLG", so we get the appeal level for free; no separate
|
||||
instance segment needed)
|
||||
|
||||
The jurisdiction is dropped because the parent client/patent already
|
||||
implies the jurisdiction context. If the user wants explicit
|
||||
jurisdiction in the code, custom `reference` wins.
|
||||
|
||||
If `proceeding_type_id` is NULL on the case, segment is omitted
|
||||
silently. No fallback to `court` text — that's free-text and noisy.
|
||||
|
||||
**Q4 — Override semantics: wholesale or per-segment?**
|
||||
**Pick: wholesale.** When `paliad.projects.reference` is non-empty on
|
||||
the project the helper is asked about, that string is returned
|
||||
verbatim — no auto-derivation, no string-concatenation, no merging.
|
||||
Per-segment override doubles the implementation complexity for a UX
|
||||
nobody asked for. Users who want partial overrides set the
|
||||
`reference` on the relevant ancestor and let the rest auto-derive
|
||||
naturally.
|
||||
|
||||
**Q5 — Where the user types the override?**
|
||||
**Pick: existing `paliad.projects.reference` field.** Already there,
|
||||
already labelled "Interne Referenz (optional)", already used by users.
|
||||
Adding a second "project_code_override" alongside `reference` would
|
||||
confuse the form. The hint text gets a small addendum: "Leer lassen
|
||||
für automatischen Code aus dem Projekt-Baum."
|
||||
|
||||
**Q6 — Collision handling (two cases derive to the same code)?**
|
||||
**Pick: advisory in v1; no disambiguator.** Codes are display-only
|
||||
(not a primary key, not a unique constraint). Real-world collisions
|
||||
inside the same litigation tree are vanishingly rare; if they happen,
|
||||
the user notices in the picker and sets a custom `reference` on one.
|
||||
Adding `-N` suffixes silently would mask a data issue the user should
|
||||
see. A future surface could flag duplicates as a project-detail warning,
|
||||
but it's not in v1.
|
||||
|
||||
**Q7 (new) — Helper signature and call site?**
|
||||
**Pick: `ProjectService.BuildProjectCode(ctx context.Context, projectID
|
||||
uuid.UUID) (string, error)`.** Lives on the existing ProjectService
|
||||
(it needs DB access for the ancestor walk). Internally builds segments
|
||||
with a small `projectCodeSegment(p Project) string` pure function per
|
||||
type that's table-test-friendly. The helper is called from the
|
||||
projection layer when a project gets serialised for the API
|
||||
(adds a `code` field to the JSON), so every surface — header,
|
||||
breadcrumb, picker, dashboard tile, Excel export — gets the code for
|
||||
free without each surface re-walking the tree. Pricier than a
|
||||
display-time call but eliminates N+1 walks in list views.
|
||||
|
||||
**Q8 (new) — Cache strategy?**
|
||||
**Pick: no cache in v1.** Each ancestor walk is one indexed lookup
|
||||
on `paliad.projects(path)`. With 12 projects in prod and order-of-100s
|
||||
in any plausible firm-scale future, this is microsecond-cheap. If
|
||||
profiling later shows it as a hotspot in list views (which fetch many
|
||||
projects), introduce a materialised view
|
||||
`paliad.projects_derived_codes(project_id, derived_code)` refreshed by
|
||||
trigger on `projects` writes. Don't pre-optimise.
|
||||
|
||||
### §3.3 Migration `113_projects_opponent_code`
|
||||
|
||||
```sql
|
||||
-- 113_projects_opponent_code.up.sql (renumbered 2026-05-20)
|
||||
-- t-paliad-222 / m/paliad#50.
|
||||
-- Add an opponent-code field on litigation projects. Used as the
|
||||
-- middle segment when assembling auto-derived project codes from the
|
||||
-- ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI). NULL = segment is
|
||||
-- skipped silently. No backfill — existing litigation rows simply
|
||||
-- yield codes without an opponent segment until the user sets one.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS opponent_code text;
|
||||
|
||||
-- Slug-shape gate: uppercase letters, digits, dashes, max 16 chars.
|
||||
-- Matches the style of m's example "OPNT". Keeps the auto-code clean.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'projects_opponent_code_check'
|
||||
AND conrelid = 'paliad.projects'::regclass
|
||||
) THEN
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_opponent_code_check
|
||||
CHECK (opponent_code IS NULL
|
||||
OR (opponent_code ~ '^[A-Z0-9-]{1,16}$'
|
||||
AND type = 'litigation'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.opponent_code IS
|
||||
'Short slug for the opposing party on a litigation project '
|
||||
'(uppercase letters, digits, dashes, max 16 chars). Used as the '
|
||||
'middle segment when BuildProjectCode walks the ancestor tree to '
|
||||
'assemble a dotted project code (t-paliad-222 / m/paliad#50). '
|
||||
'NULL = segment skipped silently.';
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
The down migration drops the constraint then the column.
|
||||
|
||||
### §3.4 Go helper
|
||||
|
||||
New file `internal/services/project_code.go`:
|
||||
|
||||
```go
|
||||
// Package-level function (not a method) so it can be called from any
|
||||
// service that already has a *sqlx.DB. ProjectService has a thin
|
||||
// wrapper that calls into this.
|
||||
//
|
||||
// BuildProjectCode assembles the dotted ancestor code for projectID
|
||||
// from the existing paliad.projects.path ltree. If the target row's
|
||||
// reference column is non-empty, it wins outright (no derivation).
|
||||
// Missing ancestor segments are skipped silently — there is no
|
||||
// "unknown" placeholder.
|
||||
func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uuid.UUID) (string, error)
|
||||
|
||||
// projectCodeSegment is the per-type segment derivation. Pure, table-
|
||||
// test friendly, never touches the DB.
|
||||
//
|
||||
// client → opts.PreferShortReference (reference if set, else slug(title))
|
||||
// litigation → opts.PreferShortReference (opponent_code if set, else "")
|
||||
// patent → last 3 digits of patent_number (full digits if <4)
|
||||
// case → uppercase tail of proceeding_types.code (jurisdiction segment dropped)
|
||||
// project → "" (generic projects don't contribute a segment)
|
||||
//
|
||||
// proceedingCode is only needed for case rows; the caller resolves
|
||||
// it via a single join (or a cached small lookup) before calling.
|
||||
func projectCodeSegment(p models.Project, proceedingCode string) string
|
||||
```
|
||||
|
||||
Sanitisation helpers live alongside as unexported funcs:
|
||||
|
||||
- `sanitizeClientShort(s string) string` — uppercase, strip diacritics
|
||||
via `golang.org/x/text/unicode/norm` + filter, replace non-alnum
|
||||
with `-`, trim, cap at 8 chars. Already similar to what
|
||||
`internal/util/slug` does for the global slug helper.
|
||||
- `patentLast3(s string) string` — strip non-digits, take last 3
|
||||
characters (or the whole digit-stream when shorter); uppercase.
|
||||
Empty → "".
|
||||
- `proceedingTail(code string) string` — split on `.`, drop element 0
|
||||
(jurisdiction), uppercase + join the rest. `""` → `""`.
|
||||
|
||||
`BuildProjectCode` SQL is a single round-trip:
|
||||
|
||||
```sql
|
||||
SELECT p.id, p.type, p.title, p.reference, p.opponent_code,
|
||||
p.patent_number, p.proceeding_type_id,
|
||||
pt.code AS proceeding_code
|
||||
FROM paliad.projects p
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
WHERE p.path @> (SELECT path FROM paliad.projects WHERE id = $1)
|
||||
ORDER BY nlevel(p.path);
|
||||
```
|
||||
|
||||
It returns the chain root-to-target. The function:
|
||||
|
||||
1. If the last row (the target) has non-empty `reference` → return it
|
||||
verbatim. Done.
|
||||
2. Otherwise walk the chain top-to-bottom, call `projectCodeSegment`
|
||||
on each row, skip empty segments, join with `.`, return.
|
||||
|
||||
### §3.5 Wiring into surfaces
|
||||
|
||||
- `internal/services/project_service.go` projection — add a `Code`
|
||||
string field to the read-side struct and populate it in the single
|
||||
fetch path. For list endpoints, do **one** ancestor-chain query per
|
||||
page (CTE that groups by target id) rather than N+1.
|
||||
- `internal/services/submission_vars.go:277` — add
|
||||
`bag["project.code"] = derefString(p.Code)` so submission templates
|
||||
can reference `{{project.code}}`.
|
||||
- `frontend/src/components/ProjectHeader.tsx` (current header
|
||||
component on `/projects/{id}`) — render `code` next to the title
|
||||
(small monospace badge) if non-empty.
|
||||
- `frontend/src/components/Breadcrumb*.tsx` — when rendering the
|
||||
trail, use `project.code` as the trailing badge per segment if the
|
||||
caller asks for it (opt-in to avoid breaking other consumers).
|
||||
- `frontend/src/client/project-form.ts` and any project-picker
|
||||
typeahead — show `code · title` in the dropdown labels when `code`
|
||||
is non-empty.
|
||||
- Excel `__meta` sheet — add a `Project Code` row (already enumerates
|
||||
project metadata).
|
||||
|
||||
The "copy reference" affordance in the header gets a second line: if
|
||||
both `reference` (user override) and the auto-derived code differ, both
|
||||
are visible (override above, derived below, smaller).
|
||||
|
||||
### §3.6 Tests
|
||||
|
||||
- `TestProjectCodeSegment` (table) — every project type × multiple
|
||||
shapes (with/without reference, NULL ancestors, patent_number
|
||||
formats, proceeding codes with 1/2/3 segments).
|
||||
- `TestBuildProjectCodeFullChain` — fixture tree
|
||||
Client → Litigation → Patent → Case yields `EXMPL.OPNT.567.INF.CFI`.
|
||||
- `TestBuildProjectCodeRespectsOverride` — non-empty `reference` wins
|
||||
outright.
|
||||
- `TestBuildProjectCodeMissingAncestors` — case directly under client
|
||||
(no litigation, no patent) yields `EXMPL.INF.CFI`.
|
||||
- `TestBuildProjectCodeCollisionDoesNotDisambiguate` — two sibling
|
||||
cases with identical derived codes both return the same string (v1
|
||||
contract per Q6).
|
||||
- Migration sanity test (existing harness in
|
||||
`internal/db/migrations_test.go` if present) — up → down → up.
|
||||
|
||||
### §3.7 Acceptance (issue #50)
|
||||
|
||||
- [x] `BuildProjectCode` returns `EXMPL.OPNT.567.INF.CFI` for the
|
||||
reference tree (Client EXMPL → Litigation OPNT → Patent
|
||||
EP1234567 → Case `upc.inf.cfi`).
|
||||
- [x] Setting `projects.reference = 'CUSTOM-CODE'` on the case
|
||||
returns `CUSTOM-CODE` verbatim.
|
||||
- [x] Missing ancestor segments are skipped silently
|
||||
(no `..` collapses, no "?" placeholder).
|
||||
- [x] `{{project.code}}` resolves in submission templates.
|
||||
- [x] Project header, breadcrumb, picker, Excel `__meta` all show the
|
||||
code when set/derived.
|
||||
- [x] Litigation form has a new "Opponent Code" field (DE:
|
||||
"Gegner-Kürzel") with the slug pattern validation. Hidden on
|
||||
non-litigation types.
|
||||
- [x] `go build && go test ./internal/... && cd frontend && bun run
|
||||
build` clean.
|
||||
|
||||
---
|
||||
|
||||
## §4 Open questions for the head
|
||||
|
||||
(Head: default to the §2.2 / §3.2 "Pick" recommendations unless something
|
||||
material pushes back. Coder shift only after head signs off.)
|
||||
|
||||
1. **§2.2 Q1** — Keep column name `our_side`? (Recommend YES; rename
|
||||
touches 11+ Go files + bundled-template wire format for zero gain.)
|
||||
2. **§2.2 Q2** — Store 7 sub-roles? (Recommend YES; group-only is
|
||||
lossy.)
|
||||
3. **§2.2 Q3** — Hide the field on `litigation` and `patent` too, not
|
||||
just on `client`? (Recommend YES per m's "only on case projects".)
|
||||
4. **§2.2 Q6** — German prose forms use feminine grammatical gender
|
||||
(Klägerin, Beklagte) per the existing translation table? Or
|
||||
masculine / neutral? (Recommend feminine to match existing
|
||||
`ourSideDE` — keeps consistency with already-rendered templates.)
|
||||
5. **§3.2 Q1** — Add a dedicated `opponent_code` column on
|
||||
litigations? (Recommend YES; regex-on-title is brittle.)
|
||||
6. **§3.2 Q2** — Patent segment = last 3 digits (variable for
|
||||
<4-digit numbers)? (Recommend YES, matches m's example.)
|
||||
7. **§3.2 Q3** — Case segment drops the jurisdiction prefix from
|
||||
`proceeding_types.code` (so `upc.inf.cfi` → `INF.CFI`, not
|
||||
`UPC.INF.CFI`)? (Recommend YES — jurisdiction is implied by the
|
||||
ancestor client/patent context.)
|
||||
8. **§3.2 Q7** — `BuildProjectCode` populates a `code` field on every
|
||||
projected Project JSON (not lazy per-render)? (Recommend YES;
|
||||
simpler consumers, one DB round-trip per list page.)
|
||||
9. **§3.2 Q8** — No cache / materialised view in v1? (Recommend YES;
|
||||
profile later if list views get slow.)
|
||||
|
||||
---
|
||||
|
||||
## §5 Implementation order (coder phase)
|
||||
|
||||
1. **Mig 112** (client role widen + backfill) → mig 113 (opponent_code).
|
||||
*Renumbered twice on 2026-05-20 — mig 110 claimed by m/paliad#51 project_type_other; mig 111 claimed by m/paliad#48 project_admin_and_select; boltzmann's gap-tolerant runner hard-fails on collisions so this is a strict rebump.*
|
||||
Run `ls internal/db/migrations/ | tail` first to verify slot
|
||||
availability (boltzmann's gap-tolerant runner means 110 is fine
|
||||
even if 109 was the last applied).
|
||||
2. **Backend** — `isValidOurSide`, `ourSideDE/EN`,
|
||||
`derivedCounterclaimOurSide`, new `project_code.go` package
|
||||
+ ProjectService wiring + projection `Code` field.
|
||||
3. **Frontend** — `ProjectFormFields.tsx` (conditional render + new
|
||||
options + opponent_code field on litigation block), `i18n.ts` keys,
|
||||
`fristenrechner.ts` `ourSideToPerspective` widen, header /
|
||||
breadcrumb / picker code-badge wiring.
|
||||
4. **Tests** — pinning tests above; `go test ./internal/...` clean.
|
||||
5. **Build verification** — `go build && cd frontend && bun run build`
|
||||
clean.
|
||||
6. **Commit per slice** — three commits (migration + backend, frontend,
|
||||
tests) keep review tractable.
|
||||
|
||||
---
|
||||
|
||||
## §6 Risks & rollback
|
||||
|
||||
- **Submission templates in the wild.** Users may have downloaded /
|
||||
customised submission templates that still reference
|
||||
`{{project.our_side_de}}` for `our_side='court'` or `'both'`. After
|
||||
this change those values are unreachable, so the template arm
|
||||
returns `""`. Already the fallback behaviour for unknown values;
|
||||
no breakage, just an empty render. Mention in release notes.
|
||||
- **Browser cache.** Users with a stale bundle still see the old
|
||||
"Wir vertreten" form for one cache-bust cycle. The legacy i18n keys
|
||||
stay until housekeeping (§2.4), so labels still resolve.
|
||||
- **Migration down path.** Stepping down from 110 restores the old
|
||||
4-value CHECK; new sub-role rows would violate it. The down
|
||||
migration backfills new sub-roles → NULL to stay consistent.
|
||||
- **Per-tree opponent_code uniqueness.** Two litigations under the
|
||||
same client with the same `opponent_code` would derive identical
|
||||
case codes. Per Q6 we accept this; users see it in the picker and
|
||||
customise `reference` if it bothers them.
|
||||
- **No new env vars, no Dokploy compose change** — both changes are
|
||||
pure code + schema; deploy is the existing main-push → webhook →
|
||||
Dokploy auto-redeploy path.
|
||||
784
docs/design-submission-generator-2026-05-19.md
Normal file
784
docs/design-submission-generator-2026-05-19.md
Normal file
@@ -0,0 +1,784 @@
|
||||
# Design — Submission generator (t-paliad-215)
|
||||
|
||||
**Author:** copernicus (inventor)
|
||||
**Date:** 2026-05-19
|
||||
**Issue:** m/paliad (task t-paliad-215, no Gitea issue filed yet)
|
||||
**Branch:** `mai/copernicus/inventor-submission`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Each row in `paliad.deadline_rules` represents a SUBMISSION — a filing,
|
||||
hearing, or decision inside a proceeding (`submission_code` shape
|
||||
`de.inf.lg.erwidg`, `upc.inf.cfi.soc`, …). The submission generator
|
||||
takes a project + a submission_code, pulls a `.docx` template from
|
||||
Gitea, merges in project variables (party names, court, case number,
|
||||
patent number, our_side, deadline date, legal_source citation, firm
|
||||
header), and streams the result to the browser as a download.
|
||||
|
||||
- **Scope (locked by m):** template-render to `.docx`. No LLM in v1.
|
||||
- **Template registry (locked):** Gitea — same proxy pattern as the
|
||||
existing HL Patents Style `.dotm` in `internal/handlers/files.go`.
|
||||
- **Output (locked):** direct download, NO server-side binary
|
||||
persistence. One audit row per generation; the bytes themselves are
|
||||
regenerable from inputs on demand.
|
||||
- **Lookup (locked):** fallback chain — firm-specific override →
|
||||
base for the exact `submission_code` → generic for the proceeding
|
||||
family → ultra-generic skeleton.
|
||||
- **Slice 1 (locked):** one template, end-to-end, on one project.
|
||||
Pick `de.inf.lg.erwidg` (Klageerwiderung) as the proof template.
|
||||
- **AI-drafted body:** explicitly OUT of scope for this task. Lives
|
||||
in §11 as a follow-up sketch only.
|
||||
|
||||
This design is read-only. No code, no migrations, no schema
|
||||
additions. Implementation gate is m's go/no-go on this doc.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-19)
|
||||
|
||||
Anchored against the running paliad codebase + youpc Supabase, not
|
||||
against CLAUDE.md or memory. Where a claim load-bears the design, it
|
||||
was checked against the live system.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| Migration tracker at **102** (next is 103) | `ls internal/db/migrations/` — `102_system_audit_log` is the latest applied. |
|
||||
| `paliad.documents` table exists, is empty, no code writes to it yet | `SELECT COUNT(*) FROM paliad.documents` → 0 rows. Columns: `id, title, doc_type, file_path NULLABLE, file_size, mime_type, ai_extracted jsonb, uploaded_by, created_at, updated_at, project_id NOT NULL`. `grep` shows only `export_service.go` (audit-export only) and a comment in `render_spec.go`. No `document_service.go`, no `/api/documents` handler. |
|
||||
| `paliad.deadline_rules` carries the submission corpus | 254 total rows, 158 unique `submission_code`s, 214 `published`. Per-row fields used by the generator: `name`, `name_en`, `submission_code`, `primary_party` (claimant/defendant/court/both), `event_type` (filing/hearing/decision), `legal_source` (e.g. `DE.ZPO.276.1`, `UPC.RoP.23.1`), `is_bilateral`. |
|
||||
| Slice 1 target row exists in published state | `SELECT … WHERE submission_code='de.inf.lg.erwidg'` → `{name:"Klageerwiderung", name_en:"Statement of Defence", primary_party:"defendant", legal_source:"DE.ZPO.276.1"}`. |
|
||||
| Project rows carry all variables we need to merge | `paliad.projects` has `case_number, court, patent_number, filing_date, grant_date, our_side, instance_level, proceeding_type_id, title, reference, client_number, matter_number`. |
|
||||
| Party rows carry party variables | `paliad.parties` has `name, role, representative, contact_info jsonb` and is project-scoped via `project_id`. |
|
||||
| The HL Patents Style proxy pattern is reusable | `internal/handlers/files.go`: `fileRegistry` map → Gitea raw URL + SHA-based cache + 5-min refresh check + binary download response with `Content-Disposition`. Cache is in-process (`sync.Mutex` over a `map[string]*cacheEntry`). Single web replica today (`docker-compose.yml`), so in-process cache is fine. |
|
||||
| Email templates already use `{{.VarName}}` placeholders + a "variable contract" sidebar pattern | `internal/services/email_template_variables.go` — `EmailTemplateVariable{Name, Type, Description, SampleDE, SampleEN}` rendered in `/admin/email-templates`. Submission generator can copy this contract pattern. |
|
||||
| Audit infrastructure landed in mig 102 | `paliad.system_audit_log(id, event_type, actor_id, actor_email, scope, scope_root, metadata jsonb, created_at, updated_at)` — submission_generated events slot straight in. |
|
||||
| Branding source is `internal/branding.Name` | Default `"HLC"`, overridable via `FIRM_NAME`. Inlined into client bundles by `frontend/build.ts`. Submission templates honour this via the `{{firm.name}}` placeholder. |
|
||||
| `paliad.can_see_project(project_id)` is the canonical visibility predicate | mig 055; `internal/services/visibility.go` mirrors it. Generator gates on this; no new auth surface. |
|
||||
| Paliadin runs on the aichat backend (mRiver) with persona system | `internal/services/aichat_paliadin.go` + `personas.yaml` in `m/mAi/internal/aichat/persona/`. Owner-gated to `PaliadinOwnerEmail = matthias.siebels@hoganlovells.com`. A future AI-drafted body would be a new persona, not a new Go service. |
|
||||
|
||||
**Doc-vs-live conflicts found:** none material for this design.
|
||||
`docs/project-status.md` still lists "Phase H AI Frist-Extraktion
|
||||
deferred" — this design does NOT revive Phase H (different surface;
|
||||
this is template merge, not document understanding).
|
||||
|
||||
---
|
||||
|
||||
## §2 m's decisions (2026-05-19)
|
||||
|
||||
Locked via AskUserQuestion before drafting the rest of the design.
|
||||
|
||||
| # | Question | m's pick | Inventor recommended? |
|
||||
|---|---|---|---|
|
||||
| Q1 | Generator scope (template / AI-draft / brief / other) | **Template-render to `.docx`** | ✅ yes |
|
||||
| Q2 | Template registry (Gitea / paliad DB / hybrid) | **Gitea** | ✅ yes |
|
||||
| Q3 | Output flow (download-only / persist binary / attach to Frist) | **Direct download, no server-side binary** | ✅ yes |
|
||||
| Q4 | Mapping (fallback chain / 1:1 / 1:N user picks) | **Fallback chain** | ✅ yes |
|
||||
| Q5 | Slice 1 scope (1 template / 3–5 templates / full corpus / skeleton-only) | **One template, end-to-end on one project** (`de.inf.lg.erwidg` Klageerwiderung) | ✅ yes |
|
||||
|
||||
Inventor-defaulted (not asked because there's a clear right answer or
|
||||
because the question is implementation-level, not architecture-level):
|
||||
|
||||
| # | Topic | Default | Reasoning |
|
||||
|---|---|---|---|
|
||||
| D1 | Variable engine | `{{path.dot.notation}}` placeholders in the .docx body, replaced via a Go library that handles run-fragmentation | Matches the existing email-template `{{.Var}}` shape lawyers already see in `/admin/email-templates`. See §6. |
|
||||
| D2 | Authorization | Project-team visibility only (`paliad.can_see_project`) + audit row | Matches every other write surface in paliad. No profession floor (generation is read-only on source data and produces a draft, not a binding action). |
|
||||
| D3 | Naming convention | `{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx`, slashes → underscores, FIRM_NAME-aware | Mirrors how lawyers name files manually. See §7. |
|
||||
| D4 | Missing-variable behaviour | Render `[KEIN WERT: {field}]` / `[NO VALUE: {field}]` marker inline | Lets the lawyer see the gap in Word, fix in paliad, regenerate. Better than 400ing. |
|
||||
| D5 | Editor surface | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo) | A paliad uploader UI is Phase 2 affordance if Gitea round-trip is painful. |
|
||||
| D6 | AI-drafted body | OUT of scope for this task | §11 sketches the natural follow-up shape (new aichat persona) but does not commit to it. |
|
||||
|
||||
---
|
||||
|
||||
## §3 Architecture overview
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Project detail page │
|
||||
│ ├─ "Submissions" panel (or button row) │
|
||||
│ │ [Generate Klageerwiderung] [Generate Klageerhebung] [...] │
|
||||
│ │ Each button enabled iff a template exists for that │
|
||||
│ │ submission_code AND user passes paliad.can_see_project. │
|
||||
│ └─ Click → POST /api/projects/{id}/submissions/{code}/generate │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ handlers/submissions.go (NEW) │
|
||||
│ 1. Auth: UserIDFromContext + can_see_project gate │
|
||||
│ 2. Load deadline_rule by submission_code │
|
||||
│ 3. Resolve template via fallback chain (TemplateRegistry) │
|
||||
│ 4. Build variable bag (services/submission_vars.go) │
|
||||
│ 5. Render via SubmissionRenderer (services/submission_render.go) │
|
||||
│ 6. Write paliad.documents audit row (NO file_path) │
|
||||
│ 7. Write paliad.system_audit_log entry (event_type= │
|
||||
│ 'submission.generated') │
|
||||
│ 8. Stream .docx bytes with Content-Disposition: attachment │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│ (template fetch)
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ TemplateRegistry (services/submission_templates.go) — NEW │
|
||||
│ • In-process cache (same shape as handlers/files.go cacheEntry) │
|
||||
│ • Lookup path: │
|
||||
│ (1) templates/{FIRM_NAME}/{submission_code}.docx │
|
||||
│ (2) templates/_base/{submission_code}.docx │
|
||||
│ (3) templates/_base/{proceeding_family}.docx (e.g. upc.inf.cfi) │
|
||||
│ (4) templates/_base/_skeleton.docx │
|
||||
│ • Fetched from m/mWorkRepo via Gitea raw URL │
|
||||
│ • 5-min SHA refresh check (identical pattern to files.go) │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Gitea: m/mWorkRepo
|
||||
templates/HLC/de.inf.lg.erwidg.docx
|
||||
templates/_base/de.inf.lg.erwidg.docx
|
||||
templates/_base/de.inf.lg.docx
|
||||
templates/_base/_skeleton.docx
|
||||
```
|
||||
|
||||
**No new tables.** `paliad.documents` already exists; we write audit
|
||||
rows there but leave `file_path` NULL. The fallback chain uses
|
||||
filesystem-style paths inside the existing Gitea repo; no
|
||||
`submission_templates` table needed for Slice 1.
|
||||
|
||||
---
|
||||
|
||||
## §4 Slice 1 — what ships first
|
||||
|
||||
Locked by Q5: **one template, end-to-end, on one project.**
|
||||
|
||||
### 4.1 Target submission
|
||||
|
||||
**`de.inf.lg.erwidg`** — Klageerwiderung (DE Verletzungs-LG).
|
||||
Reasoning:
|
||||
|
||||
- High-frequency submission in patent practice; lawyers draft these
|
||||
often enough that the tool earns its keep on day 1.
|
||||
- `primary_party='defendant'` — exercises the our_side variable.
|
||||
- `legal_source='DE.ZPO.276.1'` — exercises citation injection.
|
||||
- Pure-DE (no UPC complexity); easier first template for HLC's
|
||||
Munich/Düsseldorf practice to author and review.
|
||||
- Klageerhebung (`de.inf.lg.klage`) is an obvious alternative; either
|
||||
works. m can flip the target in his decision review if Klageerhebung
|
||||
is the better proof case.
|
||||
|
||||
### 4.2 Surfaces in Slice 1
|
||||
|
||||
- **Project detail page** — new "Submissions" panel listing every
|
||||
submission_code from the project's `proceeding_type` (via existing
|
||||
`DeadlineRuleService`) with a `[Generieren]` button per row. Button
|
||||
is enabled iff a template resolves AND `event_type='filing'` (no
|
||||
`[Generieren]` on hearings/decisions — those don't have submissions).
|
||||
- **Project detail API** — `GET /api/projects/{id}/submissions` returns
|
||||
the list of (submission_code, name, has_template) so the frontend
|
||||
can render enabled/disabled state.
|
||||
- **Generate endpoint** — `POST /api/projects/{id}/submissions/{code}/generate`
|
||||
returns `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
|
||||
with `Content-Disposition: attachment; filename="..."`.
|
||||
|
||||
Slice 1 does NOT add:
|
||||
|
||||
- A `/admin/submission-templates` editor (Gitea is the editor).
|
||||
- A Frist-detail "Generate" button (project-detail only in Slice 1;
|
||||
Frist-level surface is a Slice 2 affordance).
|
||||
- A "Submissions" tab as a dedicated page (project-detail panel only).
|
||||
- Per-firm overrides beyond `templates/HLC/...` (the fallback chain is
|
||||
WIRED but the only override directory exercised in Slice 1 is HLC).
|
||||
- The variable-contract sidebar UI (mirrors email-template editor) —
|
||||
the contract is documented in §6 as code constants, surfaced as a
|
||||
Slice 2 admin affordance.
|
||||
|
||||
### 4.3 Slice 1 LoC estimate (informational, no time estimate)
|
||||
|
||||
| File | Approx |
|
||||
|---|---|
|
||||
| `internal/handlers/submissions.go` (NEW) | 180 |
|
||||
| `internal/services/submission_templates.go` (NEW — registry + Gitea proxy, reuses files.go cache idea) | 200 |
|
||||
| `internal/services/submission_vars.go` (NEW — variable bag builder) | 220 |
|
||||
| `internal/services/submission_render.go` (NEW — docx merge engine wrapper) | 120 |
|
||||
| `internal/services/submission_render_test.go` (placeholder coverage + missing-var marker) | 180 |
|
||||
| `frontend/src/components/SubmissionsPanel.tsx` (NEW) | 80 |
|
||||
| `frontend/src/client/submissions.ts` (NEW — fetch + download) | 60 |
|
||||
| Wiring in `cmd/server/main.go` + `internal/handlers/handlers.go` | 30 |
|
||||
| i18n keys (`submissions.*`) DE+EN | 20 |
|
||||
| **Total** | **~1090 LoC** |
|
||||
|
||||
Plus: ONE `.docx` template authored by HLC at
|
||||
`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`, lawyer-reviewed
|
||||
before Slice 1 closes.
|
||||
|
||||
---
|
||||
|
||||
## §5 Template registry (Gitea-backed)
|
||||
|
||||
### 5.1 Gitea layout
|
||||
|
||||
```
|
||||
m/mWorkRepo (existing repo)
|
||||
└── templates/
|
||||
├── HLC/ # FIRM_NAME-keyed override dir
|
||||
│ └── de.inf.lg.erwidg.docx # Slice 1 ships THIS file
|
||||
├── _base/ # Cross-firm baseline
|
||||
│ ├── de.inf.lg.erwidg.docx # (Phase 2+)
|
||||
│ ├── de.inf.lg.docx # proceeding-family fallback
|
||||
│ ├── upc.inf.cfi.docx # (Phase 2+)
|
||||
│ └── _skeleton.docx # ultra-generic fallback
|
||||
└── README.md # placeholder reference for authors
|
||||
```
|
||||
|
||||
Naming convention is the submission_code with a `.docx` suffix.
|
||||
Proceeding-family fallback is the submission_code's first two
|
||||
dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`).
|
||||
|
||||
### 5.2 Lookup algorithm
|
||||
|
||||
```go
|
||||
// services/submission_templates.go
|
||||
func (r *TemplateRegistry) Resolve(ctx context.Context, code string) (Template, error) {
|
||||
firm := branding.Name // "HLC", or whatever FIRM_NAME is
|
||||
family := familyOf(code) // "de.inf.lg" from "de.inf.lg.erwidg"
|
||||
candidates := []string{
|
||||
fmt.Sprintf("templates/%s/%s.docx", firm, code),
|
||||
fmt.Sprintf("templates/_base/%s.docx", code),
|
||||
fmt.Sprintf("templates/_base/%s.docx", family),
|
||||
"templates/_base/_skeleton.docx",
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if tmpl, ok := r.fetch(ctx, path); ok {
|
||||
return tmpl, nil
|
||||
}
|
||||
}
|
||||
return Template{}, ErrNoTemplate
|
||||
}
|
||||
```
|
||||
|
||||
`fetch` does the same SHA-cache dance `handlers/files.go` already
|
||||
does, scoped to the templates subtree.
|
||||
|
||||
### 5.3 Gitea auth
|
||||
|
||||
Reuses `GITEA_TOKEN` env var that already exists for the HL Patents
|
||||
Style proxy. `m/mWorkRepo` is the same repo, same access token. No
|
||||
new secret to configure.
|
||||
|
||||
### 5.4 What happens when no template resolves
|
||||
|
||||
The fallback chain ends at `_skeleton.docx`. The skeleton is an
|
||||
intentionally bare-bones .docx (firm letterhead + party block + court
|
||||
address + case number + signature stub) that ships as part of the
|
||||
initial template set. In practice every Generate request resolves to
|
||||
something — but if even the skeleton 404s (misconfigured repo), the
|
||||
generator returns `503` with a clear error, the SubmissionsPanel
|
||||
button surfaces "Vorlagen-Repository nicht erreichbar".
|
||||
|
||||
---
|
||||
|
||||
## §6 Variable interpolation
|
||||
|
||||
### 6.1 Engine
|
||||
|
||||
Plain text replacement of `{{path.dot.notation}}` placeholders in the
|
||||
.docx body. Whitespace inside braces is trimmed
|
||||
(`{{ project.case_number }}` ≡ `{{project.case_number}}`).
|
||||
|
||||
Implementation: a Go library that handles Word's run-fragmentation
|
||||
correctly (Word may split `{{project.case_number}}` across multiple
|
||||
`<w:r>` runs during editing; naive find/replace breaks). Candidates:
|
||||
|
||||
- **`github.com/lukasjarosch/go-docx`** (~2k stars, MIT, pure Go,
|
||||
maintained). Handles run-merging before replacement. **Inventor
|
||||
recommendation.**
|
||||
- `github.com/nguyenthenguyen/docx` — older, less active.
|
||||
- Custom in-house implementation — ~200 LoC for a minimal robust
|
||||
replacer that walks the document XML and merges runs that fall
|
||||
inside a `{{…}}` span. Fallback if the library doesn't pan out.
|
||||
|
||||
Slice 1: try `lukasjarosch/go-docx` first; if it has dealbreaker bugs
|
||||
(e.g. blows up on Word's autocorrect runs), fall back to the in-house
|
||||
~200 LoC walker. The library choice is an implementation detail; the
|
||||
placeholder syntax stays the same either way.
|
||||
|
||||
### 6.2 Variable contract (v1 placeholder set)
|
||||
|
||||
```
|
||||
{{firm.name}} — HLC (or whatever FIRM_NAME is)
|
||||
{{firm.signature_block}} — Phase 2; v1 renders empty string
|
||||
|
||||
{{today}} — 2026-05-19 (ISO)
|
||||
{{today.long_de}} — "19. Mai 2026"
|
||||
{{today.long_en}} — "19 May 2026"
|
||||
|
||||
{{user.display_name}} — "Maria Schmidt"
|
||||
{{user.email}} — "maria.schmidt@hlc.com"
|
||||
{{user.office}} — "Munich"
|
||||
|
||||
{{project.title}} — paliad.projects.title
|
||||
{{project.reference}} — paliad.projects.reference
|
||||
{{project.case_number}} — paliad.projects.case_number
|
||||
{{project.court}} — paliad.projects.court
|
||||
{{project.patent_number}} — paliad.projects.patent_number
|
||||
{{project.filing_date}} — ISO date
|
||||
{{project.grant_date}} — ISO date
|
||||
{{project.our_side}} — "claimant" | "defendant"
|
||||
{{project.our_side_de}} — "Klägerin" | "Beklagte"
|
||||
{{project.instance_level}} — "lg" | "olg" | "bgh" | ...
|
||||
{{project.proceeding.code}} — e.g. "de.inf.lg"
|
||||
{{project.proceeding.name}} — Verletzungsklage am Landgericht
|
||||
{{project.client_number}} — paliad.projects.client_number
|
||||
{{project.matter_number}} — paliad.projects.matter_number
|
||||
|
||||
{{parties.claimant.name}} — first paliad.parties row with role='claimant'
|
||||
{{parties.claimant.representative}} — paliad.parties.representative
|
||||
{{parties.defendant.name}} — first row with role='defendant'
|
||||
{{parties.defendant.representative}} — paliad.parties.representative
|
||||
{{parties.other.name}} — first row with role NOT IN ('claimant','defendant') — court, intervener, etc.
|
||||
|
||||
{{rule.submission_code}} — "de.inf.lg.erwidg"
|
||||
{{rule.name}} — "Klageerwiderung"
|
||||
{{rule.name_en}} — "Statement of Defence"
|
||||
{{rule.legal_source}} — "DE.ZPO.276.1"
|
||||
{{rule.legal_source_pretty}} — "§ 276 Abs. 1 ZPO"
|
||||
{{rule.primary_party}} — "defendant"
|
||||
{{rule.event_type}} — "filing"
|
||||
|
||||
{{deadline.due_date}} — date of the next pending deadline for this rule on this project
|
||||
{{deadline.due_date_long_de}} — "26. Juni 2026"
|
||||
{{deadline.computed_from}} — anchor description (e.g. "Klageerhebung am 12.05.2026 +6 Wochen")
|
||||
```
|
||||
|
||||
Per-firm extensions (e.g. `{{firm.signature_block}}` filled from a
|
||||
table) are Phase 2.
|
||||
|
||||
### 6.3 Variable bag construction
|
||||
|
||||
`services/submission_vars.go` builds a flat `map[string]string`
|
||||
keyed by the dotted-path placeholders above. One pass over:
|
||||
|
||||
1. `branding.Name` for `{{firm.*}}`
|
||||
2. `time.Now()` (with `Europe/Berlin` locale for the long forms) for
|
||||
`{{today.*}}`
|
||||
3. `userService.GetByID()` for `{{user.*}}`
|
||||
4. `projectService.GetByID()` for `{{project.*}}`
|
||||
5. `partyService.ListByProject()` for `{{parties.*}}`
|
||||
6. `deadlineRuleService.GetByCode()` for `{{rule.*}}`
|
||||
7. `deadlineService.NextByRuleOnProject()` for `{{deadline.*}}`
|
||||
|
||||
Missing values render as `[KEIN WERT: {dotted.path}]` (DE) or
|
||||
`[NO VALUE: {dotted.path}]` (EN) based on user locale. This is by
|
||||
design — the lawyer sees the gap in Word, fixes it (either in Word
|
||||
or in paliad and regenerates), rather than getting a 400 with a list
|
||||
of missing fields they then have to chase.
|
||||
|
||||
### 6.4 Pretty-printing the legal_source
|
||||
|
||||
`legal_source` in the rule corpus is shorthand
|
||||
(`DE.ZPO.276.1`, `UPC.RoP.23.1`). Lawyers don't want that in a brief;
|
||||
they want `§ 276 Abs. 1 ZPO` or `Rule 23.1 RoP UPC`.
|
||||
|
||||
Slice 1 ships a small pretty-printer (`legalSourcePretty`) that knows
|
||||
the families we currently use:
|
||||
|
||||
| Prefix | Pretty form (DE) | Pretty form (EN) |
|
||||
|---|---|---|
|
||||
| `DE.ZPO.<§>.<Abs>` | `§ <§> Abs. <Abs> ZPO` | `Section <§>(<Abs>) ZPO` |
|
||||
| `DE.ZPO.<§>` | `§ <§> ZPO` | `Section <§> ZPO` |
|
||||
| `UPC.RoP.<Rule>.<Sub>` | `Regel <Rule>.<Sub> VerfO UPC` | `Rule <Rule>.<Sub> RoP UPC` |
|
||||
| `UPC.RoP.<Rule>` | `Regel <Rule> VerfO UPC` | `Rule <Rule> RoP UPC` |
|
||||
| `DE.PatG.<§>` | `§ <§> PatG` | `Section <§> PatG` |
|
||||
| `EPC.<Art>` | `Art. <Art> EPÜ` | `Art. <Art> EPC` |
|
||||
| (unknown) | original string | original string |
|
||||
|
||||
Unrecognised prefixes pass through unchanged (better than an
|
||||
incorrect prettification). The function is pure and unit-tested.
|
||||
|
||||
---
|
||||
|
||||
## §7 File naming
|
||||
|
||||
Generated file name:
|
||||
|
||||
```
|
||||
{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx
|
||||
```
|
||||
|
||||
Concrete example for the Slice 1 happy path:
|
||||
|
||||
```
|
||||
Klageerwiderung-2 O 123_25-2026-05-19.docx
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `rule.name` honours user locale (`Klageerwiderung` for DE,
|
||||
`Statement of Defence` for EN).
|
||||
- `project.case_number` slash/backslash → underscore (Word file name
|
||||
hygiene), other characters preserved.
|
||||
- Date is ISO at server-local (`Europe/Berlin`) date.
|
||||
- If `project.case_number` is empty → fall back to a short hash of
|
||||
`project_id` (8 hex chars) so the file still has a stable identifier
|
||||
the lawyer can rename without losing track.
|
||||
|
||||
---
|
||||
|
||||
## §8 Authorization
|
||||
|
||||
- **Visibility gate:** `paliad.can_see_project(project_id)` — anyone
|
||||
who can see the project can generate. Matches every other write
|
||||
surface on the project. The endpoint inlines the predicate;
|
||||
unauthorised callers get 404 (not 403, to avoid project
|
||||
enumeration).
|
||||
- **No profession floor.** A paralegal can generate a draft of a
|
||||
Klageerwiderung; the draft is a Word doc that needs the associate's
|
||||
approval downstream (in Word, on the document itself). Adding an
|
||||
approval gate on generation would slow the workflow without
|
||||
preventing anything that paliad's existing approval system doesn't
|
||||
already cover at the substantive-act layer.
|
||||
- **Owner gate (Paliadin) does NOT apply.** This is the
|
||||
submission-template engine, not Paliadin. All paliad users get the
|
||||
feature once a template exists for the proceeding their project is
|
||||
in.
|
||||
|
||||
---
|
||||
|
||||
## §9 Audit trail
|
||||
|
||||
Two records per generation:
|
||||
|
||||
### 9.1 `paliad.documents` row (audit-only, no binary)
|
||||
|
||||
```sql
|
||||
INSERT INTO paliad.documents (id, title, doc_type, file_path, file_size,
|
||||
mime_type, ai_extracted, uploaded_by,
|
||||
project_id)
|
||||
VALUES (gen_random_uuid(),
|
||||
'{rule.name} (generiert {YYYY-MM-DD})',
|
||||
'generated_submission', -- new doc_type value
|
||||
NULL, -- no on-disk path
|
||||
NULL, -- no file size (binary not persisted)
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
jsonb_build_object(
|
||||
'submission_code', $1,
|
||||
'template_path', $2, -- the gitea path we resolved
|
||||
'template_sha', $3, -- pinned SHA from the cache fetch
|
||||
'firm', $4),
|
||||
$user_id,
|
||||
$project_id);
|
||||
```
|
||||
|
||||
- `doc_type='generated_submission'` is a new value; no CHECK constraint
|
||||
on doc_type today so this is additive.
|
||||
- `file_path NULL` is the marker that says "regenerate from inputs on
|
||||
demand". The /api/projects/{id}/documents listing UI (Phase 2) will
|
||||
surface a `[Erneut generieren]` action for these rows.
|
||||
- `ai_extracted` jsonb is repurposed for generation provenance
|
||||
(template SHA, firm at time of generation). Naming is unfortunate
|
||||
but the column shape fits; renaming the column is out of scope for
|
||||
this task.
|
||||
|
||||
### 9.2 `paliad.system_audit_log` row
|
||||
|
||||
```sql
|
||||
INSERT INTO paliad.system_audit_log (event_type, actor_id, actor_email,
|
||||
scope, scope_root, metadata)
|
||||
VALUES ('submission.generated',
|
||||
$user_id,
|
||||
$user_email,
|
||||
'project',
|
||||
$project_id::text,
|
||||
jsonb_build_object(
|
||||
'submission_code', $1,
|
||||
'template_path', $2,
|
||||
'template_sha', $3,
|
||||
'document_id', $document_id,
|
||||
'firm', $4));
|
||||
```
|
||||
|
||||
Mirrors the existing `system_audit_log` event_type convention
|
||||
(`*.created`, `*.updated`, etc., from t-paliad-214).
|
||||
|
||||
### 9.3 Verlauf entry (project event)
|
||||
|
||||
`paliad.project_events` gets a row with `event_type='submission_generated'`
|
||||
and `timeline_kind='custom_milestone'` so the generation surfaces in
|
||||
SmartTimeline's audit-log toggle and on the project's Verlauf list.
|
||||
This is the user-visible footprint; the `system_audit_log` entry is
|
||||
the admin-visible audit footprint.
|
||||
|
||||
---
|
||||
|
||||
## §10 Frontend surface
|
||||
|
||||
### 10.1 Slice 1 — SubmissionsPanel on project detail
|
||||
|
||||
A new panel below the existing Verlauf / Deadlines panels on
|
||||
`/projects/{id}`:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Schriftsätze │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Klageerhebung [— Vorlage fehlt] │
|
||||
│ Klageerwiderung [Generieren ↓] │
|
||||
│ Replik [— Vorlage fehlt] │
|
||||
│ Duplik [— Vorlage fehlt] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Filter: only `event_type='filing'` rules from the project's
|
||||
`proceeding_type` are listed. Hearings and decisions don't have
|
||||
submissions.
|
||||
- Per-row state: `has_template` returned by
|
||||
`GET /api/projects/{id}/submissions`. Disabled buttons show the
|
||||
"Vorlage fehlt" hint (German default, English in EN locale).
|
||||
- Click `[Generieren ↓]` → POST → browser triggers download.
|
||||
- `aria-busy="true"` on the panel while a generation is in flight
|
||||
(cheap, but lawyers feel slow networks).
|
||||
|
||||
### 10.2 Out of scope for Slice 1
|
||||
|
||||
- A standalone `/submissions` index page.
|
||||
- A Frist-detail "Generate" button.
|
||||
- A picker for template variants (1:N) — locked to fallback chain
|
||||
(Q4), which is 1:1 from the user's perspective.
|
||||
- An "edit project, then regenerate" loop on the same UI.
|
||||
|
||||
---
|
||||
|
||||
## §11 AI-drafted body (deferred — sketch only)
|
||||
|
||||
NOT in scope for t-paliad-215. Documented here so the next inventor
|
||||
picking up the "AI Klageerwiderung body" task has a clear starting
|
||||
shape.
|
||||
|
||||
The natural fit: a new aichat persona (e.g. `paliadin-draft`) on
|
||||
mRiver, parallel to the existing `paliadin` persona.
|
||||
|
||||
```
|
||||
{{ai.draft_body}} # placeholder in the template
|
||||
|
||||
→ generator detects {{ai.*}} placeholders in the template
|
||||
→ POSTs to aichat with persona=paliadin-draft + context:
|
||||
- project state (variables already built)
|
||||
- relevant project notes (paliad.notes)
|
||||
- the deadline_rule corpus (rule + family)
|
||||
- HL Patents Style guide chunks (RAG, eventually)
|
||||
→ aichat returns Markdown body
|
||||
→ generator injects into the .docx as one or more <w:p> paragraphs
|
||||
(Word-friendly Markdown → docx mapping needed; substantive
|
||||
formatting question for that follow-up)
|
||||
```
|
||||
|
||||
Open shape questions for that follow-up (NOT for this design):
|
||||
|
||||
- One persona per submission type, or one persona that branches on
|
||||
`submission_code` in its system prompt?
|
||||
- Owner gate (m only) like current paliadin, or open to all
|
||||
authenticated users?
|
||||
- Approval gate before the AI body lands in the .docx?
|
||||
- Cost accounting per generation?
|
||||
- Where does the prose context come from (notes / uploaded patent
|
||||
spec / prior pleadings)?
|
||||
|
||||
Re-uses, when that task fires:
|
||||
|
||||
- This task's template engine, variable contract, fallback chain,
|
||||
audit trail — all unchanged.
|
||||
- Just a new placeholder family (`{{ai.*}}`) + a new aichat persona +
|
||||
a new admin gate.
|
||||
|
||||
---
|
||||
|
||||
## §12 Slice plan beyond Slice 1
|
||||
|
||||
| Slice | Scope |
|
||||
|---|---|
|
||||
| 1 | One template (`de.inf.lg.erwidg`), engine + fallback chain + audit + SubmissionsPanel on project detail. THIS DESIGN. |
|
||||
| 2 | 3–5 more templates (Klageerhebung, SoC `upc.inf.cfi.soc`, SoD `upc.inf.cfi.sod`, Berufungsbegründung `de.inf.olg.begruendung`). Template authoring effort, no new architecture. |
|
||||
| 3 | Variable-contract sidebar in a new `/admin/submission-templates` page (mirrors `/admin/email-templates` shape). Shows what placeholders exist, with samples. Does NOT add an uploader UI — Gitea remains the editor. |
|
||||
| 4 | Per-firm override directory exercised (first non-HLC firm onboarded). |
|
||||
| 5 | Frist-detail "Generate" button + paliad.documents.deadline_id FK (mig 103+) for per-Frist draft history. |
|
||||
| 6 | (Separate task) AI-drafted body via Paliadin persona — see §11. |
|
||||
| 7 | (Future) Paliad UI uploader as alternative to Gitea, if the round-trip is friction. |
|
||||
|
||||
Slices 2–5 are roadmap markers, not commitments — m decides cadence.
|
||||
|
||||
---
|
||||
|
||||
## §13 Trade-offs flagged
|
||||
|
||||
1. **No binary persistence is a deliberate retention choice.** If a
|
||||
lawyer regenerates after the project state changes (party renamed,
|
||||
case_number corrected), the "regenerated" doc differs from the
|
||||
"original generated" doc. This is a feature, not a bug — the source
|
||||
of truth is paliad's project state, and the .docx is a derivative.
|
||||
But the lawyer needs to be aware: there is no "what did I generate
|
||||
last Thursday" recovery without re-saving locally. The
|
||||
`paliad.documents` audit row records WHAT was generated (template
|
||||
SHA + project state hash, optionally), but not the bytes.
|
||||
|
||||
2. **Gitea round-trip for template edits is friction.** Template
|
||||
authors edit `.docx` in Word, save, drag to Gitea web UI (or push
|
||||
from a local clone). The 5-min SHA cache means edits surface
|
||||
within 5 minutes (or instantly via `POST /api/files/refresh` —
|
||||
already wired for the HL Patents Style template). If lawyers
|
||||
complain, Phase 7 adds an in-paliad uploader. Until then, Gitea is
|
||||
the editor.
|
||||
|
||||
3. **Variable contract changes are coordinated edits.** Adding a new
|
||||
`{{project.*}}` placeholder needs both a code change (var bag) AND
|
||||
template edits (templates won't auto-discover new placeholders).
|
||||
The variable-contract sidebar (Slice 3) is the mitigation —
|
||||
template authors see what's available without reading the Go code.
|
||||
|
||||
4. **`lukasjarosch/go-docx` library risk.** ~2k stars, MIT, maintained
|
||||
— but it's a third-party dep we haven't used before. Fallback is
|
||||
the in-house ~200-LoC walker. The placeholder syntax doesn't change
|
||||
either way; Slice 1 can swap engines without touching templates or
|
||||
callers.
|
||||
|
||||
5. **`paliad.documents.ai_extracted` is repurposed for generation
|
||||
provenance.** Slightly ugly naming because the column was added for
|
||||
Phase H (AI Frist-Extraktion), which never shipped. Renaming the
|
||||
column to something like `metadata` is out of scope for this task
|
||||
but should be folded into the migration that lands when Phase 5
|
||||
adds `deadline_id`.
|
||||
|
||||
6. **`paliad.parties.role='claimant'`** — multiple claimants on a
|
||||
project (multi-party suit) → Slice 1 picks the first row. v1
|
||||
shortcut. Templates needing multi-claimant blocks become Phase 2
|
||||
work (with a `{{#each parties.claimants}}` shape on top of
|
||||
`lukasjarosch/go-docx`'s loop support).
|
||||
|
||||
7. **No Word-side `MERGEFIELD` support.** Lawyers who insert Word
|
||||
merge fields (via Insert → Quick Parts → Field) instead of typing
|
||||
`{{…}}` will get untouched MERGEFIELD codes in the rendered output.
|
||||
Decision: standardise on `{{…}}` syntax (cheap to type, visible
|
||||
in the template, predictable). Document this in the `templates/
|
||||
README.md`.
|
||||
|
||||
8. **No template versioning UI.** Gitea provides git history; that's
|
||||
the canonical version trail. Bumping to "use template X as of
|
||||
commit Y" for an old project is a manual git-checkout-and-pin
|
||||
exercise. Phase 2+ if anyone asks; not today.
|
||||
|
||||
---
|
||||
|
||||
## §14 Open follow-ups (NOT blocking)
|
||||
|
||||
These items are NOT m-decisions; they're follow-ups for the coder
|
||||
shift or future inventor passes:
|
||||
|
||||
- **Template authoring effort.** Slice 1 needs HLC to author/review
|
||||
the actual Klageerwiderung template. That's a legal-review task that
|
||||
can run in parallel with the engine code (template uploaded last
|
||||
before the slice ships). Coordinate with m on who reviews.
|
||||
- **English version of `legalSourcePretty`.** Pretty-printer table in
|
||||
§6.4 needs an EN column for every prefix — populated from existing
|
||||
glossary entries where possible.
|
||||
- **i18n key sweep.** `submissions.*` namespace; ~20 keys for Slice 1
|
||||
(panel title, button labels, "Vorlage fehlt" hints, error messages
|
||||
for 503/404/422).
|
||||
- **README for template authors.** A `templates/README.md` in
|
||||
m/mWorkRepo listing the available placeholders + naming convention
|
||||
+ a screenshot of a working template. Coder ships this alongside
|
||||
Slice 1.
|
||||
- **CLAUDE.md update.** Add a "Submission templates" section
|
||||
documenting the Gitea proxy, placeholder syntax, and the
|
||||
`submission.generated` audit event_type.
|
||||
- **Cleanup task for `ai_extracted` naming.** Issue + Phase 5 mig.
|
||||
|
||||
---
|
||||
|
||||
## §15 What this design does NOT do
|
||||
|
||||
To set the scope boundary cleanly:
|
||||
|
||||
- ❌ Generate PDFs.
|
||||
- ❌ Generate emails or any non-.docx format.
|
||||
- ❌ Edit `.docx` files inside paliad (no in-browser Word editor).
|
||||
- ❌ Upload .docx to NetDocuments or any external DMS.
|
||||
- ❌ Translate templates DE↔EN automatically.
|
||||
- ❌ Validate the generated draft against any legal rule.
|
||||
- ❌ Sign, certify, or notarise the output.
|
||||
- ❌ Send the draft to court / e-filing.
|
||||
- ❌ AI-draft any prose. (See §11.)
|
||||
- ❌ Provide a paliad-UI template editor. (Gitea is the editor.)
|
||||
- ❌ Persist generated .docx bytes server-side. (Audit row only.)
|
||||
- ❌ Add a new database table. (`paliad.documents` is enough for v1.)
|
||||
- ❌ Require a database migration. (Slice 1 is migration-free.)
|
||||
|
||||
Each of these is a defensible future-scope item; none belong in
|
||||
Slice 1.
|
||||
|
||||
---
|
||||
|
||||
## §16 Recommended implementer
|
||||
|
||||
Pattern-fluent Sonnet coder. The substrate is well-trodden:
|
||||
|
||||
- Gitea proxy + cache: `internal/handlers/files.go` is the template
|
||||
to lift.
|
||||
- Variable contract pattern: `internal/services/email_template_variables.go`
|
||||
is the template to mirror (different surface, identical shape).
|
||||
- Visibility gate: `internal/services/visibility.go` +
|
||||
`paliad.can_see_project()` — standard everywhere.
|
||||
- Audit insert: `paliad.system_audit_log` (mig 102) + `paliad.documents`
|
||||
(existing table, first writer).
|
||||
- Frontend SubmissionsPanel: stock TSX + client/.ts pattern, same shape
|
||||
as the existing CardLayout / EventsList panels.
|
||||
|
||||
The only novel piece is the docx merge library integration — that's a
|
||||
~200 LoC isolated module the coder can prototype on a sample .docx
|
||||
before wiring into the project flow.
|
||||
|
||||
NOT cronus per project memory directive.
|
||||
|
||||
---
|
||||
|
||||
## §17 Acceptance criteria for Slice 1
|
||||
|
||||
The coder considers Slice 1 done when:
|
||||
|
||||
1. Pushing a `.docx` to `m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`
|
||||
and visiting any project with `proceeding_type=de.inf.lg` surfaces
|
||||
a `[Generieren]` Klageerwiderung button.
|
||||
2. Clicking it downloads a `.docx` named per §7 with all §6.2
|
||||
placeholders resolved (or `[KEIN WERT: …]` markers for genuinely
|
||||
missing project fields).
|
||||
3. Opening the downloaded .docx in Word renders cleanly (no run
|
||||
fragmentation artefacts, no broken styles).
|
||||
4. A row appears in `paliad.documents` with `doc_type='generated_submission'`,
|
||||
`file_path=NULL`, and `ai_extracted` jsonb carrying the template
|
||||
path + SHA.
|
||||
5. A row appears in `paliad.system_audit_log` with `event_type='submission.generated'`.
|
||||
6. A row appears in `paliad.project_events` with
|
||||
`event_type='submission_generated'` and shows up in the project's
|
||||
Verlauf / SmartTimeline.
|
||||
7. Calling the endpoint without project visibility returns 404.
|
||||
8. Calling the endpoint with no template anywhere in the fallback
|
||||
chain returns 503 with a clear error.
|
||||
9. Unit tests cover: placeholder rendering happy path, missing-var
|
||||
marker, fallback chain (all 4 levels), file naming, slash
|
||||
sanitization, legalSourcePretty for every prefix in §6.4.
|
||||
10. `go build ./... && go vet ./... && go test ./... && bun run build`
|
||||
all clean.
|
||||
11. Manual test on the live database (test admin
|
||||
`tester@hlc.de` per memory) against a project with a real
|
||||
`de.inf.lg` proceeding succeeds end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## §18 Approval gate
|
||||
|
||||
Per inventor SKILL.md and project CLAUDE.md: this design needs m's
|
||||
go/no-go before any coder is hired. After m approves:
|
||||
|
||||
- The head decides whether to hire the same worker as `/mai-coder`
|
||||
with this design as the brief, or a fresh coder.
|
||||
- A coder shift takes this doc as the spec, ships Slice 1, opens a
|
||||
PR (no self-merge — maria's gate).
|
||||
- Phase 11 (AI-drafted body) is a SEPARATE task — not auto-spawned.
|
||||
|
||||
Inventor parks here.
|
||||
52
docs/t-paliad-207-followup-scope.md
Normal file
52
docs/t-paliad-207-followup-scope.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# t-paliad-207 follow-up scope — close-out assessment
|
||||
|
||||
**Author:** fermi (inventor)
|
||||
**Date:** 2026-05-20
|
||||
**Verdict:** **(A) DONE** — interactive session scope is shipped; remaining tail is filed-or-fileable as discrete issues, not a fresh fermi slice.
|
||||
|
||||
---
|
||||
|
||||
## 0. What shipped under t-paliad-207
|
||||
|
||||
Six substantive deliveries on `mai/fermi/interactive-session`, all merged to main as of 2026-05-20 morning:
|
||||
|
||||
1. **Verfahrensablauf + Fristenrechner polish** — jurisdiction prefix on the picked proceeding, trigger-event label derived from the root rule, flag rows lifted to `/tools/verfahrensablauf`, rule references rendered as `youpc.org/laws#…` links via new `BuildLegalSourceURL`, `Vorab-Einrede → Einspruch` rename (DE i18n).
|
||||
2. **DE proceeding picker — sub-group headers** (`Verletzungsverfahren` / `Nichtigkeitsverfahren`) + parallel labels (`LG (1. Instanz)` / `OLG (Berufung)` / …).
|
||||
3. **mig 099** — drop the `with_po` flag from the two RoP 19 rules (Einspruch is always-available, not flag-gated).
|
||||
4. **mig 100** — `upc.inf.cfi.ccr` visible rule (`Nichtigkeitswiderklage`) so the CCR filing event surfaces when `with_ccr` is set; later corrected to `priority='optional'` via mig 101.
|
||||
5. **mig 101** — strip rule-cite brackets from the two Einspruch names + flip the CCR priority `informational → optional`.
|
||||
6. **mig 102** — track-aware sequence reshuffle on `upc.inf.cfi` so at any tied date the order is infringement (Replik) → revocation (Erwiderung Nichtigkeitswiderklage) → amendment.
|
||||
7. **Notes toggle** — `Hinweise anzeigen` checkbox in the view-toggle bar; compact ⓘ hover hint when off (default), inline `timeline-notes` block when on. `localStorage` shared across both tool pages.
|
||||
|
||||
Filed two follow-up issues during the session:
|
||||
|
||||
- **m/paliad#39** — link DE + EPA + EU rule references to `youpc.org/laws` (depends on youpc.org ingesting the corpus).
|
||||
- **m/paliad#41** — DE proceedings as one combined timeline per type (LG→OLG→BGH, BPatG→BGH) — corpus + spawn + de-duplication + multi-instance UI.
|
||||
|
||||
## 1. Why (A) DONE
|
||||
|
||||
Every concrete thing m surfaced in the session was addressed and merged. The two larger unaddressed asks — combined-timeline behaviour for DE proceedings, and DE/EPA rule-link coverage — are already captured in #39 and #41 with concrete scope notes. Neither belongs as a fermi "next slice" because:
|
||||
|
||||
- **#41** is a corpus + UI design pass of its own (3 new spawn rules, de-duplication of the existing `de.inf.lg.berufung ↔ de.inf.olg.berufung` pair, multi-court picker shape, instance markers in the timeline body). That's its own design ticket, not a fermi follow-up.
|
||||
- **#39** is primarily a youpc.org-side ingest task; the paliad-side change is a 5-line `switch` extension once youpc serves the URLs. Wait for the dependency, then small.
|
||||
|
||||
Everything else I surfaced in the read-only audit is either pre-existing (not introduced by this session) or speculative (no user complaint behind it).
|
||||
|
||||
## 2. Optional tail — would file as discrete issues, not a fermi slice
|
||||
|
||||
Surfacing these for completeness; none are blocking, and most would be small enough to either roll into the existing tickets or land as one-off polish:
|
||||
|
||||
| # | Candidate | Size | Already covered? |
|
||||
|---|---|---|---|
|
||||
| 1 | **`legal_source` backfill on 47 unsourced active rules** — query: 4 of `upc.inf.cfi`, 4 of `upc.pi.cfi` (100% gap), 6 of `upc.rev.cfi`, others. Pre-condition for #39's links to bite. | Medium — corpus research per rule | Partially: huygens did the broader citation backfill in t-paliad-208 / mig 097. This is the remaining tail. |
|
||||
| 2 | **`upc.pi.cfi` corpus completeness audit** — all 4 of its rules lack `legal_source`; likely also missing the analogous track-of-decision spawn rules to `upc.apl.merits`. | Small audit, medium fix | No — would be a fresh task. |
|
||||
| 3 | **Touch-device fallback for the ⓘ hover hint** — `title=` attribute degrades poorly on phones (no hover, no tap-to-show). Either a click-to-popover variant, or accept the gap. | Tiny | No, but no user complaint yet. |
|
||||
| 4 | **R.46 mutatis-mutandis distinction in `upc.rev.cfi.prelim` description** — when mig 101 stripped the `(R. 19 i.V.m. R. 46)` cite, the legal nuance dropped from the user-visible name. Could be surfaced in the description text where it doesn't crowd the timeline cell. | Tiny (one row update) | No. |
|
||||
| 5 | **Save-modal warning on SoD + CCR double-check** — with mig 100's new `upc.inf.cfi.ccr` rule, a user can save both `sod` and `ccr` from the same modal and get two `paliad.deadlines` rows on the same date. Today's pre-uncheck behaviour for optional priority mitigates accidental double-write but doesn't surface the duplication actively. | Small | No. |
|
||||
| 6 | **Deferred slices from earlier design docs that touch this surface**: t-paliad-179 Slice 2-4 (variant chips, lane view, side-by-side compare on `/tools/verfahrensablauf`); t-paliad-169 "+ Eintrag" CTA on the SmartTimeline (project-bound) path. | Each a separate slice. | Yes — parked from their original tasks; would be revisited when m prioritises. |
|
||||
|
||||
None of these warrant a "next fermi slice" right now. They're polish + corpus tail, and best handled as individual issues that m can pick from.
|
||||
|
||||
## 3. Recommendation
|
||||
|
||||
Close t-paliad-207. Fire fermi. The remaining tail (items 1–6 above) is appropriate as a small "polish backlog" m can dip into when relevant, but not a coherent unit of work that needs a parked inventor.
|
||||
126
frontend/public/patentstyle/index.html
Normal file
126
frontend/public/patentstyle/index.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HL Patents Style</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #002236;
|
||||
--fg: #e8e8ed;
|
||||
--muted: #8a9aa6;
|
||||
--accent: #bff355;
|
||||
--rule: #0f3a55;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif;
|
||||
line-height: 1.55;
|
||||
font-size: 17px;
|
||||
}
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem 6rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
margin: 0 0 0.25rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h1 .accent { color: var(--accent); }
|
||||
.lead {
|
||||
color: var(--muted);
|
||||
margin: 0 0 3rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
margin: 2.5rem 0 0.75rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
ul { padding-left: 1.25rem; margin: 0.5rem 0 1rem; }
|
||||
li { margin: 0.35rem 0; }
|
||||
p { margin: 0.6rem 0; }
|
||||
a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; }
|
||||
a:hover { border-bottom-color: var(--accent); }
|
||||
code, kbd {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
background: #0a2d44;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.download {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
border: 0;
|
||||
}
|
||||
.download:hover { border-bottom: 0; filter: brightness(1.05); }
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
footer code { color: var(--muted); background: transparent; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<h1>HL <span class="accent">Patents Style</span></h1>
|
||||
<p class="lead">Das Word-Template fuer Patentschriftsaetze bei Hogan Lovells.</p>
|
||||
|
||||
<h2>Was es kann</h2>
|
||||
<ul>
|
||||
<li>Vorlagen-Stile fuer alle gaengigen Schriftsatz-Bausteine (Headings, Randnummern, Antraege, Exhibits)</li>
|
||||
<li>BuildingBlocks: ueber das Ribbon vorgefertigte Abschnitte einfuegen</li>
|
||||
<li>Sprachumschaltung DE / EN per Ribbon-Toggle</li>
|
||||
<li>Scaffolding: kompletter Schriftsatz-Aufbau mit einem Klick</li>
|
||||
<li>Margin Numbers, Exhibit-Nummerierung, SEQ-Felder</li>
|
||||
<li>Auto-Update ueber das Ribbon (siehe unten)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Aktualisierungen</h2>
|
||||
<p>Im Ribbon-Tab <em>HL Patent</em> → Gruppe <em>Manage</em> → <kbd>Check for Updates</kbd>. Holt das aktuelle Manifest von diesem Server, prueft die Version, laedt die neue <code>.dotm</code> nur bei Bedarf, verifiziert per SHA256, installiert. Nach dem Update Word neu starten.</p>
|
||||
|
||||
<h2>Frische Installation</h2>
|
||||
<p>Wer das Template noch nicht installiert hat, laedt einmal manuell die aktuelle Version und kopiert sie in den Word-Startup-Ordner. Den Rest macht die <code>InstallTemplate</code>-Routine im Template selbst.</p>
|
||||
<p><a class="download" href="HL-Patents-Style.dotm" download>HL Patents Style.dotm herunterladen</a></p>
|
||||
|
||||
<h2>Hilfe & Feedback</h2>
|
||||
<p>Fehler, Wuensche, Stilfragen, Build-Probleme: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HL%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
|
||||
|
||||
<footer>
|
||||
<p>Update-Endpoint: <code>paliad.msbls.de/patentstyle/</code> · Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
|
||||
<p id="ver"></p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Best-effort: show the currently-served version
|
||||
fetch('version.json', { cache: 'no-cache' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(j => {
|
||||
if (j && j.version) {
|
||||
document.getElementById('ver').textContent = 'Aktuell ausgeliefert: ' + j.version;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
</script>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -33,6 +33,9 @@ export function renderAdminTeam(): string {
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-team-actions">
|
||||
<button className="btn-primary" id="admin-team-add-full" type="button" data-i18n="admin.team.add.full">
|
||||
Konto direkt anlegen
|
||||
</button>
|
||||
<button className="btn-primary" id="admin-team-direct-add" type="button" data-i18n="admin.team.add.direct">
|
||||
Bestehendes Konto onboarden
|
||||
</button>
|
||||
@@ -132,6 +135,67 @@ export function renderAdminTeam(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal.
|
||||
Creates BOTH the auth.users row (via Supabase Admin API) and
|
||||
the paliad.users row in one click. New user is visible in
|
||||
dropdowns immediately. */}
|
||||
<div className="modal-overlay" id="admin-add-full-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="admin.team.add_full.title">Konto direkt anlegen</h2>
|
||||
<button className="modal-close" id="admin-af-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p data-i18n="admin.team.add_full.body" className="invite-modal-body">
|
||||
Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erhält eine E-Mail mit einem Link, über den sie ein Passwort setzt.
|
||||
</p>
|
||||
<form id="admin-add-full-form" className="entity-form" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-email" data-i18n="admin.team.add_full.email">E-Mail</label>
|
||||
<input type="email" id="admin-af-email" name="email" required autocomplete="off" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-name" data-i18n="admin.team.add_full.name">Anzeigename</label>
|
||||
<input type="text" id="admin-af-name" name="display_name" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-office" data-i18n="admin.team.add_full.office">Standort</label>
|
||||
<select id="admin-af-office" name="office" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-profession" data-i18n="admin.team.add_full.profession">Profession</label>
|
||||
<select id="admin-af-profession" name="profession">
|
||||
<option value="partner" data-i18n="projects.team.profession.partner">Partner</option>
|
||||
<option value="of_counsel" data-i18n="projects.team.profession.of_counsel">Of Counsel</option>
|
||||
<option value="associate" selected data-i18n="projects.team.profession.associate">Associate</option>
|
||||
<option value="senior_pa" data-i18n="projects.team.profession.senior_pa">Senior PA</option>
|
||||
<option value="pa" data-i18n="projects.team.profession.pa">PA</option>
|
||||
<option value="paralegal" data-i18n="projects.team.profession.paralegal">Paralegal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-job-title" data-i18n="admin.team.add_full.job_title">Berufsbezeichnung</label>
|
||||
<input type="text" id="admin-af-job-title" name="job_title" placeholder="Associate" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-lang" data-i18n="admin.team.add_full.lang">Sprache</label>
|
||||
<select id="admin-af-lang" name="lang">
|
||||
<option value="de" selected>Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="admin-af-send-welcome" checked />
|
||||
<span data-i18n="admin.team.add_full.send_welcome">Willkommens-E-Mail mit Login-Link senden</span>
|
||||
</label>
|
||||
<div id="admin-af-feedback" className="form-msg" style="display:none" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="admin-af-cancel" data-i18n="admin.team.add_full.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary" id="admin-af-submit" data-i18n="admin.team.add_full.submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-team.js"></script>
|
||||
|
||||
@@ -468,11 +468,125 @@ function initInviteButton() {
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal. Creates both
|
||||
// the auth.users row (via Supabase Admin API) and the paliad.users row in
|
||||
// one POST. New user appears in dropdowns immediately. Welcome email with
|
||||
// magic-link is sent by default; admin can opt out via the checkbox.
|
||||
function openAddFullModal() {
|
||||
const modal = document.getElementById("admin-add-full-modal")!;
|
||||
const fb = document.getElementById("admin-af-feedback")!;
|
||||
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
|
||||
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
|
||||
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
|
||||
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
|
||||
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
|
||||
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
|
||||
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
|
||||
|
||||
fb.style.display = "none";
|
||||
emailField.value = "";
|
||||
nameField.value = "";
|
||||
jobTitleField.value = "";
|
||||
profSel.value = "associate";
|
||||
langSel.value = "de";
|
||||
sendWelcome.checked = true;
|
||||
officeSel.innerHTML = officeOptions("munich");
|
||||
|
||||
modal.style.display = "flex";
|
||||
emailField.focus();
|
||||
}
|
||||
|
||||
function closeAddFullModal() {
|
||||
document.getElementById("admin-add-full-modal")!.style.display = "none";
|
||||
}
|
||||
|
||||
function initAddFullModal() {
|
||||
document.getElementById("admin-team-add-full")!.addEventListener("click", openAddFullModal);
|
||||
document.getElementById("admin-af-close")!.addEventListener("click", closeAddFullModal);
|
||||
document.getElementById("admin-af-cancel")!.addEventListener("click", closeAddFullModal);
|
||||
document.getElementById("admin-add-full-modal")!.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeAddFullModal();
|
||||
});
|
||||
|
||||
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
|
||||
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
|
||||
// Pre-fill the display name from the email local-part the first time the
|
||||
// admin tabs out of the email field — mirrors the existing onboard flow.
|
||||
emailField.addEventListener("blur", () => {
|
||||
if (nameField.value || !emailField.value) return;
|
||||
const local = emailField.value.split("@")[0] ?? "";
|
||||
nameField.value = local
|
||||
.split(/[._-]/)
|
||||
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
|
||||
.join(" ")
|
||||
.trim();
|
||||
});
|
||||
|
||||
const form = document.getElementById("admin-add-full-form") as HTMLFormElement;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const fb = document.getElementById("admin-af-feedback")!;
|
||||
fb.style.display = "none";
|
||||
|
||||
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
|
||||
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
|
||||
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
|
||||
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
|
||||
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
|
||||
const submitBtn = document.getElementById("admin-af-submit") as HTMLButtonElement;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
email: emailField.value.trim().toLowerCase(),
|
||||
display_name: nameField.value.trim(),
|
||||
office: officeSel.value,
|
||||
job_title: jobTitleField.value.trim() || "Associate",
|
||||
profession: profSel.value,
|
||||
lang: langSel.value,
|
||||
send_welcome_mail: sendWelcome.checked,
|
||||
};
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch("/api/admin/users/full", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
// Map two friendly cases inline; everything else surfaces the
|
||||
// server message so the admin can act on it.
|
||||
if (resp.status === 503) {
|
||||
fb.textContent = t("admin.team.add_full.error.unavailable")
|
||||
|| "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).";
|
||||
} else if (resp.status === 409) {
|
||||
fb.textContent = body.error
|
||||
|| (t("admin.team.add_full.error.email_exists")
|
||||
|| "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.");
|
||||
} else {
|
||||
fb.textContent = body.error || (t("admin.team.add_full.error.generic") || "Fehler.");
|
||||
}
|
||||
fb.className = "form-msg form-msg-error";
|
||||
fb.style.display = "block";
|
||||
return;
|
||||
}
|
||||
const created = (await resp.json()) as User;
|
||||
users = users.concat(created);
|
||||
closeAddFullModal();
|
||||
showFeedback(t("admin.team.add_full.feedback.added") || "Konto angelegt.", false);
|
||||
render();
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initSearch();
|
||||
initDirectAddModal();
|
||||
initAddFullModal();
|
||||
initInviteButton();
|
||||
onLangChange(() => {
|
||||
buildOfficeFilters();
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
|
||||
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7,
|
||||
// retrofitted onto the unified modal primitive in t-paliad-217 Slice D).
|
||||
//
|
||||
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
|
||||
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
|
||||
// collects subject + body + (optional) template and posts to
|
||||
// /api/team/broadcast. On success it shows a per-recipient send report
|
||||
// and closes.
|
||||
// and closes after a short delay.
|
||||
//
|
||||
// Per-recipient privacy: each member receives their own envelope. The
|
||||
// modal lists every addressee so the sender knows exactly who will be
|
||||
// mailed; there is no surprise to-line.
|
||||
//
|
||||
// Migration notes (t-paliad-217 Slice D): the shell, ESC, backdrop,
|
||||
// close button, and browser back-button are now owned by openModal().
|
||||
// The body is built imperatively so the submit handler can read form
|
||||
// state from the modal-body element it constructed.
|
||||
|
||||
import { t } from "./i18n";
|
||||
import { openModal } from "./components/modal";
|
||||
|
||||
export interface BroadcastRecipient {
|
||||
user_id: string;
|
||||
@@ -35,6 +42,12 @@ interface EmailTemplateOption {
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
interface BroadcastResult {
|
||||
sent: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const RECIPIENT_CAP = 100;
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -78,69 +91,32 @@ export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing modal? Remove. Avoids stacking on rapid double-click.
|
||||
document.getElementById("broadcast-modal")?.remove();
|
||||
const body = renderBody(args);
|
||||
wireBody(body);
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "broadcast-modal";
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = renderShell(args);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Close handlers
|
||||
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
document.addEventListener("keydown", function escClose(e) {
|
||||
if (e.key === "Escape") {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", escClose);
|
||||
}
|
||||
});
|
||||
|
||||
// Recipient toggle
|
||||
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
||||
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
||||
if (!list) return;
|
||||
list.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
// Template dropdown
|
||||
const templateSelect = overlay.querySelector<HTMLSelectElement>("[data-broadcast-template]");
|
||||
templateSelect?.addEventListener("change", async () => {
|
||||
const key = templateSelect.value;
|
||||
if (!key) return;
|
||||
const lang = (document.documentElement.lang || "de") as "de" | "en";
|
||||
try {
|
||||
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
|
||||
if (!res.ok) return;
|
||||
const tpl = (await res.json()) as EmailTemplateOption;
|
||||
const subjectInput = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
|
||||
const bodyInput = overlay.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
|
||||
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
|
||||
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
|
||||
} catch {
|
||||
/* template load failure is non-fatal — sender keeps freeform mode. */
|
||||
}
|
||||
});
|
||||
|
||||
// Submit
|
||||
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(form, overlay, args);
|
||||
void openModal<BroadcastResult>({
|
||||
title: t("team.broadcast.title") || "E-Mail an Auswahl",
|
||||
body,
|
||||
size: "lg",
|
||||
primary: {
|
||||
label: `${t("team.broadcast.send") || "Senden"} (${args.recipients.length})`,
|
||||
handler: async (close) => {
|
||||
await onSubmit(body, args, close);
|
||||
},
|
||||
},
|
||||
secondary: { label: t("common.cancel") || "Abbrechen" },
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(args: OpenBroadcastModalArgs): string {
|
||||
function renderBody(args: OpenBroadcastModalArgs): HTMLElement {
|
||||
const root = document.createElement("div");
|
||||
root.className = "broadcast-body";
|
||||
const count = args.recipients.length;
|
||||
const previewItems = args.recipients
|
||||
.slice(0, 5)
|
||||
.map((r) => esc(r.display_name) + " <" + esc(r.email) + ">")
|
||||
.join(", ");
|
||||
const more = count > 5 ? ` +${count - 5}` : "";
|
||||
|
||||
const fullList = args.recipients
|
||||
.map(
|
||||
(r) =>
|
||||
@@ -150,65 +126,89 @@ function renderShell(args: OpenBroadcastModalArgs): string {
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
|
||||
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">×</button>
|
||||
</header>
|
||||
<form data-broadcast-form>
|
||||
<div class="modal-body">
|
||||
<div class="broadcast-recipient-summary">
|
||||
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
||||
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
||||
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
|
||||
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
|
||||
</a>
|
||||
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
||||
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
||||
<ul>${fullList}</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
|
||||
<select id="broadcast-template-select" data-broadcast-template>
|
||||
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
|
||||
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
|
||||
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
|
||||
</select>
|
||||
|
||||
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
|
||||
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
|
||||
|
||||
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
|
||||
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
|
||||
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
|
||||
</p>
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
|
||||
</p>
|
||||
|
||||
<div class="broadcast-error hidden" data-broadcast-error></div>
|
||||
<div class="broadcast-success hidden" data-broadcast-success></div>
|
||||
</div>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
|
||||
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
|
||||
</footer>
|
||||
</form>
|
||||
root.innerHTML = `
|
||||
<div class="broadcast-recipient-summary">
|
||||
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
||||
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
||||
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
|
||||
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
|
||||
</a>
|
||||
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
||||
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
||||
<ul>${fullList}</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
|
||||
<select id="broadcast-template-select" data-broadcast-template>
|
||||
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
|
||||
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
|
||||
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
|
||||
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
|
||||
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
|
||||
</div>
|
||||
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
|
||||
</p>
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
|
||||
</p>
|
||||
|
||||
<div class="broadcast-error hidden" data-broadcast-error></div>
|
||||
<div class="broadcast-success hidden" data-broadcast-success></div>
|
||||
`;
|
||||
return root;
|
||||
}
|
||||
|
||||
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
|
||||
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
|
||||
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
|
||||
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
|
||||
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
|
||||
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
|
||||
function wireBody(body: HTMLElement): void {
|
||||
// Recipient list toggle.
|
||||
body.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
||||
const list = body.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
||||
if (!list) return;
|
||||
list.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
// Template dropdown — populates subject/body from the selected template.
|
||||
const templateSelect = body.querySelector<HTMLSelectElement>("[data-broadcast-template]");
|
||||
templateSelect?.addEventListener("change", async () => {
|
||||
const key = templateSelect.value;
|
||||
if (!key) return;
|
||||
const lang = (document.documentElement.lang || "de") as "de" | "en";
|
||||
try {
|
||||
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
|
||||
if (!res.ok) return;
|
||||
const tpl = (await res.json()) as EmailTemplateOption;
|
||||
const subjectInput = body.querySelector<HTMLInputElement>("[data-broadcast-subject]");
|
||||
const bodyInput = body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
|
||||
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
|
||||
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
|
||||
} catch {
|
||||
/* template load failure is non-fatal — sender keeps freeform mode. */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit(
|
||||
body: HTMLElement,
|
||||
args: OpenBroadcastModalArgs,
|
||||
close: (result: BroadcastResult) => void,
|
||||
): Promise<void> {
|
||||
const subject = (body.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
|
||||
const bodyText = (body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
|
||||
const templateKey = body.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
|
||||
const errEl = body.querySelector<HTMLDivElement>("[data-broadcast-error]");
|
||||
const okEl = body.querySelector<HTMLDivElement>("[data-broadcast-success]");
|
||||
errEl?.classList.add("hidden");
|
||||
okEl?.classList.add("hidden");
|
||||
|
||||
@@ -216,17 +216,15 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
if (!body) {
|
||||
if (!bodyText) {
|
||||
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
|
||||
}
|
||||
|
||||
// The modal primary button lives in the footer (owned by openModal),
|
||||
// not in the body. We surface "sending..." feedback via the in-body
|
||||
// success/error areas; the primary button stays clickable but the
|
||||
// server-side idempotency + RECIPIENT_CAP make double-clicks safe.
|
||||
const recipientFilter: Record<string, unknown> = {};
|
||||
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
|
||||
if (args.projectID) recipientFilter.project_id = args.projectID;
|
||||
@@ -242,7 +240,7 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
body: JSON.stringify({
|
||||
project_id: args.projectID ?? null,
|
||||
subject,
|
||||
body,
|
||||
body: bodyText,
|
||||
template_key: templateKey || undefined,
|
||||
lang,
|
||||
recipient_filter: recipientFilter,
|
||||
@@ -252,13 +250,9 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
|
||||
showError(errEl, (errBody as { error?: string }).error || "Send failed");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const report = (await res.json()) as { sent: number; failed: number; total: number };
|
||||
const report = (await res.json()) as BroadcastResult;
|
||||
if (okEl) {
|
||||
okEl.classList.remove("hidden");
|
||||
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
|
||||
@@ -267,17 +261,10 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
.replace("{total}", String(report.total))
|
||||
.replace("{failed}", String(report.failed));
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
|
||||
}
|
||||
setTimeout(() => overlay.remove(), 2500);
|
||||
// Give the sender a moment to see the report, then close.
|
||||
setTimeout(() => close(report), 2500);
|
||||
} catch (e) {
|
||||
showError(errEl, String(e));
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
435
frontend/src/client/components/approval-edit-modal.ts
Normal file
435
frontend/src/client/components/approval-edit-modal.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
// t-paliad-216 Slice B (initial) + t-paliad-217 Slice C (rewrite) —
|
||||
// modal for the "Suggest changes" approval action.
|
||||
//
|
||||
// The approver authors a counter-proposal: edits any field on the
|
||||
// underlying deadline / appointment AND/OR leaves a free-text note. On
|
||||
// submit the caller POSTs to /api/approval-requests/{id}/suggest-changes,
|
||||
// which closes the OLD row as `changes_requested` and spawns a NEW pending
|
||||
// row authored by the approver carrying counter_payload as its payload.
|
||||
//
|
||||
// Scope (t-paliad-217 m's Q1 Reading A — 2026-05-20):
|
||||
// - Every editable field on the entity is in the form, not just the
|
||||
// date allowlist that triggers approval (t-paliad-138 §Q4). The
|
||||
// backend's counter-allowlist (buildCounterSetClauses in
|
||||
// approval_service.go) accepts the wider set:
|
||||
// deadline: title, due_date, original_due_date, warning_date,
|
||||
// description, notes, rule_code, event_type_ids
|
||||
// appointment: title, start_at, end_at, description, location,
|
||||
// appointment_type
|
||||
// - Lifecycle restriction: update-only. shape-list.ts hides the
|
||||
// suggest_changes button for create / complete / delete; this modal
|
||||
// refuses to open on them as defence-in-depth.
|
||||
//
|
||||
// Built on the unified openModal() primitive (t-paliad-217 Slice A) —
|
||||
// the primitive owns ESC, focus, backdrop, close button, browser
|
||||
// back-button, mobile takeover. This module only constructs the body.
|
||||
//
|
||||
// API:
|
||||
// const result = await openApprovalEditModal({
|
||||
// entityType: "deadline",
|
||||
// lifecycleEvent: "update",
|
||||
// payload: {...}, // requester's proposed values (= current entity row)
|
||||
// preImage: {...}, // pre-mutation values (for "vorher" diff hints)
|
||||
// });
|
||||
// if (result) {
|
||||
// // result.counterPayload + result.note ready to POST
|
||||
// } else {
|
||||
// // user cancelled
|
||||
// }
|
||||
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
fetchEventTypes,
|
||||
type PickerHandle,
|
||||
} from "../event-types";
|
||||
import { openModal } from "./modal";
|
||||
|
||||
export interface ApprovalEditModalArgs {
|
||||
entityType: "deadline" | "appointment";
|
||||
lifecycleEvent: string;
|
||||
payload: Record<string, unknown> | null;
|
||||
preImage: Record<string, unknown> | null;
|
||||
// Optional context for the read-only context section. The caller can
|
||||
// hydrate these from the row's API response (project_title,
|
||||
// requester_name, requested_at) when available; the modal degrades
|
||||
// gracefully when they're missing.
|
||||
projectTitle?: string;
|
||||
requesterName?: string;
|
||||
requestedAt?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalEditModalResult {
|
||||
counterPayload: Record<string, unknown>;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// FieldSpec — one editable input row. The type determines the <input>
|
||||
// (or <textarea>) shape; getValue / setValue normalise the form-element
|
||||
// value to the server-friendly counter_payload shape.
|
||||
interface FieldSpec {
|
||||
key: string;
|
||||
labelKey: string; // i18n key
|
||||
inputType: "text" | "date" | "datetime-local" | "textarea";
|
||||
// Required = title (NOT NULL on the column). Other fields are nullable;
|
||||
// empty string clears (server's addText helper handles this).
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
// Deadline-only fields rendered in the editable section. `rule_code` and
|
||||
// `event_type_ids` are intentionally NOT here — they're bundled into the
|
||||
// dedicated "Verfahrenshandlung" section below the base fields so the
|
||||
// event-type (parent concept) reads before the rule (m/paliad#56).
|
||||
const DEADLINE_FIELDS: ReadonlyArray<FieldSpec> = [
|
||||
{ key: "title", labelKey: "deadlines.field.title", inputType: "text", required: true },
|
||||
{ key: "due_date", labelKey: "deadlines.field.due", inputType: "date" },
|
||||
{ key: "original_due_date", labelKey: "approvals.suggest.field.original_due_date", inputType: "date" },
|
||||
{ key: "warning_date", labelKey: "approvals.suggest.field.warning_date", inputType: "date" },
|
||||
{ key: "description", labelKey: "approvals.suggest.field.description", inputType: "textarea" },
|
||||
{ key: "notes", labelKey: "deadlines.field.notes", inputType: "textarea" },
|
||||
];
|
||||
|
||||
const APPOINTMENT_FIELDS: ReadonlyArray<FieldSpec> = [
|
||||
{ key: "title", labelKey: "appointments.field.title", inputType: "text", required: true },
|
||||
{ key: "start_at", labelKey: "appointments.field.start", inputType: "datetime-local" },
|
||||
{ key: "end_at", labelKey: "appointments.field.end", inputType: "datetime-local" },
|
||||
{ key: "location", labelKey: "appointments.field.location", inputType: "text" },
|
||||
{ key: "appointment_type", labelKey: "appointments.field.type", inputType: "text" },
|
||||
{ key: "description", labelKey: "appointments.field.description", inputType: "textarea" },
|
||||
];
|
||||
|
||||
export async function openApprovalEditModal(
|
||||
args: ApprovalEditModalArgs,
|
||||
): Promise<ApprovalEditModalResult | null> {
|
||||
if (args.lifecycleEvent !== "update") {
|
||||
window.alert(t("approvals.suggest.unsupported_lifecycle"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields = args.entityType === "deadline" ? DEADLINE_FIELDS : APPOINTMENT_FIELDS;
|
||||
const original = (args.payload ?? {}) as Record<string, unknown>;
|
||||
const preImage = (args.preImage ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Build the body element imperatively so we can wire input handlers
|
||||
// before openModal mounts the dialog.
|
||||
const body = document.createElement("div");
|
||||
body.className = "approval-suggest-body";
|
||||
|
||||
body.appendChild(renderIntro());
|
||||
body.appendChild(renderFieldsSection(fields, original, preImage));
|
||||
|
||||
// event_type_ids picker (deadline-only) — async because the picker
|
||||
// needs to fetch the firm's event-type catalogue. We attach a host
|
||||
// element synchronously and populate it once the fetch returns.
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let eventTypePickerLoaded = false;
|
||||
if (args.entityType === "deadline") {
|
||||
const pickerSection = renderEventTypePickerSection(original, preImage);
|
||||
body.appendChild(pickerSection.section);
|
||||
void (async () => {
|
||||
try {
|
||||
await fetchEventTypes();
|
||||
eventTypePicker = attachEventTypePicker(pickerSection.host, {
|
||||
initialIDs: (original.event_type_ids as string[] | undefined) ?? [],
|
||||
});
|
||||
eventTypePickerLoaded = true;
|
||||
} catch (_e) {
|
||||
// Fail-soft: leave the section empty; counter still works
|
||||
// without event_type_ids in the payload.
|
||||
pickerSection.host.textContent = t("approvals.suggest.event_type_picker_unavailable");
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
body.appendChild(renderContextSection(args, original));
|
||||
const noteEl = renderNoteSection();
|
||||
body.appendChild(noteEl.section);
|
||||
|
||||
// Read inputs back at submit time. The same list is what we listen to
|
||||
// for the dirty-state gate.
|
||||
const fieldInputs = Array.from(
|
||||
body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>("[data-suggest-field]"),
|
||||
);
|
||||
|
||||
return openModal<ApprovalEditModalResult>({
|
||||
title: `${t("approvals.suggest.modal_title")} — ${t(("approvals.entity." + args.entityType) as never)}`,
|
||||
body,
|
||||
size: "lg",
|
||||
primary: {
|
||||
label: t("approvals.suggest.submit"),
|
||||
handler: (close) => {
|
||||
const result = buildResult(fieldInputs, noteEl.textarea, original, eventTypePicker, eventTypePickerLoaded);
|
||||
if (!result.dirty && !result.note) {
|
||||
// Server enforces too. Client-side guard avoids the 400 round-trip.
|
||||
window.alert(t("approvals.suggest.submit_disabled_hint"));
|
||||
return;
|
||||
}
|
||||
close({
|
||||
counterPayload: result.counterPayload,
|
||||
note: result.note,
|
||||
});
|
||||
},
|
||||
},
|
||||
secondary: { label: t("approvals.suggest.cancel") },
|
||||
});
|
||||
}
|
||||
|
||||
function renderIntro(): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
p.className = "approval-suggest-intro muted";
|
||||
p.textContent = t("approvals.suggest.intro");
|
||||
return p;
|
||||
}
|
||||
|
||||
function renderFieldsSection(
|
||||
fields: ReadonlyArray<FieldSpec>,
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--editable";
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("approvals.suggest.section.editable");
|
||||
section.appendChild(h);
|
||||
|
||||
for (const f of fields) {
|
||||
section.appendChild(renderSingleField(f, original, preImage));
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
// Verfahrenshandlung section — bundles the event-type picker and the
|
||||
// rule_code input so the editor reads "what procedural step? which rule
|
||||
// cites it?" instead of two disconnected fields with rule above type
|
||||
// (m/paliad#56). The hint underneath spells out the parent/child
|
||||
// relationship so first-time editors don't read them as peers.
|
||||
function renderEventTypePickerSection(
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): { section: HTMLElement; host: HTMLElement } {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--editable";
|
||||
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("approvals.suggest.section.event_type_rule");
|
||||
section.appendChild(h);
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.className = "approval-suggest-event-type-picker";
|
||||
section.appendChild(host);
|
||||
|
||||
// Rule citation — rendered as a sub-field directly beneath the picker so
|
||||
// the visual hierarchy matches the conceptual one (rule is meta on the
|
||||
// event type, not a peer).
|
||||
const ruleField: FieldSpec = {
|
||||
key: "rule_code",
|
||||
labelKey: "approvals.suggest.field.rule_code",
|
||||
inputType: "text",
|
||||
};
|
||||
section.appendChild(renderSingleField(ruleField, original, preImage));
|
||||
|
||||
return { section, host };
|
||||
}
|
||||
|
||||
// renderSingleField builds one labelled input in the same shape as the
|
||||
// fields-section loop. Extracted so the Verfahrenshandlung section can
|
||||
// host the rule_code input next to the picker without duplicating the
|
||||
// wiring (dirty-tracking, pre_image hint, label/for binding).
|
||||
function renderSingleField(
|
||||
f: FieldSpec,
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-field approval-suggest-field";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = t(f.labelKey as never);
|
||||
wrap.appendChild(label);
|
||||
|
||||
const value = formatFieldForInput(original[f.key], f.inputType);
|
||||
|
||||
let input: HTMLInputElement | HTMLTextAreaElement;
|
||||
if (f.inputType === "textarea") {
|
||||
input = document.createElement("textarea");
|
||||
input.rows = 3;
|
||||
(input as HTMLTextAreaElement).value = value;
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
(input as HTMLInputElement).type = f.inputType;
|
||||
(input as HTMLInputElement).value = value;
|
||||
}
|
||||
input.dataset.suggestField = f.key;
|
||||
input.dataset.suggestOriginal = value;
|
||||
input.dataset.suggestInputType = f.inputType;
|
||||
if (f.required) input.required = true;
|
||||
|
||||
const inputID = `suggest-field-${f.key}`;
|
||||
input.id = inputID;
|
||||
label.setAttribute("for", inputID);
|
||||
|
||||
wrap.appendChild(input);
|
||||
|
||||
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
|
||||
if (preVal && preVal !== value) {
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "approval-suggest-prehint";
|
||||
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
|
||||
wrap.appendChild(hint);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderContextSection(
|
||||
args: ApprovalEditModalArgs,
|
||||
original: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--context";
|
||||
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("approvals.suggest.section.context");
|
||||
section.appendChild(h);
|
||||
|
||||
const rows: Array<[string, string]> = [];
|
||||
if (args.projectTitle) {
|
||||
rows.push([t("approvals.suggest.context.project"), args.projectTitle]);
|
||||
}
|
||||
if (args.requesterName) {
|
||||
rows.push([t("approvals.suggest.context.requester"), args.requesterName]);
|
||||
}
|
||||
if (args.requestedAt) {
|
||||
rows.push([t("approvals.suggest.context.requested_at"), formatDateForDisplay(args.requestedAt)]);
|
||||
}
|
||||
// Approval status — entity row's current approval_status (typically
|
||||
// "pending" while the modal is open, but display the requester's
|
||||
// perspective for completeness).
|
||||
const approvalStatus = original.approval_status as string | undefined;
|
||||
if (approvalStatus) {
|
||||
rows.push([
|
||||
t("approvals.suggest.context.approval_status"),
|
||||
t(("approvals.status." + approvalStatus) as never) || approvalStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
section.style.display = "none";
|
||||
return section;
|
||||
}
|
||||
|
||||
const dl = document.createElement("dl");
|
||||
dl.className = "approval-suggest-context-grid";
|
||||
for (const [label, value] of rows) {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = label;
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = value;
|
||||
dl.appendChild(dt);
|
||||
dl.appendChild(dd);
|
||||
}
|
||||
section.appendChild(dl);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderNoteSection(): { section: HTMLElement; textarea: HTMLTextAreaElement } {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--note";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-field approval-suggest-note";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = t("approvals.suggest.note_label");
|
||||
label.setAttribute("for", "suggest-note");
|
||||
wrap.appendChild(label);
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.id = "suggest-note";
|
||||
textarea.rows = 3;
|
||||
textarea.placeholder = t("approvals.suggest.note_placeholder");
|
||||
textarea.dataset.suggestNote = "true";
|
||||
wrap.appendChild(textarea);
|
||||
|
||||
section.appendChild(wrap);
|
||||
return { section, textarea };
|
||||
}
|
||||
|
||||
interface BuildResult {
|
||||
counterPayload: Record<string, unknown>;
|
||||
note: string;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
fieldInputs: ReadonlyArray<HTMLInputElement | HTMLTextAreaElement>,
|
||||
noteEl: HTMLTextAreaElement,
|
||||
original: Record<string, unknown>,
|
||||
eventTypePicker: PickerHandle | null,
|
||||
eventTypePickerLoaded: boolean,
|
||||
): BuildResult {
|
||||
const counterPayload: Record<string, unknown> = {};
|
||||
let dirty = false;
|
||||
|
||||
for (const el of fieldInputs) {
|
||||
const key = el.dataset.suggestField || "";
|
||||
const orig = el.dataset.suggestOriginal || "";
|
||||
const inputType = el.dataset.suggestInputType || "text";
|
||||
if (el.value === orig) continue;
|
||||
counterPayload[key] = formatFieldForServer(el.value, inputType);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (eventTypePicker && eventTypePickerLoaded) {
|
||||
const currentIDs = eventTypePicker.getIDs().slice().sort();
|
||||
const originalIDs = ((original.event_type_ids as string[] | undefined) ?? []).slice().sort();
|
||||
if (currentIDs.length !== originalIDs.length
|
||||
|| currentIDs.some((id, i) => id !== originalIDs[i])) {
|
||||
counterPayload.event_type_ids = currentIDs;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
counterPayload,
|
||||
note: noteEl.value.trim(),
|
||||
dirty,
|
||||
};
|
||||
}
|
||||
|
||||
// formatFieldForInput — convert a server-side payload value to the format
|
||||
// the <input> wants. Dates round-trip as YYYY-MM-DD; datetime-local wants
|
||||
// YYYY-MM-DDTHH:MM. Server returns ISO 8601 / RFC 3339 timestamps; we
|
||||
// trim to the local-input shape. Text passes through verbatim.
|
||||
function formatFieldForInput(v: unknown, inputType: string): string {
|
||||
if (v == null) return "";
|
||||
const s = String(v);
|
||||
if (inputType === "date") {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
return m ? m[1] : s;
|
||||
}
|
||||
if (inputType === "datetime-local") {
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/);
|
||||
return m ? `${m[1]}T${m[2]}` : s;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// formatFieldForServer — convert input value back to server-friendly
|
||||
// shape. Empty string means "clear this nullable field"; the server's
|
||||
// addText helper writes NULL for "". Required fields (title) reach the
|
||||
// server's non-empty CHECK on the column, which surfaces as a 400.
|
||||
function formatFieldForServer(value: string, inputType: string): unknown {
|
||||
if (inputType === "date" || inputType === "datetime-local") {
|
||||
return value || null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatDateForDisplay(iso: string): string {
|
||||
const d = Date.parse(iso);
|
||||
if (isNaN(d)) return iso;
|
||||
return new Date(d).toLocaleString();
|
||||
}
|
||||
200
frontend/src/client/components/modal.ts
Normal file
200
frontend/src/client/components/modal.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
// Unified modal primitive — t-paliad-217.
|
||||
//
|
||||
// Native <dialog>-backed. The browser handles top-layer stacking, ESC,
|
||||
// ARIA, and focus trap. We layer back-button integration and focus
|
||||
// restoration on top so the modal behaves consistently on desktop and on
|
||||
// the iPhone PWA (m's checking surface).
|
||||
//
|
||||
// API:
|
||||
// const result = await openModal<MyResult>({
|
||||
// title: "…",
|
||||
// body: htmlStringOrElement,
|
||||
// primary: { label: "Speichern", handler: (close) => { close(result); } },
|
||||
// secondary: { label: "Abbrechen" }, // optional, defaults to "Abbrechen"
|
||||
// size: "sm" | "md" | "lg" | "full", // optional, defaults to "md"
|
||||
// onClose: () => { /* … */ },
|
||||
// classNames: "extra css classes on the <dialog>",
|
||||
// });
|
||||
// // result is the value passed to close(), or null if the user
|
||||
// // dismissed via ESC / backdrop / secondary / browser back-button.
|
||||
//
|
||||
// All dismiss paths are unified: ESC, backdrop click, secondary button,
|
||||
// the always-rendered close (×) button, and the browser back-button all
|
||||
// resolve the promise with null. Programmatic close from the primary
|
||||
// handler resolves with whatever was passed.
|
||||
//
|
||||
// Migration target: call sites that currently roll their own
|
||||
// modal-overlay + ESC handler + focus management replace all of it with
|
||||
// one openModal() call. broadcast.ts and approval-edit-modal.ts are the
|
||||
// first two call sites (t-paliad-217 Slices C + D); the other ~5 legacy
|
||||
// modals migrate in follow-up PRs.
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
export interface ModalConfig<T> {
|
||||
title: string;
|
||||
// body can be either a pre-built HTMLElement (the caller assembled the
|
||||
// DOM and may have local references for read-back) or an HTML string
|
||||
// (caller is responsible for escaping). Element is preferred when the
|
||||
// caller needs to read form state on submit.
|
||||
body: HTMLElement | string;
|
||||
primary: {
|
||||
label: string;
|
||||
handler: (close: (result: T) => void) => void | Promise<void>;
|
||||
};
|
||||
// secondary defaults to a Cancel button that just dismisses. Pass null
|
||||
// explicitly to suppress (rare — primary-only modals like a confirmation
|
||||
// toast).
|
||||
secondary?: { label: string } | null;
|
||||
size?: "sm" | "md" | "lg" | "full";
|
||||
// onClose fires on EVERY dismiss path (including primary handler
|
||||
// resolution). Use for analytics / dirty-state warnings.
|
||||
onClose?: () => void;
|
||||
classNames?: string;
|
||||
}
|
||||
|
||||
// openModal returns a promise that resolves with the value passed to
|
||||
// close() inside the primary handler, or null if the user dismissed via
|
||||
// any other path. Always non-throwing — the primary handler decides
|
||||
// whether to surface errors via its own UI (e.g. inline form errors)
|
||||
// rather than rejecting the promise.
|
||||
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
// Record + restore focus to whatever was focused before the modal
|
||||
// opened. Native <dialog> does NOT do this automatically.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = ["modal", config.classNames].filter(Boolean).join(" ");
|
||||
dialog.dataset.size = config.size ?? "md";
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.className = "modal__header";
|
||||
const titleEl = document.createElement("h2");
|
||||
titleEl.className = "modal__title";
|
||||
titleEl.textContent = config.title;
|
||||
header.appendChild(titleEl);
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.type = "button";
|
||||
closeBtn.className = "modal__close";
|
||||
closeBtn.setAttribute("aria-label", t("modal.close.label"));
|
||||
closeBtn.textContent = "×"; // ×
|
||||
header.appendChild(closeBtn);
|
||||
dialog.appendChild(header);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "modal__body";
|
||||
if (typeof config.body === "string") {
|
||||
body.innerHTML = config.body;
|
||||
} else {
|
||||
body.appendChild(config.body);
|
||||
}
|
||||
dialog.appendChild(body);
|
||||
|
||||
const footer = document.createElement("footer");
|
||||
footer.className = "modal__footer";
|
||||
const secondaryCfg = config.secondary === null
|
||||
? null
|
||||
: config.secondary ?? { label: t("common.cancel") };
|
||||
let secondaryBtn: HTMLButtonElement | null = null;
|
||||
if (secondaryCfg) {
|
||||
secondaryBtn = document.createElement("button");
|
||||
secondaryBtn.type = "button";
|
||||
secondaryBtn.className = "btn btn-ghost modal__secondary";
|
||||
secondaryBtn.textContent = secondaryCfg.label;
|
||||
footer.appendChild(secondaryBtn);
|
||||
}
|
||||
const primaryBtn = document.createElement("button");
|
||||
primaryBtn.type = "button";
|
||||
primaryBtn.className = "btn btn-primary modal__primary";
|
||||
primaryBtn.textContent = config.primary.label;
|
||||
footer.appendChild(primaryBtn);
|
||||
dialog.appendChild(footer);
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
// History integration (Q5): push a synthetic history state so the
|
||||
// browser back-button closes the modal instead of leaving the page.
|
||||
// We pop the state in finish() unless popstate already fired it.
|
||||
let historyEntryActive = false;
|
||||
try {
|
||||
history.pushState({ paliadModalOpen: true }, "");
|
||||
historyEntryActive = true;
|
||||
} catch (_e) {
|
||||
// pushState may throw in obscure embedded contexts; degrade gracefully.
|
||||
}
|
||||
|
||||
// resolved guards against double-resolution (e.g. ESC fires + then a
|
||||
// microtask-deferred primary handler also calls close).
|
||||
let resolved = false;
|
||||
|
||||
const finish = (value: T | null) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
|
||||
window.removeEventListener("popstate", onPopState);
|
||||
|
||||
// Pop our history entry if it's still on the stack. Skip when the
|
||||
// popstate listener already fired (otherwise we'd go back twice).
|
||||
if (historyEntryActive) {
|
||||
historyEntryActive = false;
|
||||
try { history.back(); } catch (_e) { /* same fallback as pushState */ }
|
||||
}
|
||||
|
||||
// Native dialog close. Use the close event's default rather than
|
||||
// the cancel event so we don't fight the browser's own dismissal.
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
|
||||
// Restore focus to whatever the user was on before. The dialog
|
||||
// teardown happens synchronously so the focus call lands on a
|
||||
// live element.
|
||||
if (previouslyFocused && document.body.contains(previouslyFocused)) {
|
||||
previouslyFocused.focus();
|
||||
}
|
||||
|
||||
config.onClose?.();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const close = (result: T) => finish(result);
|
||||
|
||||
// Dismiss paths.
|
||||
closeBtn.addEventListener("click", () => finish(null));
|
||||
secondaryBtn?.addEventListener("click", () => finish(null));
|
||||
dialog.addEventListener("click", (e) => {
|
||||
// Backdrop click — only when the click landed on the dialog element
|
||||
// itself (not on a child). Browsers report dialog.click events
|
||||
// through the backdrop too because the backdrop is conceptually
|
||||
// part of the dialog's box.
|
||||
if (e.target === dialog) finish(null);
|
||||
});
|
||||
// <dialog>'s cancel event fires on ESC. preventDefault stops the
|
||||
// browser's default close so we can run our finish() (history pop,
|
||||
// focus restore, onClose, resolve).
|
||||
dialog.addEventListener("cancel", (e) => {
|
||||
e.preventDefault();
|
||||
finish(null);
|
||||
});
|
||||
const onPopState = () => {
|
||||
// Browser back-button. Our history entry is gone by the time this
|
||||
// fires, so skip the history.back() in finish().
|
||||
historyEntryActive = false;
|
||||
finish(null);
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
|
||||
// Primary action.
|
||||
primaryBtn.addEventListener("click", () => {
|
||||
const result = config.primary.handler(close);
|
||||
// Allow async primary handlers (handler returns a promise) — we
|
||||
// don't wait for it explicitly; the handler is responsible for
|
||||
// calling close() when ready.
|
||||
void result;
|
||||
});
|
||||
|
||||
// Open the dialog in the top layer. showModal activates ARIA
|
||||
// role="dialog" + aria-modal=true + focus trap + backdrop.
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
@@ -65,14 +65,60 @@ interface DashboardData {
|
||||
upcoming_deadlines: UpcomingDeadline[];
|
||||
upcoming_appointments: UpcomingAppointment[];
|
||||
recent_activity: ActivityEntry[];
|
||||
inbox_summary?: InboxSummary;
|
||||
}
|
||||
|
||||
interface InboxEntry {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_title?: string | null;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
requested_at: string;
|
||||
requester_id: string;
|
||||
requester_name: string;
|
||||
}
|
||||
|
||||
interface InboxSummary {
|
||||
pending_count: number;
|
||||
top: InboxEntry[];
|
||||
}
|
||||
|
||||
// DashboardLayoutSpec mirrors the Go shape in
|
||||
// internal/services/dashboard_layout_spec.go. The client treats the spec
|
||||
// as advice: unknown widget keys are dropped silently (server is the
|
||||
// source of truth for the catalog).
|
||||
interface DashboardWidgetRef {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
settings?: { count?: number; horizon_days?: number };
|
||||
}
|
||||
interface DashboardLayoutSpec {
|
||||
v: number;
|
||||
widgets: DashboardWidgetRef[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PALIAD_DASHBOARD__?: DashboardData | null;
|
||||
__PALIAD_DASHBOARD_LAYOUT__?: DashboardLayoutSpec | null;
|
||||
__PALIAD_DASHBOARD_CATALOG__?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
let currentLayout: DashboardLayoutSpec | null = null;
|
||||
|
||||
// settingsFor returns the (possibly-empty) settings blob for a given
|
||||
// widget key in the active layout. Falls back to an empty object so
|
||||
// renderers can read `.count ?? defaultN` without null checks.
|
||||
function settingsFor(key: string): { count?: number; horizon_days?: number } {
|
||||
if (!currentLayout) return {};
|
||||
for (const w of currentLayout.widgets) {
|
||||
if (w.key === key) return w.settings ?? {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
// 30-day look-ahead matches the agenda.tsx default chip and the server's
|
||||
// default `to=today+30d` window — keeps the inline agenda visually
|
||||
@@ -110,7 +156,13 @@ function render(): void {
|
||||
renderAppointments(data.upcoming_appointments);
|
||||
renderAgenda();
|
||||
renderActivity(data.recent_activity);
|
||||
renderInbox(data.inbox_summary ?? { pending_count: 0, top: [] });
|
||||
toggleOnboardingHint(data.user);
|
||||
// Apply the saved layout AFTER renderers so the per-widget settings
|
||||
// applied above (count truncation, horizon filtering) are stable
|
||||
// before we toggle visibility + reorder. Failing to find the layout
|
||||
// is non-fatal — the factory default markup order takes over.
|
||||
applyLayout();
|
||||
}
|
||||
|
||||
function renderGreeting(user: DashboardUser | null): void {
|
||||
@@ -162,6 +214,13 @@ function renderDeadlines(items: UpcomingDeadline[]): void {
|
||||
const list = document.getElementById("dashboard-deadlines-list")!;
|
||||
const empty = document.getElementById("dashboard-deadlines-empty")!;
|
||||
|
||||
// Per-widget settings: truncate by count + filter by horizon. Backend
|
||||
// returns 40 rows / 60d; the widget settings narrow it. Defaults match
|
||||
// the catalog (10 rows, 30 days).
|
||||
const s = settingsFor("upcoming-deadlines");
|
||||
items = filterByHorizonDays(items, s.horizon_days ?? 30, (d) => d.due_date);
|
||||
items = items.slice(0, s.count ?? 10);
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
@@ -191,6 +250,10 @@ function renderAppointments(items: UpcomingAppointment[]): void {
|
||||
const list = document.getElementById("dashboard-appointments-list")!;
|
||||
const empty = document.getElementById("dashboard-appointments-empty")!;
|
||||
|
||||
const s = settingsFor("upcoming-appointments");
|
||||
items = filterByHorizonDays(items, s.horizon_days ?? 30, (a) => a.start_at);
|
||||
items = items.slice(0, s.count ?? 10);
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
@@ -226,6 +289,9 @@ function renderActivity(items: ActivityEntry[]): void {
|
||||
const list = document.getElementById("dashboard-activity-list")!;
|
||||
const empty = document.getElementById("dashboard-activity-empty")!;
|
||||
|
||||
const s = settingsFor("recent-activity");
|
||||
items = items.slice(0, s.count ?? 10);
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
@@ -344,8 +410,10 @@ function renderAgenda(): void {
|
||||
}
|
||||
|
||||
async function loadAgenda(): Promise<void> {
|
||||
const s = settingsFor("inline-agenda");
|
||||
const horizon = s.horizon_days ?? AGENDA_LOOKAHEAD_DAYS;
|
||||
const from = toAgendaDate(startOfToday());
|
||||
const to = toAgendaDate(addDays(startOfToday(), AGENDA_LOOKAHEAD_DAYS - 1));
|
||||
const to = toAgendaDate(addDays(startOfToday(), horizon - 1));
|
||||
try {
|
||||
const resp = await fetch(`/api/agenda?from=${from}&to=${to}&types=deadlines,appointments`);
|
||||
if (!resp.ok) {
|
||||
@@ -439,6 +507,125 @@ function syncCollapseAriaLabels(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderInbox(s: InboxSummary): void {
|
||||
const summary = document.getElementById("dashboard-inbox-summary");
|
||||
const list = document.getElementById("dashboard-inbox-list");
|
||||
const empty = document.getElementById("dashboard-inbox-empty");
|
||||
if (!summary || !list || !empty) return;
|
||||
|
||||
const settings = settingsFor("inbox-approvals");
|
||||
const cap = settings.count ?? 3;
|
||||
const top = s.top.slice(0, cap);
|
||||
|
||||
if (s.pending_count === 0) {
|
||||
summary.style.display = "none";
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
summary.style.display = "block";
|
||||
summary.textContent = getLang() === "de"
|
||||
? `${s.pending_count} offene Freigaben warten auf dich.`
|
||||
: `${s.pending_count} open approvals are waiting for you.`;
|
||||
list.style.display = "";
|
||||
list.innerHTML = top.map((e) => {
|
||||
const entityLabel = e.entity_type === "deadline"
|
||||
? tDyn("dashboard.inbox.entity.deadline")
|
||||
: (e.entity_type === "appointment"
|
||||
? tDyn("dashboard.inbox.entity.appointment")
|
||||
: e.entity_type);
|
||||
const title = e.entity_title || entityLabel;
|
||||
return `<li class="dashboard-list-item">
|
||||
<a href="/inbox" class="dashboard-list-link">
|
||||
<div class="dashboard-list-main">
|
||||
<span class="dashboard-list-title">${esc(title)}</span>
|
||||
<span class="dashboard-list-ref" title="${escAttr(`${e.project_title} · ${e.requester_name}`)}">${esc(e.project_title)} · ${esc(e.requester_name)}</span>
|
||||
</div>
|
||||
<div class="dashboard-list-meta">
|
||||
<span class="dashboard-appt-time">${esc(formatDateTime(e.requested_at))}</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// applyLayout walks the saved DashboardLayoutSpec and hides widgets whose
|
||||
// keys are `visible: false`, then reorders the visible ones to match the
|
||||
// layout's order. Widgets in the layout but missing from the DOM are
|
||||
// ignored (the catalog must define the markup for them — Slice A has
|
||||
// every catalog widget pre-rendered in dashboard.tsx). Widgets in the
|
||||
// DOM but missing from the layout (e.g. a deploy added markup ahead of a
|
||||
// migration) stay in their authored position so nothing disappears
|
||||
// silently.
|
||||
//
|
||||
// Reordering target: the visible widgets live in two parents — the
|
||||
// outer .container and the .dashboard-columns 2-up grid. We respect
|
||||
// that boundary: widgets inside .dashboard-columns are reordered within
|
||||
// it; widgets outside are reordered relative to each other inside
|
||||
// .container. This keeps the existing 2-up behaviour for the
|
||||
// deadlines+appointments pair without forcing a full container flatten.
|
||||
function applyLayout(): void {
|
||||
if (!currentLayout || !Array.isArray(currentLayout.widgets)) return;
|
||||
|
||||
// Discover widget elements once. data-widget-key set in dashboard.tsx.
|
||||
const allWidgets = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-widget-key]"),
|
||||
);
|
||||
if (!allWidgets.length) return;
|
||||
const byKey = new Map<string, HTMLElement>();
|
||||
allWidgets.forEach((el) => {
|
||||
const k = el.dataset.widgetKey;
|
||||
if (k) byKey.set(k, el);
|
||||
});
|
||||
|
||||
// Hide widgets whose layout entry says visible:false. Anything not in
|
||||
// the layout at all stays untouched.
|
||||
const seenInLayout = new Set<string>();
|
||||
for (const w of currentLayout.widgets) {
|
||||
seenInLayout.add(w.key);
|
||||
const el = byKey.get(w.key);
|
||||
if (!el) continue;
|
||||
el.style.display = w.visible ? "" : "none";
|
||||
}
|
||||
|
||||
// Reorder visible widgets inside each parent. We group widgets by their
|
||||
// current parent element so we don't move them out of .dashboard-columns
|
||||
// and lose the 2-up grid layout.
|
||||
const groups = new Map<HTMLElement, HTMLElement[]>();
|
||||
for (const w of currentLayout.widgets) {
|
||||
if (!w.visible) continue;
|
||||
const el = byKey.get(w.key);
|
||||
if (!el || !el.parentElement) continue;
|
||||
const arr = groups.get(el.parentElement) ?? [];
|
||||
arr.push(el);
|
||||
groups.set(el.parentElement, arr);
|
||||
}
|
||||
groups.forEach((widgets, parent) => {
|
||||
widgets.forEach((el) => parent.appendChild(el));
|
||||
});
|
||||
}
|
||||
|
||||
// filterByHorizonDays drops items whose key date is more than `days`
|
||||
// days from today. Items without a parseable date stay in (we don't
|
||||
// want to silently hide rows on bad data). today is inclusive.
|
||||
function filterByHorizonDays<T>(items: T[], days: number, key: (t: T) => string): T[] {
|
||||
if (!Number.isFinite(days) || days <= 0) return items;
|
||||
const cutoff = new Date();
|
||||
cutoff.setHours(0, 0, 0, 0);
|
||||
cutoff.setDate(cutoff.getDate() + days);
|
||||
return items.filter((t) => {
|
||||
const raw = key(t);
|
||||
if (!raw) return true;
|
||||
// due_date is "YYYY-MM-DD"; start_at is RFC 3339. Both parseable
|
||||
// by Date.
|
||||
const d = new Date(raw.length === 10 ? raw + "T00:00:00" : raw);
|
||||
if (isNaN(d.getTime())) return true;
|
||||
return d.getTime() <= cutoff.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleOnboardingHint(user: DashboardUser | null): void {
|
||||
// Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
|
||||
// already redirects users without a paliad.users row to /onboarding before
|
||||
@@ -518,6 +705,23 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
syncCollapseAriaLabels();
|
||||
});
|
||||
|
||||
// Configurable layout (t-paliad-219). The Go shell handler splices
|
||||
// the user's saved layout into __PALIAD_DASHBOARD_LAYOUT__. If it's
|
||||
// missing (knowledge-platform-only deploy, hydration failure), the
|
||||
// dashboard renders the factory order baked into dashboard.tsx; the
|
||||
// client also kicks off a best-effort fetch so a slow-hydrating user
|
||||
// still gets their saved layout on the next render pass.
|
||||
const layoutInline = window.__PALIAD_DASHBOARD_LAYOUT__;
|
||||
if (layoutInline) {
|
||||
currentLayout = layoutInline;
|
||||
} else if (layoutInline === undefined) {
|
||||
void fetch("/api/me/dashboard-layout").then(async (r) => {
|
||||
if (!r.ok) return;
|
||||
currentLayout = (await r.json()) as DashboardLayoutSpec;
|
||||
if (data) render();
|
||||
}).catch(() => { /* silent — factory order is the fallback */ });
|
||||
}
|
||||
|
||||
// Inline agenda fetch is independent of the main dashboard payload.
|
||||
// Kicked off in parallel so the agenda section paints as soon as the
|
||||
// /api/agenda response lands instead of waiting on the dashboard
|
||||
|
||||
@@ -125,8 +125,11 @@ const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
|
||||
{ value: "completed", key: "deadlines.filter.completed" },
|
||||
];
|
||||
|
||||
// Appointment status options — m/paliad#54: the legacy 'upcoming' /
|
||||
// "Ab heute" option was a UI lie (backend never narrowed past events for
|
||||
// appointments) and is removed. 'today' is the sane default — matches the
|
||||
// dashboard tile. 'all' stays as the explicit opt-in for past events.
|
||||
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
|
||||
{ value: "upcoming", key: "events.filter.status.upcoming" },
|
||||
{ value: "today", key: "deadlines.filter.today" },
|
||||
{ value: "this_week", key: "deadlines.filter.thisweek" },
|
||||
{ value: "next_week", key: "deadlines.filter.nextweek" },
|
||||
@@ -140,7 +143,7 @@ function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
|
||||
}
|
||||
|
||||
function defaultStatusFor(type: EventTypeChoice): string {
|
||||
return type === "appointment" ? "upcoming" : "pending";
|
||||
return type === "appointment" ? "today" : "pending";
|
||||
}
|
||||
|
||||
let currentType: EventTypeChoice = "deadline";
|
||||
|
||||
@@ -162,10 +162,11 @@ function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
{ value: "changes_requested", key: "views.bar.approval_status.changes_requested" },
|
||||
];
|
||||
|
||||
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// 3-step wizard: select proceeding -> enter date -> view timeline
|
||||
//
|
||||
// Rendering primitives (renderTimelineBody / renderColumnsBody /
|
||||
// deadlineCardHtml / formatDate / partyBadge / court picker) live in
|
||||
// `./views/verfahrensablauf-core` and are shared with the
|
||||
// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns
|
||||
// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override
|
||||
// click-to-edit — none of which Verfahrensablauf wants.
|
||||
// deadlineCardHtml / formatDate / partyBadge / court picker / inline
|
||||
// date editor) live in `./views/verfahrensablauf-core` and are shared
|
||||
// with /tools/verfahrensablauf. This module owns the Step1/2/3a
|
||||
// wizard, Pathway A/B, Akte save flow — none of which Verfahrensablauf
|
||||
// wants.
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
priorityRendering,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
@@ -57,6 +58,19 @@ type ProcedureView = "timeline" | "columns";
|
||||
// HLC team than the single vertical line.
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// Notes toggle — off by default; per-rule notes render as a compact
|
||||
// ⓘ hover icon. Flipped on, they expand under each card. Choice is
|
||||
// localStorage-persisted (paliad.fristen.notes-show key shared with
|
||||
// /tools/verfahrensablauf so the preference carries across both).
|
||||
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
|
||||
function readNotesPref(): boolean {
|
||||
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
function writeNotesPref(on: boolean): void {
|
||||
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
||||
}
|
||||
let showNotes = readNotesPref();
|
||||
|
||||
onLangChange(() => {
|
||||
if (lastResponse) renderProcedureResults(lastResponse);
|
||||
// Update trigger event name if a proceeding is selected
|
||||
@@ -173,12 +187,21 @@ interface ProjectOption {
|
||||
// (Slice 3b) can scope the cascade by the project's jurisdiction
|
||||
// without an extra fetch.
|
||||
proceeding_type_id?: number | null;
|
||||
// our_side carries which side the firm represents on this project
|
||||
// (t-paliad-164). When a user selects an Akte, the perspective chip
|
||||
// pre-locks to this value; a small hint above the strip flags the
|
||||
// our_side carries which side the firm represents on this case
|
||||
// project (Client Role; t-paliad-164, widened in t-paliad-222).
|
||||
// When a user selects an Akte, the perspective chip pre-locks via
|
||||
// ourSideToPerspective(); a small hint above the strip flags the
|
||||
// pre-selection and the user can still click another chip to
|
||||
// override. NULL/undefined leaves the chip unset (free-pick).
|
||||
our_side?: "claimant" | "defendant" | "court" | "both" | null;
|
||||
our_side?:
|
||||
| "claimant"
|
||||
| "defendant"
|
||||
| "applicant"
|
||||
| "appellant"
|
||||
| "respondent"
|
||||
| "third_party"
|
||||
| "other"
|
||||
| null;
|
||||
}
|
||||
|
||||
async function fetchProjects(): Promise<ProjectOption[]> {
|
||||
@@ -237,6 +260,19 @@ function closeSaveModal() {
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
// preselectedProjectId returns the project the user picked in Step 1
|
||||
// (if any) so the various save/add flows can default their project
|
||||
// pickers to it. Carries through anywhere a "save to Akte" pop-out
|
||||
// renders \u2014 preselection is *only* a default; the picker still
|
||||
// renders every available project and the user can override.
|
||||
// m/paliad#57 part 1: 2026-05-20 user complaint \u2014 "the pre-selected
|
||||
// project should be pre-selected" on Add.
|
||||
function preselectedProjectId(): string {
|
||||
return currentStep1Context.kind === "project" && currentStep1Context.projectId
|
||||
? currentStep1Context.projectId
|
||||
: "";
|
||||
}
|
||||
|
||||
async function openSaveModal() {
|
||||
if (!lastResponse) return;
|
||||
ensureSaveModal();
|
||||
@@ -253,6 +289,7 @@ async function openSaveModal() {
|
||||
sel.style.display = "";
|
||||
noProjects.style.display = "none";
|
||||
submit.disabled = false;
|
||||
const preselected = preselectedProjectId();
|
||||
sel.innerHTML = projects
|
||||
.map((p) => {
|
||||
const ref = (p.reference || "").trim();
|
||||
@@ -260,9 +297,11 @@ async function openSaveModal() {
|
||||
const label = ref
|
||||
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
|
||||
: `${indent}${escHtml(p.title)}`;
|
||||
return `<option value="${escAttr(p.id)}">${label}</option>`;
|
||||
const selected = p.id === preselected ? " selected" : "";
|
||||
return `<option value="${escAttr(p.id)}"${selected}>${label}</option>`;
|
||||
})
|
||||
.join("");
|
||||
if (preselected) sel.value = preselected;
|
||||
}
|
||||
|
||||
const list = document.getElementById("frist-save-list")!;
|
||||
@@ -391,8 +430,8 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true });
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
printBtn.style.display = "block";
|
||||
@@ -417,54 +456,21 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
applyPendingFocus();
|
||||
}
|
||||
|
||||
// openInlineDateEditor swaps the date span for a date input. On commit
|
||||
// (blur or Enter), the override is recorded and the timeline re-fetched.
|
||||
// On Escape, the editor closes without changing anything. An empty
|
||||
// commit clears the override (lets the user revert to the calculated
|
||||
// date or to the IsCourtSet placeholder).
|
||||
function openInlineDateEditor(span: HTMLElement) {
|
||||
const ruleCode = span.dataset.ruleCode!;
|
||||
const current = span.dataset.currentDate || anchorOverrides.get(ruleCode) || "";
|
||||
const editor = document.createElement("input");
|
||||
editor.type = "date";
|
||||
editor.className = "frist-date-edit-input";
|
||||
editor.value = current;
|
||||
|
||||
const commit = (newValue: string) => {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
void calculate();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
editor.replaceWith(span);
|
||||
};
|
||||
|
||||
editor.addEventListener("blur", () => {
|
||||
if (editor.value !== current) commit(editor.value);
|
||||
else cancel();
|
||||
});
|
||||
editor.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key === "Enter") {
|
||||
e.preventDefault();
|
||||
editor.blur();
|
||||
} else if (ke.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
span.replaceWith(editor);
|
||||
editor.focus();
|
||||
if (editor.value) editor.select();
|
||||
// onDateEditCommit is the click-to-edit callback handed to the shared
|
||||
// wireDateEditClicks() helper: persist the per-rule override (empty value
|
||||
// clears it) then recompute so downstream rules re-anchor.
|
||||
function onDateEditCommit(ruleCode: string, newValue: string) {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
void calculate();
|
||||
}
|
||||
|
||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to
|
||||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody /
|
||||
// openInlineDateEditor / wireDateEditClicks moved to
|
||||
// ./views/verfahrensablauf-core.
|
||||
|
||||
function reset() {
|
||||
selectedType = "";
|
||||
@@ -635,21 +641,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// rules re-anchor on the user's date. Delegated on the container so
|
||||
// it survives renderProcedureResults() innerHTML rewrites.
|
||||
const timelineContainer = document.getElementById("timeline-container");
|
||||
if (timelineContainer) {
|
||||
timelineContainer.addEventListener("click", (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
openInlineDateEditor(target);
|
||||
});
|
||||
timelineContainer.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
e.preventDefault();
|
||||
openInlineDateEditor(target);
|
||||
});
|
||||
}
|
||||
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
|
||||
|
||||
// Reset button
|
||||
document.getElementById("reset-btn")!.addEventListener("click", reset);
|
||||
@@ -661,6 +653,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const saveBtn = document.getElementById("fristen-save-cta");
|
||||
if (saveBtn) saveBtn.addEventListener("click", openSaveModal);
|
||||
|
||||
// Notes toggle — restores last preference on load + re-renders when
|
||||
// the user flips it. Lives in the same toggle bar as the view picker.
|
||||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||||
if (notesShowCb) {
|
||||
notesShowCb.checked = showNotes;
|
||||
notesShowCb.addEventListener("change", () => {
|
||||
showNotes = notesShowCb.checked;
|
||||
writeNotesPref(showNotes);
|
||||
if (lastResponse) renderProcedureResults(lastResponse);
|
||||
});
|
||||
}
|
||||
|
||||
// View toggle (timeline vs. columns layout) for procedure mode.
|
||||
initViewToggle();
|
||||
|
||||
@@ -1281,19 +1285,27 @@ function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) {
|
||||
card.classList.add("is-expanded");
|
||||
card.setAttribute("aria-expanded", "true");
|
||||
|
||||
const panel = buildCalcPanel(cardData, rulePills);
|
||||
card.appendChild(panel);
|
||||
// m/paliad#57 part 4: when the user clicked a specific rule pill, the
|
||||
// context is already known — the calc panel renders with that pill
|
||||
// locked in and no "Which context?" picker. The card's pill list is
|
||||
// hidden via CSS while is-expanded so the rules aren't listed twice.
|
||||
// When the user clicked the card body (no autoSelectPill), the picker
|
||||
// is the primary surface — still no duplicate pill list above it.
|
||||
const lockedPill = (autoSelectPill && autoSelectPill.dataset.kind === "rule")
|
||||
? rulePills.find((p) =>
|
||||
p.proceeding?.code === autoSelectPill.dataset.proc
|
||||
&& (autoSelectPill.dataset.focus
|
||||
? p.rule_local_code === autoSelectPill.dataset.focus
|
||||
: true))
|
||||
: undefined;
|
||||
|
||||
// Auto-select the clicked pill if it's a rule pill; otherwise the
|
||||
// first pill is preselected by buildCalcPanel.
|
||||
if (autoSelectPill && autoSelectPill.dataset.kind === "rule") {
|
||||
selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
|
||||
}
|
||||
const panel = buildCalcPanel(cardData, rulePills, lockedPill || null);
|
||||
card.appendChild(panel);
|
||||
|
||||
scheduleCardCalc(card);
|
||||
}
|
||||
|
||||
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLElement {
|
||||
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPill: SearchPill | null = null): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "fristen-card-calc";
|
||||
// stopPropagation so clicks inside the panel don't bubble to the
|
||||
@@ -1304,10 +1316,38 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
|
||||
const lang = getLang();
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
// Pill picker (only when >1 rule pill).
|
||||
const pickerHtml = rulePills.length <= 1
|
||||
? `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`
|
||||
: `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
|
||||
// Picker semantics (m/paliad#57 part 4):
|
||||
// - lockedPill set → context known (user clicked a specific
|
||||
// rule pill on the card). Render as a
|
||||
// hidden input only; the calc panel shows
|
||||
// no "Which context?" question. A small
|
||||
// "ändern" link reopens the picker fieldset.
|
||||
// - rulePills.length <= 1 → only one possible context, never a
|
||||
// picker (hidden input carries the data).
|
||||
// - otherwise → show the picker as primary surface; the
|
||||
// card's pill list is hidden via CSS while
|
||||
// the panel is open, so the user isn't
|
||||
// asked the same thing twice.
|
||||
let pickerHtml: string;
|
||||
if (lockedPill) {
|
||||
const procName = lockedPill.proceeding
|
||||
? (lang === "en" && lockedPill.proceeding.name_en ? lockedPill.proceeding.name_en : lockedPill.proceeding.name_de)
|
||||
: "";
|
||||
const ruleName = lang === "en" && lockedPill.rule_name_en ? lockedPill.rule_name_en : lockedPill.rule_name_de;
|
||||
const src = lockedPill.legal_source_display || lockedPill.legal_source || "";
|
||||
const reopenLabel = t("deadlines.card.calc.pill_picker.change");
|
||||
pickerHtml = `<div class="fristen-card-calc-pill-locked">
|
||||
<span class="fristen-card-calc-pill-locked-label">${escHtml(t("deadlines.card.calc.pill_picker.locked_label"))}</span>
|
||||
<span class="fristen-card-calc-pill-locked-proc">${escHtml(procName)}</span>
|
||||
<span class="fristen-card-calc-pill-locked-rule">${escHtml(ruleName)}</span>
|
||||
${src ? `<span class="fristen-card-calc-pill-locked-source">${escHtml(src)}</span>` : ""}
|
||||
${rulePills.length > 1 ? `<button type="button" class="fristen-card-calc-pill-change">${escHtml(reopenLabel)}</button>` : ""}
|
||||
<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(lockedPill.proceeding?.code || "")}" data-focus="${escAttr(lockedPill.rule_local_code || "")}" />
|
||||
</div>`;
|
||||
} else if (rulePills.length <= 1) {
|
||||
pickerHtml = `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`;
|
||||
} else {
|
||||
pickerHtml = `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
|
||||
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
|
||||
${rulePills.map((p, i) => {
|
||||
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
|
||||
@@ -1321,6 +1361,7 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
|
||||
</label>`;
|
||||
}).join("")}
|
||||
</fieldset>`;
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<button type="button" class="fristen-card-calc-close" aria-label="${escAttr(t("deadlines.card.calc.close"))}">×</button>
|
||||
@@ -1373,6 +1414,38 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
|
||||
void addCalcToProject(card, last);
|
||||
});
|
||||
|
||||
// "ändern" — swap the locked-context caption for the full radio
|
||||
// picker so the user can change context without collapsing the panel.
|
||||
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-pill-change")?.addEventListener("click", () => {
|
||||
const card = panel.closest<HTMLElement>(".fristen-card");
|
||||
const locked = panel.querySelector<HTMLElement>(".fristen-card-calc-pill-locked");
|
||||
if (!card || !locked) return;
|
||||
const fieldset = document.createElement("fieldset");
|
||||
fieldset.className = "fristen-card-calc-pill-picker";
|
||||
fieldset.setAttribute("role", "radiogroup");
|
||||
const lockedProc = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.proc || "";
|
||||
const lockedFocus = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.focus || "";
|
||||
fieldset.innerHTML = `
|
||||
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
|
||||
${rulePills.map((p, i) => {
|
||||
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
|
||||
const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
|
||||
const src = p.legal_source_display || p.legal_source || "";
|
||||
const isChecked = (p.proceeding?.code || "") === lockedProc
|
||||
&& (p.rule_local_code || "") === lockedFocus;
|
||||
return `<label class="fristen-card-calc-pill-option">
|
||||
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${isChecked ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
|
||||
<span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
|
||||
<span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
|
||||
${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
|
||||
</label>`;
|
||||
}).join("")}`;
|
||||
locked.replaceWith(fieldset);
|
||||
fieldset.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
|
||||
r.addEventListener("change", () => scheduleCardCalc(card, 0));
|
||||
});
|
||||
});
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -1576,6 +1649,7 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
|
||||
const lang = getLang();
|
||||
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
|
||||
const dueLabel = formatDate(calc.dueDate);
|
||||
const preselected = preselectedProjectId();
|
||||
msgEl.innerHTML = `
|
||||
<div class="fristen-card-calc-add-picker">
|
||||
<label class="fristen-card-calc-label">${escHtml(t("deadlines.save.modal.akte"))}
|
||||
@@ -1584,7 +1658,8 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
|
||||
const ref = (p.reference || "").trim();
|
||||
const indent = projectIndent(p.path);
|
||||
const label = ref ? `${indent}${ref} — ${p.title}` : `${indent}${p.title}`;
|
||||
return `<option value="${escAttr(p.id)}">${escHtml(label)}</option>`;
|
||||
const selected = p.id === preselected ? " selected" : "";
|
||||
return `<option value="${escAttr(p.id)}"${selected}>${escHtml(label)}</option>`;
|
||||
}).join("")}
|
||||
</select>
|
||||
</label>
|
||||
@@ -1594,6 +1669,7 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
|
||||
`;
|
||||
|
||||
const sel = msgEl.querySelector<HTMLSelectElement>(".fristen-card-calc-add-select")!;
|
||||
if (preselected) sel.value = preselected;
|
||||
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-cancel")!.addEventListener("click", () => {
|
||||
msgEl.innerHTML = "";
|
||||
addBtn.disabled = false;
|
||||
@@ -1663,12 +1739,12 @@ function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
|
||||
const triggerPills = card.pills.filter((p) => p.kind === "trigger");
|
||||
|
||||
const ruleSection = rulePills.length === 0 ? "" : `
|
||||
<div class="fristen-card-pills-section">
|
||||
<div class="fristen-card-pills-section fristen-card-pills-section--rules">
|
||||
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.heading"))}</h4>
|
||||
<div class="fristen-card-pills">${rulePills.map((p) => renderPill(p, lang)).join("")}</div>
|
||||
</div>`;
|
||||
const triggerSection = triggerPills.length === 0 ? "" : `
|
||||
<div class="fristen-card-pills-section">
|
||||
<div class="fristen-card-pills-section fristen-card-pills-section--cross">
|
||||
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.cross_cutting"))}</h4>
|
||||
<div class="fristen-card-pills">${triggerPills.map((p) => renderPill(p, lang)).join("")}</div>
|
||||
</div>`;
|
||||
@@ -2444,6 +2520,17 @@ interface EventCategoryNode {
|
||||
let eventCategoryTree: EventCategoryNode[] | null = null;
|
||||
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
|
||||
|
||||
// Top-level cascade roots that represent forward-looking workflows ("I
|
||||
// want to file X, what deadlines does my action trigger?") rather than
|
||||
// the backward-looking calc the Fristenrechner is built for ("event Y
|
||||
// happened, what deadlines spawn?"). m's 2026-05-20 ask (m/paliad#57):
|
||||
// remove these from the "Was ist passiert?" picker — they belong in a
|
||||
// future forward-workflow tool, not here. The DB rows stay so that
|
||||
// future tool can pick them back up; we just hide them at the UI layer.
|
||||
const HIDDEN_CASCADE_ROOTS: ReadonlySet<string> = new Set([
|
||||
"ich-moechte-einreichen",
|
||||
]);
|
||||
|
||||
async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
|
||||
if (eventCategoryTree) return eventCategoryTree;
|
||||
if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
|
||||
@@ -2452,7 +2539,8 @@ async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
|
||||
const r = await fetch("/api/tools/fristenrechner/event-categories");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
eventCategoryTree = (data.tree || []) as EventCategoryNode[];
|
||||
const raw = (data.tree || []) as EventCategoryNode[];
|
||||
eventCategoryTree = raw.filter((n) => !HIDDEN_CASCADE_ROOTS.has(n.slug));
|
||||
return eventCategoryTree;
|
||||
} finally {
|
||||
eventCategoryFetchInflight = null;
|
||||
@@ -3722,14 +3810,30 @@ function applyPerspective(p: Perspective) {
|
||||
triggerCascadeRefresh();
|
||||
}
|
||||
|
||||
// ourSideToPerspective maps the project-level "Wir vertreten" enum
|
||||
// onto the chip-strip Perspective. 'court' / 'both' map to null
|
||||
// (chip cleared) — court actions are neutral to the user's side and
|
||||
// "both" is explicit no-filter intent.
|
||||
// ourSideToPerspective maps the project-level "Client Role" enum
|
||||
// (DB column: our_side) onto the chip-strip Perspective.
|
||||
//
|
||||
// Per t-paliad-222 (m/paliad#47) the field carries one of seven
|
||||
// sub-role values grouped at display time:
|
||||
// Active (we initiate) : claimant, applicant, appellant → "claimant"
|
||||
// Reactive (we defend) : defendant, respondent → "defendant"
|
||||
// Other : third_party, other, NULL → null
|
||||
//
|
||||
// Legacy 'court' / 'both' values no longer exist in the column
|
||||
// (mig 110 backfilled them to NULL); both fall through to the null
|
||||
// default arm if a stale value sneaks in.
|
||||
function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||||
if (os === "claimant") return "claimant";
|
||||
if (os === "defendant") return "defendant";
|
||||
return null;
|
||||
switch (os) {
|
||||
case "claimant":
|
||||
case "applicant":
|
||||
case "appellant":
|
||||
return "claimant";
|
||||
case "defendant":
|
||||
case "respondent":
|
||||
return "defendant";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// applyOurSidePredefine locks the perspective from project.our_side
|
||||
|
||||
@@ -272,10 +272,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.step1.divider.new": "oder eine neue Akte",
|
||||
"deadlines.step1.divider.adhoc": "oder ad-hoc, ohne Akte",
|
||||
"deadlines.step1.new.cta": "+ Neue Akte anlegen",
|
||||
"deadlines.step1.adhoc.upc": "Custom UPC-Verfahren",
|
||||
"deadlines.step1.adhoc.de": "Custom DE-Verfahren",
|
||||
"deadlines.step1.adhoc.epa": "Custom EPA-Verfahren",
|
||||
"deadlines.step1.adhoc.dpma": "Custom DPMA-Verfahren",
|
||||
"deadlines.step1.adhoc.upc": "UPC-Verfahren",
|
||||
"deadlines.step1.adhoc.de": "DE-Verfahren",
|
||||
"deadlines.step1.adhoc.epa": "EPA-Verfahren",
|
||||
"deadlines.step1.adhoc.dpma": "DPMA-Verfahren",
|
||||
"deadlines.step1.selected": "Akte:",
|
||||
"deadlines.step1.reselect": "Andere Akte",
|
||||
"deadlines.step1.summary.adhoc.suffix": "ohne Akte (Erkundung)",
|
||||
@@ -300,6 +300,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.label": "Ansicht:",
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
"deadlines.view.columns": "Spalten",
|
||||
"deadlines.notes.show": "Hinweise anzeigen",
|
||||
"deadlines.col.proactive": "Proaktiv",
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.reactive": "Reaktiv",
|
||||
@@ -344,6 +345,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.card.calc.expand_hint": "Frist berechnen oder zu Akte hinzufügen",
|
||||
"deadlines.card.calc.close": "schließen",
|
||||
"deadlines.card.calc.pill_picker.label": "Welcher Kontext?",
|
||||
"deadlines.card.calc.pill_picker.locked_label": "Kontext:",
|
||||
"deadlines.card.calc.pill_picker.change": "ändern",
|
||||
"deadlines.card.calc.trigger.label": "Datum des auslösenden Ereignisses",
|
||||
"deadlines.card.calc.flags.label": "Bedingungen:",
|
||||
"deadlines.card.calc.flag.with_ccr": "Mit Nichtigkeitswiderklage",
|
||||
@@ -910,6 +913,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.agenda.heading": "Agenda",
|
||||
"dashboard.agenda.empty": "Keine F\u00e4lligkeiten in den n\u00e4chsten 30 Tagen.",
|
||||
"dashboard.agenda.full_link": "Vollst\u00e4ndige Agenda \u00f6ffnen \u2192",
|
||||
// Inbox-approvals widget (t-paliad-219).
|
||||
"dashboard.inbox.heading": "Offene Freigaben",
|
||||
"dashboard.inbox.empty": "Keine offenen Freigaben.",
|
||||
"dashboard.inbox.full_link": "Vollst\u00e4ndigen Posteingang \u00f6ffnen \u2192",
|
||||
"dashboard.inbox.entity.deadline": "Frist",
|
||||
"dashboard.inbox.entity.appointment": "Termin",
|
||||
// Collapsible-section toggle a11y labels (t-paliad-162). Both states
|
||||
// are needed because the aria-label flips with the expanded state.
|
||||
"dashboard.section.collapse": "Abschnitt einklappen",
|
||||
@@ -971,18 +980,22 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.deadline_approval_approved": "Genehmigung erteilt",
|
||||
"event.title.deadline_approval_rejected": "Genehmigung abgelehnt",
|
||||
"event.title.deadline_approval_revoked": "Anfrage zurückgezogen",
|
||||
"event.title.deadline_approval_changes_suggested": "Änderungen vorgeschlagen",
|
||||
"event.title.appointment_approval_requested": "Genehmigung beantragt",
|
||||
"event.title.appointment_approval_approved": "Genehmigung erteilt",
|
||||
"event.title.appointment_approval_rejected": "Genehmigung abgelehnt",
|
||||
"event.title.appointment_approval_revoked": "Anfrage zurückgezogen",
|
||||
"event.title.appointment_approval_changes_suggested": "Änderungen vorgeschlagen",
|
||||
"event.description.deadline_approval_requested": "4-Augen-Genehmigung für Frist beantragt",
|
||||
"event.description.deadline_approval_approved": "Genehmigung für Frist erteilt",
|
||||
"event.description.deadline_approval_rejected": "Genehmigung für Frist abgelehnt",
|
||||
"event.description.deadline_approval_revoked": "Genehmigungsanfrage für Frist zurückgezogen",
|
||||
"event.description.deadline_approval_changes_suggested": "Frist abgelehnt mit Gegenvorschlag",
|
||||
"event.description.appointment_approval_requested": "4-Augen-Genehmigung für Termin beantragt",
|
||||
"event.description.appointment_approval_approved": "Genehmigung für Termin erteilt",
|
||||
"event.description.appointment_approval_rejected": "Genehmigung für Termin abgelehnt",
|
||||
"event.description.appointment_approval_revoked": "Genehmigungsanfrage für Termin zurückgezogen",
|
||||
"event.description.appointment_approval_changes_suggested": "Termin abgelehnt mit Gegenvorschlag",
|
||||
"dashboard.action.short.deadline_approval_requested": "beantragte Genehmigung",
|
||||
"dashboard.action.short.deadline_approval_approved": "genehmigte Frist",
|
||||
"dashboard.action.short.deadline_approval_rejected": "lehnte Frist ab",
|
||||
@@ -1197,9 +1210,30 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.our_side.unset": "Unbekannt / nicht gesetzt",
|
||||
"projects.field.our_side.claimant": "Klägerseite",
|
||||
"projects.field.our_side.defendant": "Beklagtenseite",
|
||||
"projects.field.our_side.applicant": "Antragsteller",
|
||||
"projects.field.our_side.appellant": "Berufungsführer",
|
||||
"projects.field.our_side.respondent": "Antragsgegner",
|
||||
"projects.field.our_side.third_party": "Streithelfer / Dritter",
|
||||
"projects.field.our_side.other": "Sonstige Beteiligte",
|
||||
"projects.field.our_side.court": "Gericht / Tribunal",
|
||||
"projects.field.our_side.both": "Beide Seiten",
|
||||
"projects.field.our_side.none": "—",
|
||||
"projects.field.client_role": "Mandantenrolle",
|
||||
"projects.field.client_role.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator: Aktiv → Klägerseite, Reaktiv → Beklagtenseite. Lässt sich dort jederzeit überschreiben.",
|
||||
"projects.field.client_role.unset": "Unbekannt",
|
||||
"projects.field.client_role.group.active": "Aktiv (wir greifen an)",
|
||||
"projects.field.client_role.group.reactive": "Reaktiv (wir verteidigen)",
|
||||
"projects.field.client_role.group.other": "Dritte / Sonstige",
|
||||
"projects.field.client_role.claimant": "Klägerseite",
|
||||
"projects.field.client_role.applicant": "Antragsteller",
|
||||
"projects.field.client_role.appellant": "Berufungsführer",
|
||||
"projects.field.client_role.defendant": "Beklagtenseite",
|
||||
"projects.field.client_role.respondent": "Antragsgegner",
|
||||
"projects.field.client_role.third_party": "Streithelfer / Dritter",
|
||||
"projects.field.client_role.other": "Sonstige Beteiligte",
|
||||
"projects.field.opponent_code": "Gegner-Kürzel",
|
||||
"projects.field.opponent_code.placeholder": "z.B. OPNT",
|
||||
"projects.field.opponent_code.hint": "Kurzes Kürzel der Gegenseite (Großbuchstaben, Ziffern, Bindestriche, max. 16 Zeichen). Wird als mittleres Segment in automatisch abgeleiteten Projekt-Codes verwendet (z.B. EXMPL.OPNT.567.INF.CFI).",
|
||||
"projects.field.status": "Status",
|
||||
"projects.error.title_required": "Titel erforderlich",
|
||||
"projects.detail.edit.type_change_warning.title": "Diese Felder werden geleert:",
|
||||
@@ -1256,6 +1290,18 @@ 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).
|
||||
@@ -1382,6 +1428,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.type.patent": "Patent",
|
||||
"projects.type.case": "Verfahren",
|
||||
"projects.type.project": "Projekt",
|
||||
"projects.type.other": "Sonstiges",
|
||||
"projects.team.role.lead": "Leitung",
|
||||
"projects.team.role.associate": "Associate",
|
||||
"projects.team.role.pa": "PA",
|
||||
@@ -1389,10 +1436,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.team.role.local_counsel": "Local Counsel",
|
||||
"projects.team.role.expert": "Experte",
|
||||
"projects.team.role.observer": "Beobachter",
|
||||
"projects.team.responsibility.admin": "Admin",
|
||||
"projects.team.responsibility.admin.hint": "Kann Team und Rollen auf diesem Projekt und Unterprojekten verwalten",
|
||||
"projects.team.responsibility.lead": "Leitung",
|
||||
"projects.team.responsibility.member": "Mitglied",
|
||||
"projects.team.responsibility.observer": "Beobachter",
|
||||
"projects.team.responsibility.external": "Extern",
|
||||
"projects.team.error.last_admin": "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.",
|
||||
"projects.team.error.forbidden": "Diese Aktion ist nicht erlaubt.",
|
||||
"projects.team.error.generic": "Aktion fehlgeschlagen.",
|
||||
"projects.team.profession.partner": "Partner",
|
||||
"projects.team.profession.of_counsel": "Of Counsel",
|
||||
"projects.team.profession.associate": "Associate",
|
||||
@@ -1442,6 +1494,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.chip.type.patent": "Patent",
|
||||
"projects.chip.type.case": "Verfahren",
|
||||
"projects.chip.type.project": "Projekt",
|
||||
"projects.chip.type.other": "Sonstiges",
|
||||
"projects.chip.multi.none": "Keine Auswahl",
|
||||
"projects.chip.multi.count": "{n} ausgew\u00e4hlt",
|
||||
"projects.empty.filtered.action": "Filter zur\u00fccksetzen",
|
||||
@@ -1668,6 +1721,45 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"caldav.log.col.error": "Fehler",
|
||||
"caldav.log.empty": "Noch keine Synchronisationen aufgezeichnet.",
|
||||
|
||||
// CalDAV multi-calendar bindings (t-paliad-212 Slice 2b)
|
||||
"caldav.bindings.heading": "Kalender",
|
||||
"caldav.bindings.hint": "Verbinde mehrere Kalender mit Paliad — einen Master für alles oder eigene Kalender pro Projekt.",
|
||||
"caldav.bindings.add": "+ Kalender hinzufügen",
|
||||
"caldav.bindings.empty": "Noch keine Kalender konfiguriert.",
|
||||
"caldav.bindings.scope.all_visible": "Alles",
|
||||
"caldav.bindings.scope.personal_only": "Nur persönlich",
|
||||
"caldav.bindings.scope.project": "Projekt",
|
||||
"caldav.bindings.card.enabled": "Aktiv",
|
||||
"caldav.bindings.card.edit": "Bearbeiten",
|
||||
"caldav.bindings.card.remove": "Entfernen",
|
||||
"caldav.bindings.modal.add_title": "Kalender hinzufügen",
|
||||
"caldav.bindings.modal.edit_title": "Kalender bearbeiten",
|
||||
"caldav.bindings.modal.source": "Kalender",
|
||||
"caldav.bindings.modal.source.loading": "Lädt …",
|
||||
"caldav.bindings.modal.source.existing": "Vorhandenen Kalender wählen",
|
||||
"caldav.bindings.modal.source.create": "Neuen Kalender erstellen",
|
||||
"caldav.bindings.modal.source.custom": "Eigene URL eingeben",
|
||||
"caldav.bindings.modal.source.degrade": "Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV. Erstelle den Kalender direkt in der Anbieter-Oberfläche und füge ihn hier per URL hinzu.",
|
||||
"caldav.bindings.modal.source.discover_failed": "Kalender konnten nicht ermittelt werden — eigene URL eingeben.",
|
||||
"caldav.bindings.modal.source.discover_empty": "Keine Kalender gefunden — eigene URL eingeben.",
|
||||
"caldav.bindings.modal.display_name": "Anzeigename (optional)",
|
||||
"caldav.bindings.modal.display_name.placeholder": "z.B. Projekt Acme v Bosch",
|
||||
"caldav.bindings.modal.scope": "Inhalt",
|
||||
"caldav.bindings.modal.scope.all_visible": "Alles, was ich sehe",
|
||||
"caldav.bindings.modal.scope.personal_only": "Nur persönliche Termine",
|
||||
"caldav.bindings.modal.scope.project": "Ein Projekt:",
|
||||
"caldav.bindings.modal.scope.project.loading": "Lädt …",
|
||||
"caldav.bindings.modal.submit_add": "Hinzufügen",
|
||||
"caldav.bindings.modal.submit_edit": "Speichern",
|
||||
"caldav.bindings.delete.confirm": "Diesen Kalender wirklich entfernen? Die zugehörigen Termine werden im externen Kalender gelöscht.",
|
||||
"caldav.bindings.delete.failed": "Entfernen fehlgeschlagen — bitte später erneut versuchen.",
|
||||
"caldav.bindings.error.scope": "Bitte einen Inhaltsbereich wählen.",
|
||||
"caldav.bindings.error.scope_project": "Bitte ein Projekt auswählen.",
|
||||
"caldav.bindings.error.path": "Bitte einen Kalender wählen oder eine URL eingeben.",
|
||||
"caldav.bindings.error.create_name_required": "Bitte einen Anzeigenamen eingeben.",
|
||||
"caldav.bindings.error.create_name_taken": "Name bereits vergeben — bitte einen anderen Anzeigenamen wählen.",
|
||||
"caldav.bindings.error.create_unsupported": "Dein Anbieter unterstützt das Erstellen neuer Kalender nicht. Bitte 'Eigene URL eingeben' verwenden.",
|
||||
|
||||
// Notizen (polymorphic notes — Phase I)
|
||||
"notes.section.title": "Notizen",
|
||||
"notes.placeholder": "Notiz hinzuf\u00fcgen\u2026",
|
||||
@@ -1745,6 +1837,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"team.filter.project.all": "Alle Projekte",
|
||||
"team.filter.project.selected": "ausgewählt",
|
||||
"team.filter.project.clear": "Alle abwählen",
|
||||
// Click-to-select (t-paliad-223 #53). Layered ON TOP of the existing
|
||||
// filter pills — selection is an explicit subset of the visible set,
|
||||
// pruned on filter change, wiped on page navigation.
|
||||
"team.selection.count": "{n} ausgewählt",
|
||||
"team.selection.clear": "Auswahl aufheben",
|
||||
"team.selection.send": "E-Mail an Auswahl",
|
||||
"team.selection.select_all": "Alle sichtbaren auswählen",
|
||||
"team.selection.toggle_card": "Kontakt auswählen",
|
||||
// Broadcast modal (t-paliad-147)
|
||||
"team.broadcast.button": "E-Mail an Auswahl",
|
||||
"team.broadcast.title": "E-Mail an Auswahl",
|
||||
@@ -1977,8 +2077,24 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.heading": "Team-Verwaltung",
|
||||
"admin.team.subtitle": "Alle Paliad-Konten anzeigen, bearbeiten oder hinzufügen.",
|
||||
"admin.team.search.placeholder": "Nach Name oder E-Mail suchen…",
|
||||
"admin.team.add.full": "Konto direkt anlegen",
|
||||
"admin.team.add.direct": "Bestehendes Konto onboarden",
|
||||
"admin.team.add.invite": "Neue:n Kolleg:in einladen",
|
||||
"admin.team.add_full.title": "Konto direkt anlegen",
|
||||
"admin.team.add_full.body": "Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erhält eine E-Mail mit einem Link, über den sie ein Passwort setzt.",
|
||||
"admin.team.add_full.email": "E-Mail",
|
||||
"admin.team.add_full.name": "Anzeigename",
|
||||
"admin.team.add_full.office": "Standort",
|
||||
"admin.team.add_full.profession": "Profession",
|
||||
"admin.team.add_full.job_title": "Berufsbezeichnung",
|
||||
"admin.team.add_full.lang": "Sprache",
|
||||
"admin.team.add_full.send_welcome": "Willkommens-E-Mail mit Login-Link senden",
|
||||
"admin.team.add_full.cancel": "Abbrechen",
|
||||
"admin.team.add_full.submit": "Anlegen",
|
||||
"admin.team.add_full.feedback.added": "Konto angelegt.",
|
||||
"admin.team.add_full.error.unavailable": "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).",
|
||||
"admin.team.add_full.error.email_exists": "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.",
|
||||
"admin.team.add_full.error.generic": "Konto konnte nicht angelegt werden.",
|
||||
"admin.team.loading": "Lade…",
|
||||
"admin.team.empty": "Keine Treffer.",
|
||||
"admin.team.error.forbidden": "Zugriff nur für Admins.",
|
||||
@@ -2098,6 +2214,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
|
||||
// t-paliad-088: Event Types — picker, multi-select filter, add modal.
|
||||
"common.cancel": "Abbrechen",
|
||||
"modal.close.label": "Schließen",
|
||||
"event_types.cat.submission": "Eingaben",
|
||||
"event_types.cat.decision": "Entscheidungen",
|
||||
"event_types.cat.order": "Anordnungen",
|
||||
@@ -2214,10 +2331,33 @@ 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.suggest.section.editable": "Felder",
|
||||
"approvals.suggest.section.event_type_rule": "Verfahrenshandlung (Typ + Regel)",
|
||||
"approvals.suggest.section.context": "Kontext",
|
||||
"approvals.suggest.context.project": "Projekt",
|
||||
"approvals.suggest.context.requester": "Eingereicht von",
|
||||
"approvals.suggest.context.requested_at": "Eingereicht am",
|
||||
"approvals.suggest.context.approval_status": "Genehmigungsstatus",
|
||||
"approvals.suggest.event_type_picker_unavailable": "Ereignistypen konnten nicht geladen werden.",
|
||||
"approvals.suggest.field.original_due_date": "Ursprüngliches Fälligkeitsdatum",
|
||||
"approvals.suggest.field.warning_date": "Warndatum",
|
||||
"approvals.suggest.field.rule_code": "Regel-Zitat",
|
||||
"approvals.suggest.field.description": "Beschreibung",
|
||||
"approvals.requested_by": "Eingereicht von",
|
||||
"approvals.decided_by": "Entschieden von",
|
||||
"approvals.decision_kind.peer": "Genehmigt durch Teammitglied",
|
||||
@@ -2229,9 +2369,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.",
|
||||
"approvals.error.awaiting_approval": "Diese Anforderung wartet auf Genehmigung.",
|
||||
"approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.",
|
||||
"approvals.error.suggestion_requires_change": "Ein Vorschlag braucht entweder geänderte Werte oder einen Kommentar.",
|
||||
"approvals.error.suggestion_lifecycle_invalid": "Änderungen vorschlagen ist nur für Update-Anfragen möglich.",
|
||||
"approvals.disabled.self_approval": "Du kannst eigene Anträge nicht genehmigen",
|
||||
"approvals.disabled.not_authorized": "Du hast keine Genehmigungsberechtigung für diesen Antrag",
|
||||
"approvals.disabled.revoke_not_requester": "Nur der Antragsteller kann zurückziehen",
|
||||
"approvals.disabled.suggest_lifecycle": "Änderungen vorschlagen ist nur für Update-Anfragen möglich",
|
||||
"approvals.pending.badge": "Wartet auf Genehmigung",
|
||||
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
|
||||
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
|
||||
@@ -2399,6 +2542,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.approval_status.approved": "Genehmigt",
|
||||
"views.bar.approval_status.rejected": "Abgelehnt",
|
||||
"views.bar.approval_status.revoked": "Zurückgezogen",
|
||||
"views.bar.approval_status.changes_requested": "Mit Vorschlag",
|
||||
"views.bar.approval_entity.deadline": "Frist",
|
||||
"views.bar.approval_entity.appointment": "Termin",
|
||||
"views.bar.deadline_status.pending": "Offen",
|
||||
@@ -2871,10 +3015,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.step1.divider.new": "or a new matter",
|
||||
"deadlines.step1.divider.adhoc": "or ad-hoc, without a matter",
|
||||
"deadlines.step1.new.cta": "+ Create new matter",
|
||||
"deadlines.step1.adhoc.upc": "Custom UPC proceeding",
|
||||
"deadlines.step1.adhoc.de": "Custom DE proceeding",
|
||||
"deadlines.step1.adhoc.epa": "Custom EPA proceeding",
|
||||
"deadlines.step1.adhoc.dpma": "Custom DPMA proceeding",
|
||||
"deadlines.step1.adhoc.upc": "UPC proceeding",
|
||||
"deadlines.step1.adhoc.de": "DE proceeding",
|
||||
"deadlines.step1.adhoc.epa": "EPA proceeding",
|
||||
"deadlines.step1.adhoc.dpma": "DPMA proceeding",
|
||||
"deadlines.step1.selected": "Matter:",
|
||||
"deadlines.step1.reselect": "Other matter",
|
||||
"deadlines.step1.summary.adhoc.suffix": "no matter (exploration)",
|
||||
@@ -2899,6 +3043,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.label": "View:",
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
"deadlines.view.columns": "Columns",
|
||||
"deadlines.notes.show": "Show details",
|
||||
"deadlines.col.proactive": "Proactive",
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.reactive": "Reactive",
|
||||
@@ -2950,6 +3095,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.card.calc.expand_hint": "Calculate deadline or add to project",
|
||||
"deadlines.card.calc.close": "close",
|
||||
"deadlines.card.calc.pill_picker.label": "Which context?",
|
||||
"deadlines.card.calc.pill_picker.locked_label": "Context:",
|
||||
"deadlines.card.calc.pill_picker.change": "change",
|
||||
"deadlines.card.calc.trigger.label": "Date of triggering event",
|
||||
"deadlines.card.calc.flags.label": "Conditions:",
|
||||
"deadlines.card.calc.flag.with_ccr": "With counterclaim for revocation",
|
||||
@@ -3505,6 +3652,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.agenda.heading": "Agenda",
|
||||
"dashboard.agenda.empty": "Nothing due in the next 30 days.",
|
||||
"dashboard.agenda.full_link": "Open full agenda →",
|
||||
"dashboard.inbox.heading": "Open approvals",
|
||||
"dashboard.inbox.empty": "No open approvals.",
|
||||
"dashboard.inbox.full_link": "Open full inbox →",
|
||||
"dashboard.inbox.entity.deadline": "Deadline",
|
||||
"dashboard.inbox.entity.appointment": "Appointment",
|
||||
"dashboard.section.collapse": "Collapse section",
|
||||
"dashboard.section.expand": "Expand section",
|
||||
"dashboard.urgency.overdue": "Overdue",
|
||||
@@ -3558,18 +3710,22 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.deadline_approval_approved": "Approval granted",
|
||||
"event.title.deadline_approval_rejected": "Approval rejected",
|
||||
"event.title.deadline_approval_revoked": "Request revoked",
|
||||
"event.title.deadline_approval_changes_suggested": "Changes suggested",
|
||||
"event.title.appointment_approval_requested": "Approval requested",
|
||||
"event.title.appointment_approval_approved": "Approval granted",
|
||||
"event.title.appointment_approval_rejected": "Approval rejected",
|
||||
"event.title.appointment_approval_revoked": "Request revoked",
|
||||
"event.title.appointment_approval_changes_suggested": "Changes suggested",
|
||||
"event.description.deadline_approval_requested": "Four-eyes approval requested for deadline",
|
||||
"event.description.deadline_approval_approved": "Deadline approval granted",
|
||||
"event.description.deadline_approval_rejected": "Deadline approval rejected",
|
||||
"event.description.deadline_approval_revoked": "Deadline approval request revoked",
|
||||
"event.description.deadline_approval_changes_suggested": "Deadline declined with a counter-proposal",
|
||||
"event.description.appointment_approval_requested": "Four-eyes approval requested for appointment",
|
||||
"event.description.appointment_approval_approved": "Appointment approval granted",
|
||||
"event.description.appointment_approval_rejected": "Appointment approval rejected",
|
||||
"event.description.appointment_approval_revoked": "Appointment approval request revoked",
|
||||
"event.description.appointment_approval_changes_suggested": "Appointment declined with a counter-proposal",
|
||||
"dashboard.action.short.deadline_approval_requested": "requested approval",
|
||||
"dashboard.action.short.deadline_approval_approved": "approved deadline",
|
||||
"dashboard.action.short.deadline_approval_rejected": "rejected deadline",
|
||||
@@ -3784,9 +3940,30 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.our_side.unset": "Unknown / not set",
|
||||
"projects.field.our_side.claimant": "Claimant side",
|
||||
"projects.field.our_side.defendant": "Defendant side",
|
||||
"projects.field.our_side.applicant": "Applicant",
|
||||
"projects.field.our_side.appellant": "Appellant",
|
||||
"projects.field.our_side.respondent": "Respondent",
|
||||
"projects.field.our_side.third_party": "Third Party",
|
||||
"projects.field.our_side.other": "Other party",
|
||||
"projects.field.our_side.court": "Court / tribunal",
|
||||
"projects.field.our_side.both": "Both sides",
|
||||
"projects.field.our_side.none": "—",
|
||||
"projects.field.client_role": "Client Role",
|
||||
"projects.field.client_role.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator: Active → claimant side, Reactive → defendant side. Always overridable from there.",
|
||||
"projects.field.client_role.unset": "Unknown",
|
||||
"projects.field.client_role.group.active": "Active (we initiate)",
|
||||
"projects.field.client_role.group.reactive": "Reactive (we defend)",
|
||||
"projects.field.client_role.group.other": "Third Party / Other",
|
||||
"projects.field.client_role.claimant": "Claimant side",
|
||||
"projects.field.client_role.applicant": "Applicant",
|
||||
"projects.field.client_role.appellant": "Appellant",
|
||||
"projects.field.client_role.defendant": "Defendant side",
|
||||
"projects.field.client_role.respondent": "Respondent",
|
||||
"projects.field.client_role.third_party": "Third Party",
|
||||
"projects.field.client_role.other": "Other party",
|
||||
"projects.field.opponent_code": "Opponent code",
|
||||
"projects.field.opponent_code.placeholder": "e.g. OPNT",
|
||||
"projects.field.opponent_code.hint": "Short slug for the opposing party (uppercase letters, digits, dashes, max 16 chars). Used as the middle segment in auto-derived project codes (e.g. EXMPL.OPNT.567.INF.CFI).",
|
||||
"projects.field.status": "Status",
|
||||
"projects.error.title_required": "Title required",
|
||||
"projects.detail.edit.type_change_warning.title": "These fields will be cleared:",
|
||||
@@ -3843,6 +4020,18 @@ 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.",
|
||||
@@ -3968,6 +4157,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.type.patent": "Patent",
|
||||
"projects.type.case": "Case",
|
||||
"projects.type.project": "Project",
|
||||
"projects.type.other": "Other",
|
||||
"projects.team.role.lead": "Lead",
|
||||
"projects.team.role.associate": "Associate",
|
||||
"projects.team.role.pa": "PA",
|
||||
@@ -3975,10 +4165,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.team.role.local_counsel": "Local Counsel",
|
||||
"projects.team.role.expert": "Expert",
|
||||
"projects.team.role.observer": "Observer",
|
||||
"projects.team.responsibility.admin": "Admin",
|
||||
"projects.team.responsibility.admin.hint": "Can manage team and roles on this project and its sub-projects",
|
||||
"projects.team.responsibility.lead": "Lead",
|
||||
"projects.team.responsibility.member": "Member",
|
||||
"projects.team.responsibility.observer": "Observer",
|
||||
"projects.team.responsibility.external": "External",
|
||||
"projects.team.error.last_admin": "At least one admin must remain on this project or an ancestor.",
|
||||
"projects.team.error.forbidden": "This action is not permitted.",
|
||||
"projects.team.error.generic": "Action failed.",
|
||||
"projects.team.profession.partner": "Partner",
|
||||
"projects.team.profession.of_counsel": "Of Counsel",
|
||||
"projects.team.profession.associate": "Associate",
|
||||
@@ -4028,6 +4223,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.chip.type.patent": "Patent",
|
||||
"projects.chip.type.case": "Case",
|
||||
"projects.chip.type.project": "Project",
|
||||
"projects.chip.type.other": "Other",
|
||||
"projects.chip.multi.none": "Nothing selected",
|
||||
"projects.chip.multi.count": "{n} selected",
|
||||
"projects.empty.filtered.action": "Reset filters",
|
||||
@@ -4251,6 +4447,45 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"caldav.log.col.error": "Error",
|
||||
"caldav.log.empty": "No sync attempts recorded yet.",
|
||||
|
||||
// CalDAV multi-calendar bindings (t-paliad-212 Slice 2b)
|
||||
"caldav.bindings.heading": "Calendars",
|
||||
"caldav.bindings.hint": "Connect multiple calendars to Paliad — one master for everything or separate calendars per project.",
|
||||
"caldav.bindings.add": "+ Add calendar",
|
||||
"caldav.bindings.empty": "No calendars configured yet.",
|
||||
"caldav.bindings.scope.all_visible": "Everything",
|
||||
"caldav.bindings.scope.personal_only": "Personal only",
|
||||
"caldav.bindings.scope.project": "Project",
|
||||
"caldav.bindings.card.enabled": "Enabled",
|
||||
"caldav.bindings.card.edit": "Edit",
|
||||
"caldav.bindings.card.remove": "Remove",
|
||||
"caldav.bindings.modal.add_title": "Add calendar",
|
||||
"caldav.bindings.modal.edit_title": "Edit calendar",
|
||||
"caldav.bindings.modal.source": "Calendar",
|
||||
"caldav.bindings.modal.source.loading": "Loading…",
|
||||
"caldav.bindings.modal.source.existing": "Pick existing calendar",
|
||||
"caldav.bindings.modal.source.create": "Create new calendar",
|
||||
"caldav.bindings.modal.source.custom": "Enter custom URL",
|
||||
"caldav.bindings.modal.source.degrade": "This provider doesn't allow creating calendars via CalDAV. Please create the calendar in your provider's UI and add it here by URL.",
|
||||
"caldav.bindings.modal.source.discover_failed": "Couldn't discover calendars — enter URL manually.",
|
||||
"caldav.bindings.modal.source.discover_empty": "No calendars found — enter URL manually.",
|
||||
"caldav.bindings.modal.display_name": "Display name (optional)",
|
||||
"caldav.bindings.modal.display_name.placeholder": "e.g. Project Acme v Bosch",
|
||||
"caldav.bindings.modal.scope": "Contents",
|
||||
"caldav.bindings.modal.scope.all_visible": "Everything I can see",
|
||||
"caldav.bindings.modal.scope.personal_only": "Personal appointments only",
|
||||
"caldav.bindings.modal.scope.project": "One project:",
|
||||
"caldav.bindings.modal.scope.project.loading": "Loading…",
|
||||
"caldav.bindings.modal.submit_add": "Add",
|
||||
"caldav.bindings.modal.submit_edit": "Save",
|
||||
"caldav.bindings.delete.confirm": "Remove this calendar? Its events will be deleted from the external calendar.",
|
||||
"caldav.bindings.delete.failed": "Removal failed — please try again later.",
|
||||
"caldav.bindings.error.scope": "Please pick a content scope.",
|
||||
"caldav.bindings.error.scope_project": "Please pick a project.",
|
||||
"caldav.bindings.error.path": "Please pick a calendar or enter a URL.",
|
||||
"caldav.bindings.error.create_name_required": "Please enter a display name.",
|
||||
"caldav.bindings.error.create_name_taken": "Name already in use — please pick a different display name.",
|
||||
"caldav.bindings.error.create_unsupported": "Your provider doesn't support creating calendars. Please use 'Enter custom URL' instead.",
|
||||
|
||||
// Notizen (polymorphic notes — Phase I)
|
||||
"notes.section.title": "Notes",
|
||||
"notes.placeholder": "Add a note\u2026",
|
||||
@@ -4328,6 +4563,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"team.filter.project.all": "All projects",
|
||||
"team.filter.project.selected": "selected",
|
||||
"team.filter.project.clear": "Deselect all",
|
||||
// Click-to-select (t-paliad-223 #53).
|
||||
"team.selection.count": "{n} selected",
|
||||
"team.selection.clear": "Clear selection",
|
||||
"team.selection.send": "Email selection",
|
||||
"team.selection.select_all": "Select all visible",
|
||||
"team.selection.toggle_card": "Select contact",
|
||||
// Broadcast modal (t-paliad-147)
|
||||
"team.broadcast.button": "Email selection",
|
||||
"team.broadcast.title": "Email selection",
|
||||
@@ -4560,8 +4801,24 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.heading": "Team Management",
|
||||
"admin.team.subtitle": "View, edit and add Paliad accounts.",
|
||||
"admin.team.search.placeholder": "Search by name or email…",
|
||||
"admin.team.add.full": "Add account directly",
|
||||
"admin.team.add.direct": "Onboard existing account",
|
||||
"admin.team.add.invite": "Invite Colleague",
|
||||
"admin.team.add_full.title": "Add account directly",
|
||||
"admin.team.add_full.body": "Creates both the login account and the Paliad profile. The new colleague receives an email with a link to set a password.",
|
||||
"admin.team.add_full.email": "Email",
|
||||
"admin.team.add_full.name": "Display name",
|
||||
"admin.team.add_full.office": "Office",
|
||||
"admin.team.add_full.profession": "Profession",
|
||||
"admin.team.add_full.job_title": "Job title",
|
||||
"admin.team.add_full.lang": "Language",
|
||||
"admin.team.add_full.send_welcome": "Send welcome email with login link",
|
||||
"admin.team.add_full.cancel": "Cancel",
|
||||
"admin.team.add_full.submit": "Create",
|
||||
"admin.team.add_full.feedback.added": "Account created.",
|
||||
"admin.team.add_full.error.unavailable": "Add-User path is not configured (SUPABASE_SERVICE_ROLE_KEY missing on the server).",
|
||||
"admin.team.add_full.error.email_exists": "An account already exists for this email — please use 'Onboard existing account' instead.",
|
||||
"admin.team.add_full.error.generic": "Could not create the account.",
|
||||
"admin.team.loading": "Loading…",
|
||||
"admin.team.empty": "No matches.",
|
||||
"admin.team.error.forbidden": "Admins only.",
|
||||
@@ -4681,6 +4938,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
|
||||
// t-paliad-088: Event Types — picker, multi-select filter, add modal.
|
||||
"common.cancel": "Cancel",
|
||||
"modal.close.label": "Close",
|
||||
"event_types.cat.submission": "Submissions",
|
||||
"event_types.cat.decision": "Decisions",
|
||||
"event_types.cat.order": "Orders",
|
||||
@@ -4797,10 +5055,33 @@ 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.suggest.section.editable": "Fields",
|
||||
"approvals.suggest.section.event_type_rule": "Event type + rule",
|
||||
"approvals.suggest.section.context": "Context",
|
||||
"approvals.suggest.context.project": "Project",
|
||||
"approvals.suggest.context.requester": "Submitted by",
|
||||
"approvals.suggest.context.requested_at": "Submitted at",
|
||||
"approvals.suggest.context.approval_status": "Approval status",
|
||||
"approvals.suggest.event_type_picker_unavailable": "Event types could not be loaded.",
|
||||
"approvals.suggest.field.original_due_date": "Original due date",
|
||||
"approvals.suggest.field.warning_date": "Warning date",
|
||||
"approvals.suggest.field.rule_code": "Rule citation",
|
||||
"approvals.suggest.field.description": "Description",
|
||||
"approvals.requested_by": "Submitted by",
|
||||
"approvals.decided_by": "Decided by",
|
||||
"approvals.decision_kind.peer": "Peer approval",
|
||||
@@ -4812,9 +5093,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.",
|
||||
"approvals.error.awaiting_approval": "This entity is awaiting approval.",
|
||||
"approvals.error.request_not_pending": "This request is no longer open.",
|
||||
"approvals.error.suggestion_requires_change": "A suggestion needs either changed values or a note.",
|
||||
"approvals.error.suggestion_lifecycle_invalid": "Suggest changes is only available for update requests.",
|
||||
"approvals.disabled.self_approval": "You cannot approve your own requests",
|
||||
"approvals.disabled.not_authorized": "You are not authorized to approve this request",
|
||||
"approvals.disabled.revoke_not_requester": "Only the requester can withdraw",
|
||||
"approvals.disabled.suggest_lifecycle": "Suggest changes is only available for update requests",
|
||||
"approvals.pending.badge": "Awaiting approval",
|
||||
"approvals.withdraw.cta": "Withdraw approval request",
|
||||
"approvals.withdraw.confirm": "Withdraw the approval request?",
|
||||
@@ -4981,6 +5265,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.approval_status.approved": "Approved",
|
||||
"views.bar.approval_status.rejected": "Rejected",
|
||||
"views.bar.approval_status.revoked": "Revoked",
|
||||
"views.bar.approval_status.changes_requested": "With suggestion",
|
||||
"views.bar.approval_entity.deadline": "Deadline",
|
||||
"views.bar.approval_entity.appointment": "Appointment",
|
||||
"views.bar.deadline_status.pending": "Open",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { AxisKey } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
import { openApprovalEditModal } from "./components/approval-edit-modal";
|
||||
|
||||
// /inbox client — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
@@ -123,11 +124,20 @@ function paint(
|
||||
|
||||
function wireApprovalActions(host: HTMLElement): void {
|
||||
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
||||
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
|
||||
const action = btn.dataset.action as
|
||||
| "approve"
|
||||
| "reject"
|
||||
| "revoke"
|
||||
| "suggest_changes"
|
||||
| undefined;
|
||||
const li = btn.closest<HTMLLIElement>(".views-approval-row");
|
||||
const id = li?.dataset.requestId;
|
||||
if (!action || !id) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (action === "suggest_changes") {
|
||||
await handleSuggestChanges(btn, id, li!);
|
||||
return;
|
||||
}
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
@@ -141,8 +151,8 @@ function wireApprovalActions(host: HTMLElement): void {
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
alert(mapApprovalError(body.error || "internal"));
|
||||
const body = await r.json().catch(() => ({} as { error?: string; code?: string }));
|
||||
alert(mapApprovalError(body.code || body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
@@ -156,14 +166,109 @@ function wireApprovalActions(host: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// handleSuggestChanges — t-paliad-216. Open the edit modal with the
|
||||
// requester's original payload + pre_image pre-populated. If the user
|
||||
// submits non-empty changes / note, POST to
|
||||
// /api/approval-requests/{id}/suggest-changes; refresh the bar on success
|
||||
// so the OLD row flips to changes_requested and the NEW pending row
|
||||
// appears.
|
||||
async function handleSuggestChanges(
|
||||
btn: HTMLButtonElement,
|
||||
requestID: string,
|
||||
li: HTMLLIElement,
|
||||
): Promise<void> {
|
||||
// Read the row's detail blob off the data-attrs the shape-list stamped.
|
||||
// shape-list serialises payload/pre_image inline; we fetch fresh via
|
||||
// the per-row API to avoid relying on stale list data.
|
||||
let payload: Record<string, unknown> | null = null;
|
||||
let preImage: Record<string, unknown> | null = null;
|
||||
let entityType: "deadline" | "appointment" = "deadline";
|
||||
let lifecycleEvent = "update";
|
||||
let projectTitle: string | undefined;
|
||||
let requesterName: string | undefined;
|
||||
let requestedAt: string | undefined;
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${requestID}`, { credentials: "include" });
|
||||
if (r.ok) {
|
||||
const body = (await r.json()) as {
|
||||
entity_type?: "deadline" | "appointment";
|
||||
lifecycle_event?: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
project_title?: string;
|
||||
requester_name?: string;
|
||||
requested_at?: string;
|
||||
};
|
||||
payload = body.payload ?? null;
|
||||
preImage = body.pre_image ?? null;
|
||||
if (body.entity_type === "appointment") entityType = "appointment";
|
||||
if (body.lifecycle_event) lifecycleEvent = body.lifecycle_event;
|
||||
projectTitle = body.project_title;
|
||||
requesterName = body.requester_name;
|
||||
requestedAt = body.requested_at;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Modal still opens with empty defaults if the fetch fails; the
|
||||
// server-side schema validation catches a misshapen counter.
|
||||
}
|
||||
|
||||
const result = await openApprovalEditModal({
|
||||
entityType,
|
||||
lifecycleEvent,
|
||||
payload,
|
||||
preImage,
|
||||
projectTitle,
|
||||
requesterName,
|
||||
requestedAt,
|
||||
});
|
||||
if (!result) return; // cancel
|
||||
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${requestID}/suggest-changes`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
counter_payload: result.counterPayload,
|
||||
note: result.note,
|
||||
}),
|
||||
});
|
||||
const body = (await r.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
new_request_id?: string;
|
||||
};
|
||||
if (!r.ok) {
|
||||
alert(mapApprovalError(body.code || body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await bar?.refresh();
|
||||
await refreshInboxBadge();
|
||||
btn.disabled = false;
|
||||
|
||||
// Surface the new row's id on the OLD row's <li> so callers (e.g.
|
||||
// tests, future inspection) can find it without re-querying.
|
||||
if (body.new_request_id) {
|
||||
li.dataset.spawnedRequestId = body.new_request_id;
|
||||
}
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked": return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending": return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized": return t("approvals.error.not_authorized");
|
||||
case "request_not_pending": return t("approvals.error.request_not_pending");
|
||||
default: return key;
|
||||
case "self_approval_blocked": return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending": return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized": return t("approvals.error.not_authorized");
|
||||
case "request_not_pending": return t("approvals.error.request_not_pending");
|
||||
case "suggestion_requires_change": return t("approvals.error.suggestion_requires_change");
|
||||
case "suggestion_lifecycle_invalid": return t("approvals.error.suggestion_lifecycle_invalid");
|
||||
default: return key;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface ProjectMini {
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string | null;
|
||||
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
|
||||
// the ancestor tree. Populated by the service projection on every
|
||||
// /api/projects response, so the picker can show the code without an
|
||||
// extra fetch.
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface ProjectFormState {
|
||||
@@ -48,9 +53,11 @@ function tryGet(id: string): HTMLElement | null {
|
||||
export function showFieldsForType(typeSel: string) {
|
||||
const parentWrap = tryGet("projekt-parent-wrap") as HTMLDivElement | null;
|
||||
const clientFields = tryGet("fields-client") as HTMLDivElement | null;
|
||||
const litigationFields = tryGet("fields-litigation") as HTMLDivElement | null;
|
||||
const patentFields = tryGet("fields-patent") as HTMLDivElement | null;
|
||||
const caseFields = tryGet("fields-case") as HTMLDivElement | null;
|
||||
if (clientFields) clientFields.style.display = typeSel === "client" ? "block" : "none";
|
||||
if (litigationFields) litigationFields.style.display = typeSel === "litigation" ? "block" : "none";
|
||||
if (patentFields) patentFields.style.display = typeSel === "patent" ? "block" : "none";
|
||||
if (caseFields) caseFields.style.display = typeSel === "case" ? "block" : "none";
|
||||
if (parentWrap) parentWrap.style.display = typeSel === "client" ? "none" : "block";
|
||||
@@ -88,18 +95,28 @@ export function initParentPicker() {
|
||||
}
|
||||
const matches = parentCandidates
|
||||
.filter((p) => {
|
||||
const hay = (p.title + " " + (p.reference || "")).toLowerCase();
|
||||
// Search across title + manual reference + auto-derived code
|
||||
// so the user can type "EXMPL" or "INF.CFI" and find the row.
|
||||
const hay = (p.title + " " + (p.reference || "") + " " + (p.code || "")).toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
.slice(0, 8);
|
||||
sugs.innerHTML = matches
|
||||
.map(
|
||||
(p) =>
|
||||
`<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
|
||||
.map((p) => {
|
||||
// Render the auto-derived code (if any, and distinct from
|
||||
// reference) as a small mono badge on the right so the user
|
||||
// can disambiguate two same-titled projects by their tree
|
||||
// position. Single template literal kept readable inline.
|
||||
const code = p.code && p.code !== (p.reference || "") ? p.code : "";
|
||||
const codeBadge = code
|
||||
? `<span class="entity-ref entity-ref-code">${esc(code)}</span>`
|
||||
: "";
|
||||
return `<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
|
||||
<strong>${esc(p.title)}</strong>
|
||||
<span class="entity-type-chip entity-type-${esc(p.type)}">${esc(tDyn("projects.type." + p.type) || p.type)}</span>
|
||||
</div>`,
|
||||
)
|
||||
${codeBadge}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
sugs.querySelectorAll<HTMLDivElement>(".collab-suggestion").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
@@ -174,20 +191,32 @@ export function readPayload(
|
||||
const gd = ($("project-grant-date") as HTMLInputElement).value;
|
||||
if (gd) payload.grant_date = gd + "T00:00:00Z";
|
||||
}
|
||||
if (type === "litigation") {
|
||||
// opponent_code is the litigation-only short slug used as the
|
||||
// middle segment when BuildProjectCode auto-derives a project
|
||||
// code from the ancestor tree (t-paliad-222 / m/paliad#50).
|
||||
// Uppercased on submit so the user can type lowercase comfortably
|
||||
// — the DB CHECK enforces the [A-Z0-9-]{1,16} pattern.
|
||||
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
|
||||
if (ocEl) {
|
||||
const v = ocEl.value.trim().toUpperCase();
|
||||
if (v) payload.opponent_code = v;
|
||||
else if (!opts.omitEmpty) payload.opponent_code = "";
|
||||
}
|
||||
}
|
||||
if (type === "case") {
|
||||
stringField("project-court", "court");
|
||||
stringField("project-case-number", "case_number");
|
||||
}
|
||||
|
||||
// our_side is type-agnostic — every project type can carry "Wir
|
||||
// vertreten" because the Determinator picks it up regardless of
|
||||
// type. The select uses "" for the unset option; the service maps
|
||||
// empty string to NULL via nullableOurSide.
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) {
|
||||
const v = osSel.value.trim();
|
||||
if (v) payload.our_side = v;
|
||||
else if (!opts.omitEmpty) payload.our_side = "";
|
||||
// Client Role (DB column: our_side) — case-only after t-paliad-222.
|
||||
// The select uses "" for the unset option; the service maps empty
|
||||
// string to NULL via nullableOurSide.
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) {
|
||||
const v = osSel.value.trim();
|
||||
if (v) payload.our_side = v;
|
||||
else if (!opts.omitEmpty) payload.our_side = "";
|
||||
}
|
||||
}
|
||||
|
||||
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
|
||||
@@ -228,6 +257,8 @@ export function prefillForm(p: Record<string, unknown>) {
|
||||
get("project-case-number").value = String(p.case_number ?? "");
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) osSel.value = String(p.our_side ?? "");
|
||||
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
|
||||
if (ocEl) ocEl.value = String(p.opponent_code ?? "");
|
||||
getTA("project-description").value = String(p.description ?? "");
|
||||
getSel("project-status").value = String(p.status ?? "active");
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec } from "./views/types";
|
||||
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
|
||||
import { loadAndRenderSubmissions } from "./submissions";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -20,6 +21,12 @@ interface Project {
|
||||
path: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
|
||||
// the ancestor tree (e.g. EXMPL.OPNT.789.INF.CFI). Populated by the
|
||||
// service layer on every projection; equal to `reference` when the
|
||||
// user typed an override.
|
||||
code?: string;
|
||||
opponent_code?: string | null;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
client_number?: string | null;
|
||||
@@ -33,6 +40,12 @@ interface Project {
|
||||
grant_date?: string | null;
|
||||
court?: string | null;
|
||||
case_number?: string | null;
|
||||
// t-paliad-223: piggybacked onto the GET /api/projects/{id} payload so
|
||||
// the team panel can render an inline <select> for callers who can
|
||||
// change responsibilities (global_admin or effective_project_admin on
|
||||
// this project / ancestor). Optional for back-compat with cached
|
||||
// payloads.
|
||||
effective_admin?: boolean;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -158,7 +171,8 @@ type TabId =
|
||||
| "deadlines"
|
||||
| "appointments"
|
||||
| "notes"
|
||||
| "checklists";
|
||||
| "checklists"
|
||||
| "submissions";
|
||||
|
||||
const VALID_TABS: TabId[] = [
|
||||
"history",
|
||||
@@ -169,6 +183,7 @@ const VALID_TABS: TabId[] = [
|
||||
"appointments",
|
||||
"notes",
|
||||
"checklists",
|
||||
"submissions",
|
||||
];
|
||||
|
||||
// Legacy German tab slugs that may appear in bookmarked URLs after the
|
||||
@@ -1086,6 +1101,24 @@ function renderHeader() {
|
||||
(document.getElementById("project-title-display") as HTMLElement).textContent = project.title;
|
||||
(document.getElementById("project-ref-display") as HTMLElement).textContent = project.reference || "";
|
||||
|
||||
// t-paliad-222 / m/paliad#50 — show the auto-derived project code
|
||||
// as a second badge whenever it's non-empty AND distinct from the
|
||||
// manual reference. Hides when the derived value equals reference
|
||||
// (avoids visual duplication when the user typed the same string)
|
||||
// or when no derivation produced a value.
|
||||
const codeEl = document.getElementById("project-code-display") as HTMLElement | null;
|
||||
if (codeEl) {
|
||||
const code = project.code ?? "";
|
||||
const ref = project.reference ?? "";
|
||||
if (code && code !== ref) {
|
||||
codeEl.textContent = code;
|
||||
codeEl.style.display = "";
|
||||
} else {
|
||||
codeEl.textContent = "";
|
||||
codeEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-177 — link from Verlauf header to standalone chart page.
|
||||
// Wired here (not in the TSX shell) because we need the resolved
|
||||
// project id, which only exists after the detail fetch settles.
|
||||
@@ -1610,6 +1643,9 @@ function showTab(tab: TabId) {
|
||||
if (tab === "checklists" && project) {
|
||||
void loadAndRenderChecklistInstances(project.id);
|
||||
}
|
||||
if (tab === "submissions" && project) {
|
||||
void loadAndRenderSubmissions(project.id);
|
||||
}
|
||||
}
|
||||
|
||||
let checklistInstancesInited = false;
|
||||
@@ -2058,6 +2094,7 @@ async function main() {
|
||||
initAttachUnitForm(id);
|
||||
initNotesContainer(id);
|
||||
mountVerlaufFilterBar(id);
|
||||
wireExportButton(id);
|
||||
showTab(parseTab());
|
||||
}
|
||||
|
||||
@@ -2487,6 +2524,11 @@ function renderTeam() {
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
// t-paliad-223: callers with effective_project_admin authority see an
|
||||
// inline <select> on the Rolle cell. Everyone else sees the read-only
|
||||
// <span>. The bool comes from the GET /api/projects/{id} payload.
|
||||
const canEditResponsibility = !!project?.effective_admin;
|
||||
|
||||
body.innerHTML = teamMembers
|
||||
.map((m) => {
|
||||
// t-paliad-148: profession is firm-wide (read-only badge) and
|
||||
@@ -2512,11 +2554,20 @@ function renderTeam() {
|
||||
: "";
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none";
|
||||
|
||||
// Inline-select only on direct rows where the caller can edit.
|
||||
// Inherited rows stay read-only — the edit must happen at the
|
||||
// ancestor where the row is direct.
|
||||
const responsibilityCell =
|
||||
canEditResponsibility && !m.inherited
|
||||
? renderResponsibilitySelect(m.user_id, responsibility)
|
||||
: `<span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span>`;
|
||||
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
|
||||
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
|
||||
<td>${responsibilityCell}</td>
|
||||
<td>${source}</td>
|
||||
<td>${removeBtn}</td>
|
||||
</tr>`;
|
||||
@@ -2535,6 +2586,47 @@ function renderTeam() {
|
||||
if (resp.ok) {
|
||||
await loadTeam(project.id);
|
||||
renderTeam();
|
||||
} else {
|
||||
await showTeamErrorToast(resp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelectorAll<HTMLSelectElement>(".team-responsibility-select").forEach((sel) => {
|
||||
// Capture the pre-change value on focus so we can roll back the
|
||||
// <select> if the PATCH fails (e.g. last-admin guard).
|
||||
sel.dataset.previous = sel.value;
|
||||
sel.addEventListener("focus", () => {
|
||||
sel.dataset.previous = sel.value;
|
||||
});
|
||||
sel.addEventListener("change", async () => {
|
||||
if (!project) return;
|
||||
const userID = sel.dataset.userId!;
|
||||
const previous = sel.dataset.previous || "member";
|
||||
const next = sel.value;
|
||||
if (next === previous) return;
|
||||
sel.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${project.id}/team/${encodeURIComponent(userID)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ responsibility: next }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
sel.value = previous;
|
||||
await showTeamErrorToast(resp);
|
||||
return;
|
||||
}
|
||||
sel.dataset.previous = next;
|
||||
// Refresh the team list so derived/descendant sections re-render
|
||||
// with the new authority shape.
|
||||
await loadTeam(project.id);
|
||||
renderTeam();
|
||||
} finally {
|
||||
sel.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2680,10 +2772,92 @@ 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;
|
||||
return me.global_role === "global_admin";
|
||||
if (me.global_role === "global_admin") return true;
|
||||
// t-paliad-223: effective_project_admin (from the project payload)
|
||||
// also covers remove. RLS makes the request fail anyway if the bit is
|
||||
// stale; this just hides the affordance.
|
||||
return !!project?.effective_admin;
|
||||
}
|
||||
|
||||
// t-paliad-223: build the inline <select> for the responsibility cell.
|
||||
// Options mirror the IsValidResponsibility set in approval_levels.go.
|
||||
function renderResponsibilitySelect(userID: string, current: string): string {
|
||||
const options = ["admin", "lead", "member", "observer", "external"]
|
||||
.map((v) => {
|
||||
const label = tDyn(`projects.team.responsibility.${v}`) || v;
|
||||
const sel = v === current ? " selected" : "";
|
||||
return `<option value="${esc(v)}"${sel}>${esc(label)}</option>`;
|
||||
})
|
||||
.join("");
|
||||
return `<select class="team-responsibility-select projekt-team-responsibility" data-user-id="${esc(userID)}">${options}</select>`;
|
||||
}
|
||||
|
||||
// t-paliad-223: surface backend error responses (last-admin guard / 403
|
||||
// from RLS / etc.) as a transient toast. We have no global toast service
|
||||
// yet on this page, so write into #team-msg.
|
||||
async function showTeamErrorToast(resp: Response): Promise<void> {
|
||||
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
|
||||
if (!msg) return;
|
||||
let text = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
text = data?.error || "";
|
||||
} catch {
|
||||
text = "";
|
||||
}
|
||||
if (!text) {
|
||||
if (resp.status === 409) text = t("projects.team.error.last_admin") || "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.";
|
||||
else if (resp.status === 403 || resp.status === 404) text = t("projects.team.error.forbidden") || "Diese Aktion ist nicht erlaubt.";
|
||||
else text = t("projects.team.error.generic") || "Aktion fehlgeschlagen.";
|
||||
}
|
||||
msg.textContent = text;
|
||||
msg.classList.add("form-msg--error");
|
||||
// Auto-clear after 5s so a stale error doesn't linger past the next
|
||||
// successful action.
|
||||
window.setTimeout(() => {
|
||||
if (msg.textContent === text) {
|
||||
msg.textContent = "";
|
||||
msg.classList.remove("form-msg--error");
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function initTeamForm(id: string) {
|
||||
|
||||
@@ -412,6 +412,11 @@ async function loadCalDAVTab() {
|
||||
fillCalDAVForm();
|
||||
renderCalDAVStatus();
|
||||
await loadCalDAVLog();
|
||||
// Slice 2b — multi-calendar bindings. loadBindingProjects feeds the
|
||||
// project picker for scope=project; runs in parallel with the binding
|
||||
// list fetch.
|
||||
void loadBindingProjects();
|
||||
await loadBindings();
|
||||
}
|
||||
|
||||
async function loadCalDAVConfig(): Promise<boolean> {
|
||||
@@ -597,6 +602,415 @@ async function deleteCalDAVConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- CalDAV bindings (Slice 2b multi-calendar picker) ---------------------
|
||||
|
||||
interface UserCalendarBinding {
|
||||
id: string;
|
||||
user_id: string;
|
||||
calendar_path: string;
|
||||
display_name: string;
|
||||
scope_kind: "all_visible" | "personal_only" | "project" | "client" | "litigation" | "patent" | "case";
|
||||
scope_id?: string | null;
|
||||
include_personal: boolean;
|
||||
enabled: boolean;
|
||||
last_sync_at?: string | null;
|
||||
last_sync_error?: string | null;
|
||||
}
|
||||
|
||||
interface DiscoveredCalendar {
|
||||
href: string;
|
||||
display_name: string;
|
||||
supported_components?: string[];
|
||||
}
|
||||
|
||||
interface ProjectListItem {
|
||||
id: string;
|
||||
reference?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
let bindings: UserCalendarBinding[] = [];
|
||||
let discoveredCalendars: DiscoveredCalendar[] = [];
|
||||
let bindingProjects: ProjectListItem[] = [];
|
||||
let editingBindingID: string | null = null;
|
||||
// Slice 2c — capability cached from /api/caldav-discover. null = unprobed,
|
||||
// true = MKCALENDAR supported (show "Create new calendar" radio),
|
||||
// false = degrade UX (hide radio, surface bilingual notice).
|
||||
let supportsMKCalendar: boolean | null = null;
|
||||
|
||||
async function loadBindings(): Promise<void> {
|
||||
const section = document.getElementById("caldav-bindings-section");
|
||||
if (!section) return;
|
||||
try {
|
||||
const resp = await fetch("/api/caldav-bindings");
|
||||
if (resp.status === 501) return; // CalDAV unavailable; leave hidden
|
||||
if (!resp.ok) return;
|
||||
bindings = (await resp.json()) as UserCalendarBinding[];
|
||||
section.style.display = "";
|
||||
renderBindingsList();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function renderBindingsList(): void {
|
||||
const list = document.getElementById("caldav-bindings-list")!;
|
||||
const empty = document.getElementById("caldav-bindings-empty")!;
|
||||
if (!bindings.length) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = bindings.map(renderBindingCard).join("");
|
||||
// Wire per-card buttons.
|
||||
for (const b of bindings) {
|
||||
const card = document.getElementById(`caldav-binding-card-${b.id}`);
|
||||
if (!card) continue;
|
||||
card.querySelector(".caldav-binding-edit-btn")?.addEventListener("click", () => openBindingModal(b));
|
||||
card.querySelector(".caldav-binding-delete-btn")?.addEventListener("click", () => deleteBinding(b));
|
||||
const toggle = card.querySelector(".caldav-binding-enabled-toggle") as HTMLInputElement | null;
|
||||
toggle?.addEventListener("change", () => toggleBindingEnabled(b, toggle.checked));
|
||||
}
|
||||
}
|
||||
|
||||
function renderBindingCard(b: UserCalendarBinding): string {
|
||||
const label = b.display_name || b.calendar_path;
|
||||
const scope = scopeLabel(b);
|
||||
const last = b.last_sync_at ? fmtDateTime(b.last_sync_at) : t("caldav.never");
|
||||
const err = b.last_sync_error ? `<span class="caldav-status-error">${esc(b.last_sync_error)}</span>` : "";
|
||||
return `<div class="caldav-binding-card" id="caldav-binding-card-${esc(b.id)}">
|
||||
<div class="caldav-binding-card-row">
|
||||
<div class="caldav-binding-card-title">
|
||||
<strong>${esc(label)}</strong>
|
||||
<span class="caldav-binding-scope-chip">${esc(scope)}</span>
|
||||
</div>
|
||||
<label class="caldav-toggle-label">
|
||||
<input type="checkbox" class="caldav-binding-enabled-toggle" ${b.enabled ? "checked" : ""} />
|
||||
<span data-i18n="caldav.bindings.card.enabled">Aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="caldav-binding-card-row caldav-binding-card-meta">
|
||||
<span class="caldav-binding-path">${esc(b.calendar_path)}</span>
|
||||
<span class="caldav-binding-last-sync">${esc(t("caldav.status.last_sync"))} ${esc(last)} ${err}</span>
|
||||
</div>
|
||||
<div class="caldav-binding-card-actions">
|
||||
<button type="button" class="btn-secondary caldav-binding-edit-btn" data-i18n="caldav.bindings.card.edit">Bearbeiten</button>
|
||||
<button type="button" class="btn-danger caldav-binding-delete-btn" data-i18n="caldav.bindings.card.remove">Entfernen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function scopeLabel(b: UserCalendarBinding): string {
|
||||
switch (b.scope_kind) {
|
||||
case "all_visible":
|
||||
return t("caldav.bindings.scope.all_visible");
|
||||
case "personal_only":
|
||||
return t("caldav.bindings.scope.personal_only");
|
||||
case "project": {
|
||||
const p = bindingProjects.find((p) => p.id === b.scope_id);
|
||||
const name = p ? p.title || p.reference || p.id.slice(0, 8) : "?";
|
||||
return `${t("caldav.bindings.scope.project")}: ${name}`;
|
||||
}
|
||||
default:
|
||||
return b.scope_kind;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBindingProjects(): Promise<void> {
|
||||
if (bindingProjects.length) return;
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.ok) bindingProjects = (await resp.json()) as ProjectListItem[];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiscoveredCalendars(): Promise<void> {
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.loading"))}</option>`;
|
||||
try {
|
||||
const resp = await fetch("/api/caldav-discover");
|
||||
if (!resp.ok) {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
||||
supportsMKCalendar = null;
|
||||
syncBindingSourceModeUI();
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as {
|
||||
calendars: DiscoveredCalendar[];
|
||||
supports_mkcalendar?: boolean | null;
|
||||
};
|
||||
discoveredCalendars = data.calendars || [];
|
||||
supportsMKCalendar = data.supports_mkcalendar ?? null;
|
||||
if (!discoveredCalendars.length) {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_empty"))}</option>`;
|
||||
} else {
|
||||
sel.innerHTML = discoveredCalendars
|
||||
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
|
||||
.join("");
|
||||
}
|
||||
syncBindingSourceModeUI();
|
||||
} catch {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
||||
supportsMKCalendar = null;
|
||||
syncBindingSourceModeUI();
|
||||
}
|
||||
}
|
||||
|
||||
// syncBindingSourceModeUI shows / hides the "Neuen Kalender erstellen"
|
||||
// radio + the Google-degrade notice based on the cached
|
||||
// supports_mkcalendar capability. Also flips the visible input
|
||||
// (dropdown vs URL text box) to match the currently selected mode.
|
||||
function syncBindingSourceModeUI(): void {
|
||||
const createRow = document.getElementById("caldav-binding-source-mode-create-row");
|
||||
const degrade = document.getElementById("caldav-binding-degrade-notice");
|
||||
if (createRow) createRow.style.display = supportsMKCalendar === true ? "" : "none";
|
||||
if (degrade) degrade.style.display = supportsMKCalendar === false ? "" : "none";
|
||||
|
||||
// If supports_mkcalendar flipped to false while "create" was selected,
|
||||
// fall back to "existing" so the user isn't staring at a hidden radio.
|
||||
if (supportsMKCalendar !== true) {
|
||||
const createRadio = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="create"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (createRadio?.checked) {
|
||||
const existing = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="existing"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (existing) existing.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
const mode = currentBindingSourceMode();
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
sel.style.display = mode === "existing" ? "" : "none";
|
||||
customInput.style.display = mode === "custom" ? "" : "none";
|
||||
}
|
||||
|
||||
function currentBindingSourceMode(): "existing" | "create" | "custom" {
|
||||
const checked = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"]:checked',
|
||||
) as HTMLInputElement | null;
|
||||
return (checked?.value as "existing" | "create" | "custom") ?? "existing";
|
||||
}
|
||||
|
||||
function openBindingModal(b: UserCalendarBinding | null) {
|
||||
editingBindingID = b ? b.id : null;
|
||||
const modal = document.getElementById("caldav-binding-modal")!;
|
||||
const title = document.getElementById("caldav-binding-modal-title")!;
|
||||
const submitBtn = document.getElementById("caldav-binding-submit-btn")!;
|
||||
const sourceField = document.getElementById("caldav-binding-source-field")!;
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
const msg = document.getElementById("caldav-binding-msg")!;
|
||||
msg.textContent = "";
|
||||
|
||||
if (b) {
|
||||
title.textContent = t("caldav.bindings.modal.edit_title");
|
||||
submitBtn.textContent = t("caldav.bindings.modal.submit_edit");
|
||||
sourceField.style.display = "none";
|
||||
nameInput.value = b.display_name;
|
||||
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="${b.scope_kind}"]`) as HTMLInputElement | null;
|
||||
if (radio) radio.checked = true;
|
||||
} else {
|
||||
title.textContent = t("caldav.bindings.modal.add_title");
|
||||
submitBtn.textContent = t("caldav.bindings.modal.submit_add");
|
||||
sourceField.style.display = "";
|
||||
// Reset the 3-way source-mode radio to "existing" (most common path).
|
||||
const existingRadio = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="existing"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (existingRadio) existingRadio.checked = true;
|
||||
customInput.value = "";
|
||||
nameInput.value = "";
|
||||
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="all_visible"]`) as HTMLInputElement;
|
||||
radio.checked = true;
|
||||
void loadDiscoveredCalendars();
|
||||
}
|
||||
|
||||
// Project picker — populate options when project scope is picked.
|
||||
projectSel.innerHTML = bindingProjects
|
||||
.map((p) => `<option value="${esc(p.id)}">${esc((p.title || p.reference || p.id.slice(0, 8)))}</option>`)
|
||||
.join("");
|
||||
if (b && b.scope_kind === "project" && b.scope_id) {
|
||||
projectSel.value = b.scope_id;
|
||||
projectSel.disabled = false;
|
||||
}
|
||||
syncBindingScopeUI();
|
||||
syncBindingSourceModeUI();
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeBindingModal() {
|
||||
document.getElementById("caldav-binding-modal")!.style.display = "none";
|
||||
editingBindingID = null;
|
||||
}
|
||||
|
||||
function syncBindingScopeUI(): void {
|
||||
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
projectSel.disabled = scope !== "project";
|
||||
}
|
||||
|
||||
async function submitBindingModal(ev: Event): Promise<void> {
|
||||
ev.preventDefault();
|
||||
const msg = document.getElementById("caldav-binding-msg")!;
|
||||
msg.textContent = "";
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
const submitBtn = document.getElementById("caldav-binding-submit-btn") as HTMLButtonElement;
|
||||
|
||||
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
|
||||
if (!scope) {
|
||||
msg.textContent = t("caldav.bindings.error.scope");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (scope === "project" && !projectSel.value) {
|
||||
msg.textContent = t("caldav.bindings.error.scope_project");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
if (editingBindingID) {
|
||||
const patchPayload: Record<string, unknown> = {
|
||||
display_name: nameInput.value.trim(),
|
||||
scope_kind: scope,
|
||||
enabled: true,
|
||||
};
|
||||
if (scope === "project") patchPayload.scope_id = projectSel.value;
|
||||
const resp = await fetch(`/api/caldav-bindings/${editingBindingID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patchPayload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const mode = currentBindingSourceMode();
|
||||
if (mode === "create") {
|
||||
// Slice 2c MKCALENDAR path.
|
||||
const displayName = nameInput.value.trim();
|
||||
if (!displayName) {
|
||||
msg.textContent = t("caldav.bindings.error.create_name_required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const createPayload: Record<string, unknown> = {
|
||||
display_name: displayName,
|
||||
scope_kind: scope,
|
||||
};
|
||||
if (scope === "project") createPayload.scope_id = projectSel.value;
|
||||
const resp = await fetch("/api/caldav-mkcalendar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(createPayload),
|
||||
});
|
||||
if (resp.status === 501) {
|
||||
// Race: probe flipped to false between modal-open and submit.
|
||||
// Re-sync the UI and surface a helpful message.
|
||||
supportsMKCalendar = false;
|
||||
syncBindingSourceModeUI();
|
||||
msg.textContent = t("caldav.bindings.error.create_unsupported");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
msg.textContent = t("caldav.bindings.error.create_name_taken");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// existing | custom — POST /api/caldav-bindings with the path.
|
||||
const path = mode === "custom" ? customInput.value.trim() : sel.value;
|
||||
if (!path) {
|
||||
msg.textContent = t("caldav.bindings.error.path");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const postPayload: Record<string, unknown> = {
|
||||
calendar_path: path,
|
||||
display_name: nameInput.value.trim(),
|
||||
scope_kind: scope,
|
||||
enabled: true,
|
||||
};
|
||||
if (scope === "project") postPayload.scope_id = projectSel.value;
|
||||
if (!postPayload.display_name && mode === "existing") {
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
postPayload.display_name = opt ? opt.text : "";
|
||||
}
|
||||
const resp = await fetch("/api/caldav-bindings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(postPayload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
closeBindingModal();
|
||||
await loadBindings();
|
||||
} catch {
|
||||
msg.textContent = t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBinding(b: UserCalendarBinding): Promise<void> {
|
||||
if (!confirm(t("caldav.bindings.delete.confirm"))) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/caldav-bindings/${b.id}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204 && resp.status !== 202) {
|
||||
alert(t("caldav.bindings.delete.failed"));
|
||||
return;
|
||||
}
|
||||
await loadBindings();
|
||||
} catch {
|
||||
alert(t("caldav.bindings.delete.failed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBindingEnabled(b: UserCalendarBinding, enabled: boolean): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`/api/caldav-bindings/${b.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
b.enabled = enabled;
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// --- "Meine Partner Units" card on the profile tab -------------------------
|
||||
//
|
||||
// Read-only summary of the current user's structural memberships. Membership
|
||||
@@ -717,6 +1131,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("caldav-form")!.addEventListener("submit", saveCalDAV);
|
||||
document.getElementById("caldav-test-btn")!.addEventListener("click", testCalDAVConnection);
|
||||
document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteCalDAVConfig);
|
||||
|
||||
// CalDAV bindings (Slice 2b + 2c) — add/edit modal wiring.
|
||||
document.getElementById("caldav-bindings-add-btn")?.addEventListener("click", () => openBindingModal(null));
|
||||
document.getElementById("caldav-binding-modal-close")?.addEventListener("click", closeBindingModal);
|
||||
document.getElementById("caldav-binding-cancel-btn")?.addEventListener("click", closeBindingModal);
|
||||
document.getElementById("caldav-binding-form")?.addEventListener("submit", submitBindingModal);
|
||||
document.querySelectorAll('input[name="caldav-binding-source-mode"]').forEach((el) => {
|
||||
el.addEventListener("change", syncBindingSourceModeUI);
|
||||
});
|
||||
document.querySelectorAll('input[name="caldav-binding-scope"]').forEach((el) => {
|
||||
el.addEventListener("change", syncBindingScopeUI);
|
||||
});
|
||||
const exportBtn = document.getElementById("export-btn");
|
||||
if (exportBtn) exportBtn.addEventListener("click", runExport);
|
||||
|
||||
|
||||
208
frontend/src/client/submissions.ts
Normal file
208
frontend/src/client/submissions.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// Submissions panel — fetches the project's submission catalog and
|
||||
// renders one row per filing-type rule, with a [Generieren] action
|
||||
// when a .docx template resolves server-side.
|
||||
//
|
||||
// t-paliad-215 Slice 1. Loaded lazily by the projects-detail tab
|
||||
// switcher so projects without the Schriftsätze tab open don't pay
|
||||
// for the per-row template-availability probes.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
interface SubmissionEntry {
|
||||
submission_code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
event_type?: string;
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
}
|
||||
|
||||
interface SubmissionListResponse {
|
||||
project_id: string;
|
||||
proceeding_type_id?: number;
|
||||
entries: SubmissionEntry[];
|
||||
}
|
||||
|
||||
// Module state — set once per page load when the user first opens the
|
||||
// tab. Subsequent activations re-use the cached result so the lawyer
|
||||
// doesn't pay for repeat list calls flipping between tabs.
|
||||
let cached: { projectID: string; data: SubmissionListResponse } | null = null;
|
||||
let loading = false;
|
||||
|
||||
/**
|
||||
* Load + render the submissions panel for the given project.
|
||||
*
|
||||
* Idempotent: safe to call on every tab activation. The second call
|
||||
* paints from cache instantly; the first call shows a loading state
|
||||
* until the list response arrives.
|
||||
*/
|
||||
export async function loadAndRenderSubmissions(projectID: string): Promise<void> {
|
||||
if (loading) return;
|
||||
if (cached && cached.projectID === projectID) {
|
||||
render(cached.data);
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${projectID}/submissions`);
|
||||
if (!resp.ok) {
|
||||
renderError();
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as SubmissionListResponse;
|
||||
cached = { projectID, data };
|
||||
render(data);
|
||||
} catch {
|
||||
renderError();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function render(data: SubmissionListResponse): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
const wrap = document.getElementById("project-submissions-tablewrap");
|
||||
const body = document.getElementById("project-submissions-body");
|
||||
if (!empty || !noProc || !wrap || !body) return;
|
||||
|
||||
if (data.proceeding_type_id == null || data.proceeding_type_id === 0) {
|
||||
noProc.style.display = "";
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
noProc.style.display = "none";
|
||||
if (data.entries.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
|
||||
const isEN = document.documentElement.lang === "en";
|
||||
body.innerHTML = data.entries.map((entry) => {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const action = entry.has_template
|
||||
? `<button type="button" class="btn-primary btn-cta-lime btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(data.project_id)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`
|
||||
: `<span class="submission-no-template" data-i18n="projects.detail.submissions.action.no_template">${isEN ? "No template" : "Keine Vorlage"}</span>`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
// Wire button clicks. One click handler per render to avoid stale
|
||||
// closures from the previous render's data.
|
||||
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onGenerateClick(btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderError(): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
const wrap = document.getElementById("project-submissions-tablewrap");
|
||||
if (!empty || !noProc || !wrap) return;
|
||||
noProc.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "";
|
||||
empty.textContent = document.documentElement.lang === "en"
|
||||
? "Failed to load submissions list."
|
||||
: "Schriftsatzliste konnte nicht geladen werden.";
|
||||
}
|
||||
|
||||
function formatParty(role: string | undefined, isEN: boolean): string {
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "claimant": return isEN ? "Claimant" : "Klägerin";
|
||||
case "defendant": return isEN ? "Defendant" : "Beklagte";
|
||||
case "both": return isEN ? "Both" : "Beide";
|
||||
case "court": return isEN ? "Court" : "Gericht";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// onGenerateClick triggers a download. Disables the button while the
|
||||
// request is in flight to prevent double-submits and surfaces an
|
||||
// inline error on failure.
|
||||
async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
|
||||
const code = btn.dataset.code;
|
||||
const projectID = btn.dataset.project;
|
||||
if (!code || !projectID) return;
|
||||
|
||||
const originalLabel = btn.textContent ?? "";
|
||||
btn.disabled = true;
|
||||
btn.textContent = document.documentElement.lang === "en" ? "Generating…" : "Wird generiert…";
|
||||
|
||||
try {
|
||||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = await resp.json() as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
alert(
|
||||
(document.documentElement.lang === "en"
|
||||
? "Generation failed."
|
||||
: "Generieren fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "")
|
||||
?? `${code}.docx`;
|
||||
triggerDownload(blob, filename);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// parseFilename pulls the filename out of a Content-Disposition
|
||||
// header. Supports both unquoted and quoted forms.
|
||||
function parseFilename(header: string): string | null {
|
||||
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// triggerDownload creates an <a> with an object URL, clicks it, and
|
||||
// revokes the URL. Standard browser-side download pattern.
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Revoke on next tick so the click actually triggers the download
|
||||
// before the URL is gone.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
@@ -77,6 +77,25 @@ let activeRole = "all";
|
||||
let activeProjectIDs: Set<string> = new Set();
|
||||
let searchQuery = "";
|
||||
|
||||
// t-paliad-223 (#53) — explicit click-to-select layer ON TOP of the existing
|
||||
// filter pills. When selection.size > 0 the sticky footer takes over the
|
||||
// broadcast action and targets only the explicit subset; with empty
|
||||
// selection the existing top-bar broadcast button still targets the whole
|
||||
// filter result (purely additive).
|
||||
//
|
||||
// Invariant: selection only ever holds user_ids that match the current
|
||||
// filter set — render() prunes drop-outs every cycle. This keeps the
|
||||
// counter honest and avoids "hidden-but-selected" debug nightmares.
|
||||
const selectedUserIDs: Set<string> = new Set();
|
||||
// For Shift-click range select — the user_id of the most recent toggle
|
||||
// in the currently-rendered list order. Reset to null on any filter
|
||||
// change so the range never spans an invisible row.
|
||||
let lastToggledUserID: string | null = null;
|
||||
// Snapshot of the rendered user-IDs in DOM order, refreshed on each render.
|
||||
// Drives Shift-click range expansion and the master-checkbox "select all
|
||||
// visible" action.
|
||||
let renderedUserIDs: string[] = [];
|
||||
|
||||
const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
|
||||
const ICON_PIN = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>';
|
||||
|
||||
@@ -403,8 +422,17 @@ function memberAsUser(m: DepartmentMember): User | undefined {
|
||||
function renderUserCard(u: User): string {
|
||||
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
|
||||
const jobTitle = (u.job_title ?? "").trim();
|
||||
// t-paliad-223 (#53): per-row select-checkbox. Wrapped in a label so a
|
||||
// click on the checkbox cell triggers the toggle; the rest of the card
|
||||
// (links, email, etc.) keeps its native behaviour. Selection state
|
||||
// mirrored to data-selected so the CSS can highlight the card.
|
||||
const selected = selectedUserIDs.has(u.id);
|
||||
const selectAria = t("team.selection.toggle_card") || "Kontakt auswählen";
|
||||
return `
|
||||
<article class="team-card">
|
||||
<article class="team-card" data-user-id="${esc(u.id)}" data-selected="${selected ? "true" : "false"}">
|
||||
<label class="team-card-select" title="${escAttr(selectAria)}">
|
||||
<input type="checkbox" class="team-card-select-input" data-user-id="${esc(u.id)}"${selected ? " checked" : ""} aria-label="${escAttr(selectAria)}" />
|
||||
</label>
|
||||
<div class="team-avatar" aria-hidden="true">${esc(initials(u.display_name))}</div>
|
||||
<div class="team-card-body">
|
||||
<div class="team-card-name">${esc(u.display_name)}</div>
|
||||
@@ -418,6 +446,13 @@ function renderUserCard(u: User): string {
|
||||
</article>`;
|
||||
}
|
||||
|
||||
// escAttr is the attribute-context counterpart of esc. Used in title=""
|
||||
// + aria-label="" where esc()'s div-textContent trick is fine but
|
||||
// double-quote-escaping is the bit we actually need.
|
||||
function escAttr(s: string): string {
|
||||
return esc(s).replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderGroupByOffice(filtered: User[]): string {
|
||||
const present = presentOffices();
|
||||
const sections = present
|
||||
@@ -505,12 +540,22 @@ function render() {
|
||||
const filtered = users.filter(
|
||||
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
|
||||
);
|
||||
|
||||
// t-paliad-223 (#53): prune drop-outs from the explicit selection. The
|
||||
// invariant is "selection ⊆ visible"; carrying invisible IDs forward
|
||||
// would create stale "12 selected" counters that don't match what the
|
||||
// user sees on screen.
|
||||
pruneSelectionToVisible(new Set(filtered.map((u) => u.id)));
|
||||
|
||||
count.textContent = `${filtered.length} / ${users.length}`;
|
||||
updateBroadcastButton();
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
renderedUserIDs = [];
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
@@ -518,6 +563,223 @@ function render() {
|
||||
list.innerHTML = groupBy === "office"
|
||||
? renderGroupByOffice(filtered)
|
||||
: renderGroupByDepartment(filtered);
|
||||
|
||||
// Refresh the DOM-order snapshot Shift-click + master-checkbox rely on.
|
||||
renderedUserIDs = Array.from(
|
||||
list.querySelectorAll<HTMLElement>(".team-card"),
|
||||
).map((el) => el.dataset.userId || "");
|
||||
|
||||
wireSelectionCheckboxes(list);
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
}
|
||||
|
||||
// pruneSelectionToVisible drops user_ids from selection that no longer
|
||||
// match the visible set. Always called from render() before painting so
|
||||
// the per-row "checked" state and the footer counter stay in sync.
|
||||
function pruneSelectionToVisible(visible: Set<string>): void {
|
||||
const removed: string[] = [];
|
||||
for (const id of selectedUserIDs) {
|
||||
if (!visible.has(id)) removed.push(id);
|
||||
}
|
||||
for (const id of removed) selectedUserIDs.delete(id);
|
||||
if (removed.length > 0 && lastToggledUserID && !visible.has(lastToggledUserID)) {
|
||||
lastToggledUserID = null;
|
||||
}
|
||||
}
|
||||
|
||||
// wireSelectionCheckboxes attaches click handlers to every per-row
|
||||
// checkbox in the freshly-rendered list. Each click toggles the
|
||||
// underlying selection Set + the data-selected attribute on the card.
|
||||
// Shift-click extends a contiguous range from the previous toggle to
|
||||
// the current row using renderedUserIDs as the order reference.
|
||||
function wireSelectionCheckboxes(list: HTMLElement): void {
|
||||
list.querySelectorAll<HTMLInputElement>(".team-card-select-input").forEach((cb) => {
|
||||
cb.addEventListener("click", (ev) => {
|
||||
const id = cb.dataset.userId || "";
|
||||
if (!id) return;
|
||||
const checked = cb.checked;
|
||||
if ((ev as MouseEvent).shiftKey && lastToggledUserID && lastToggledUserID !== id) {
|
||||
applyRangeSelection(lastToggledUserID, id, checked);
|
||||
} else {
|
||||
if (checked) selectedUserIDs.add(id);
|
||||
else selectedUserIDs.delete(id);
|
||||
}
|
||||
lastToggledUserID = id;
|
||||
// Visual + footer refresh without a full re-render (selection
|
||||
// changes don't affect the filter set; render() is reserved for
|
||||
// filter/data changes to keep typing in the search box fast).
|
||||
refreshCardSelectedAttribute();
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// applyRangeSelection sets selection state for every user between
|
||||
// (inclusive) startID and endID in renderedUserIDs order. Mode = the
|
||||
// final state — checked => add to selection, unchecked => remove.
|
||||
function applyRangeSelection(startID: string, endID: string, mode: boolean): void {
|
||||
const a = renderedUserIDs.indexOf(startID);
|
||||
const b = renderedUserIDs.indexOf(endID);
|
||||
if (a === -1 || b === -1) {
|
||||
// One of the anchors dropped out of the current visible set; fall
|
||||
// back to a single-row toggle of the end-id.
|
||||
if (mode) selectedUserIDs.add(endID);
|
||||
else selectedUserIDs.delete(endID);
|
||||
return;
|
||||
}
|
||||
const [lo, hi] = a <= b ? [a, b] : [b, a];
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const id = renderedUserIDs[i];
|
||||
if (mode) selectedUserIDs.add(id);
|
||||
else selectedUserIDs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// refreshCardSelectedAttribute syncs every visible card's data-selected
|
||||
// + checkbox.checked to the canonical Set, without a full re-render.
|
||||
function refreshCardSelectedAttribute(): void {
|
||||
const list = document.getElementById("team-list");
|
||||
if (!list) return;
|
||||
list.querySelectorAll<HTMLElement>(".team-card").forEach((card) => {
|
||||
const id = card.dataset.userId || "";
|
||||
const selected = selectedUserIDs.has(id);
|
||||
card.dataset.selected = selected ? "true" : "false";
|
||||
const cb = card.querySelector<HTMLInputElement>(".team-card-select-input");
|
||||
if (cb) cb.checked = selected;
|
||||
});
|
||||
}
|
||||
|
||||
// renderSelectionFooter mounts (or hides) the sticky footer that takes
|
||||
// over the broadcast action when ≥ 1 row is checked. The footer lives
|
||||
// outside the main content tree so it can be position: fixed without
|
||||
// fighting any of the existing layout rules.
|
||||
function renderSelectionFooter(): void {
|
||||
let footer = document.getElementById("team-selection-footer") as HTMLDivElement | null;
|
||||
const n = selectedUserIDs.size;
|
||||
if (n === 0) {
|
||||
if (footer) footer.style.display = "none";
|
||||
document.body.classList.remove("team-has-selection");
|
||||
return;
|
||||
}
|
||||
if (!footer) {
|
||||
footer = document.createElement("div");
|
||||
footer.id = "team-selection-footer";
|
||||
footer.className = "team-selection-footer";
|
||||
document.body.appendChild(footer);
|
||||
}
|
||||
const countLabel = (t("team.selection.count") || "{n} ausgewählt").replace(
|
||||
"{n}",
|
||||
String(n),
|
||||
);
|
||||
footer.innerHTML = `
|
||||
<span class="team-selection-count">${esc(countLabel)}</span>
|
||||
<button type="button" class="btn-secondary btn-small" id="team-selection-clear">
|
||||
${esc(t("team.selection.clear") || "Auswahl aufheben")}
|
||||
</button>
|
||||
<button type="button" class="btn-primary" id="team-selection-send">
|
||||
${esc(t("team.selection.send") || "E-Mail an Auswahl")}
|
||||
</button>
|
||||
`;
|
||||
footer.style.display = "";
|
||||
document.body.classList.add("team-has-selection");
|
||||
document.getElementById("team-selection-clear")?.addEventListener("click", () => {
|
||||
selectedUserIDs.clear();
|
||||
lastToggledUserID = null;
|
||||
refreshCardSelectedAttribute();
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
});
|
||||
document.getElementById("team-selection-send")?.addEventListener("click", () => {
|
||||
onBroadcastFromSelection();
|
||||
});
|
||||
}
|
||||
|
||||
// selectedRecipients maps the explicit selection Set into the
|
||||
// BroadcastRecipient shape openBroadcastModal expects. Mirrors the
|
||||
// role-resolution rules of displayedRecipients() (active project
|
||||
// filter wins; falls back to first available role).
|
||||
function selectedRecipients(): BroadcastRecipient[] {
|
||||
const out: BroadcastRecipient[] = [];
|
||||
for (const id of selectedUserIDs) {
|
||||
const u = users.find((u) => u.id === id);
|
||||
if (!u) continue;
|
||||
const m = memberships.find((m) => m.user_id === u.id);
|
||||
let role = "";
|
||||
if (m) {
|
||||
if (activeProjectIDs.size > 0) {
|
||||
const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
|
||||
if (idx >= 0) role = m.roles[idx];
|
||||
} else if (m.roles.length > 0) {
|
||||
role = m.roles[0];
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
user_id: u.id,
|
||||
email: u.email,
|
||||
display_name: u.display_name,
|
||||
first_name: firstName(u.display_name),
|
||||
role_on_project: role,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function onBroadcastFromSelection(): void {
|
||||
const recipients = selectedRecipients();
|
||||
if (recipients.length === 0) return;
|
||||
const selectedProjectIDs = Array.from(activeProjectIDs);
|
||||
// Same scope-resolution as displayedRecipients/onBroadcastClick: pass
|
||||
// project_id only when exactly one is selected so the server can
|
||||
// verify lead-ship; multi-project relies on global_admin.
|
||||
const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
|
||||
const offices = activeOffice === "all" ? [] : [activeOffice];
|
||||
const roles = activeRole === "all" ? [] : [activeRole];
|
||||
openBroadcastModal({
|
||||
recipients,
|
||||
projectID,
|
||||
projectIDs: selectedProjectIDs,
|
||||
offices,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
|
||||
// syncMasterCheckbox refreshes the master "select all visible" checkbox
|
||||
// to one of three states: empty / partial / full. The HTML element lives
|
||||
// in team.tsx (#team-select-master); when missing (older shells) the
|
||||
// helper no-ops so the page still works.
|
||||
function syncMasterCheckbox(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master) return;
|
||||
const visible = renderedUserIDs.length;
|
||||
if (visible === 0) {
|
||||
master.checked = false;
|
||||
master.indeterminate = false;
|
||||
master.disabled = true;
|
||||
return;
|
||||
}
|
||||
master.disabled = false;
|
||||
let selectedHere = 0;
|
||||
for (const id of renderedUserIDs) {
|
||||
if (selectedUserIDs.has(id)) selectedHere++;
|
||||
}
|
||||
master.checked = selectedHere === visible;
|
||||
master.indeterminate = selectedHere > 0 && selectedHere < visible;
|
||||
}
|
||||
|
||||
function onMasterToggle(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master) return;
|
||||
const checked = master.checked;
|
||||
for (const id of renderedUserIDs) {
|
||||
if (checked) selectedUserIDs.add(id);
|
||||
else selectedUserIDs.delete(id);
|
||||
}
|
||||
lastToggledUserID = checked && renderedUserIDs.length > 0 ? renderedUserIDs[renderedUserIDs.length - 1] : null;
|
||||
refreshCardSelectedAttribute();
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
}
|
||||
|
||||
function initToggle() {
|
||||
@@ -547,6 +809,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initSidebar();
|
||||
initSearch();
|
||||
initToggle();
|
||||
// t-paliad-223 (#53): master checkbox toggles every visible row.
|
||||
document.getElementById("team-select-master")?.addEventListener("change", onMasterToggle);
|
||||
onLangChange(() => {
|
||||
buildOfficeFilters();
|
||||
buildRoleFilters();
|
||||
|
||||
@@ -13,18 +13,42 @@ import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
populateCourtPicker,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
// user's chosen date. Cleared whenever the trigger changes (proceeding,
|
||||
// trigger date, flag toggle) so a fresh calc starts unanchored — same
|
||||
// semantic as /tools/fristenrechner.
|
||||
const anchorOverrides = new Map<string, string>();
|
||||
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// Notes toggle — when off (default), per-rule descriptive notes render
|
||||
// as a compact ⓘ icon next to the meta line (hover for full text). When
|
||||
// on, the full notes block expands under each card. Choice persists in
|
||||
// localStorage so a reload or recalc keeps the user's preference.
|
||||
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
|
||||
function readNotesPref(): boolean {
|
||||
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
function writeNotesPref(on: boolean): void {
|
||||
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
||||
}
|
||||
let showNotes = readNotesPref();
|
||||
|
||||
// Jurisdiction display prefix for the proceeding-summary chip + the
|
||||
// trigger-event placeholder. Same forum slugs the .proceeding-group
|
||||
// `data-forum` attribute carries in verfahrensablauf.tsx /
|
||||
@@ -112,10 +136,14 @@ async function doCalc() {
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
flags: readFlags(),
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
@@ -130,13 +158,19 @@ async function doCalc() {
|
||||
// the first event in the proceeding — e.g. Klageerhebung for
|
||||
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
|
||||
// active proceeding name if no root rule fires (shouldn't happen for
|
||||
// healthy data, but safer than a blank).
|
||||
// healthy data, but safer than a blank). Fallback respects language —
|
||||
// proceedingNameEN is consulted on EN before the DE proceedingName
|
||||
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
|
||||
// proceedings like upc.ccr.cfi which had no rules → no root).
|
||||
function triggerEventLabelFor(data: DeadlineResponse): string {
|
||||
const root = data.deadlines.find((d) => d.isRootEvent);
|
||||
if (root) {
|
||||
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
}
|
||||
return data.proceedingName || "";
|
||||
if (getLang() === "en") {
|
||||
return data.proceedingNameEN || data.proceedingName || "";
|
||||
}
|
||||
return data.proceedingName || data.proceedingNameEN || "";
|
||||
}
|
||||
|
||||
function syncTriggerEventLabel() {
|
||||
@@ -166,11 +200,23 @@ function renderResults(data: DeadlineResponse) {
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data)
|
||||
: renderTimelineBody(data);
|
||||
// Sub-track contextual note (m/paliad#58). Surfaces above the
|
||||
// timeline body when the server routed the user-picked proceeding
|
||||
// through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr).
|
||||
// Plain-text banner — server-side copy is plain text per the
|
||||
// SubTrackRouting contract.
|
||||
const noteText = getLang() === "en"
|
||||
? (data.contextualNoteEN || data.contextualNote || "")
|
||||
: (data.contextualNote || data.contextualNoteEN || "");
|
||||
const noteHtml = noteText
|
||||
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
|
||||
: "";
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + noteHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
if (toggle) toggle.style.display = "";
|
||||
|
||||
@@ -216,7 +262,12 @@ function syncInfAmendEnabled() {
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
selectedType = btn.dataset.code || "";
|
||||
const nextType = btn.dataset.code || "";
|
||||
// Different proceeding tree → previously-set overrides reference
|
||||
// rule codes that don't exist in the new tree. Clear before the
|
||||
// next calc so the fresh proceeding starts unanchored.
|
||||
if (selectedType !== nextType) clearAnchorOverrides();
|
||||
selectedType = nextType;
|
||||
|
||||
// Trigger-event label fires from the calc response (root rule).
|
||||
// Until step 3 renders, fall back to an em-dash placeholder.
|
||||
@@ -299,6 +350,33 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
// Click-to-edit on timeline / column date cells — same delegated
|
||||
// pattern as /tools/fristenrechner. Survives renderResults()'s
|
||||
// innerHTML rewrites because the listener lives on the container.
|
||||
const timelineContainer = document.getElementById("timeline-container");
|
||||
if (timelineContainer) {
|
||||
wireDateEditClicks(timelineContainer, (ruleCode, newValue) => {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Notes toggle — restores last preference on load + re-renders when
|
||||
// the user flips it. Lives in the same toggle bar as the view picker.
|
||||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||||
if (notesShowCb) {
|
||||
notesShowCb.checked = showNotes;
|
||||
notesShowCb.addEventListener("change", () => {
|
||||
showNotes = notesShowCb.checked;
|
||||
writeNotesPref(showNotes);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
|
||||
onLangChange(() => {
|
||||
|
||||
@@ -196,6 +196,12 @@ interface ApprovalDetail {
|
||||
requester_kind?: "user" | "agent";
|
||||
decider_name?: string;
|
||||
decision_note?: string;
|
||||
// counter_payload + next_request_id — populated on the OLD row of a
|
||||
// suggest-changes pair (t-paliad-216). The new row's id lets us
|
||||
// render a back-link "→ Neuer Vorschlag von {decider}". Both stay
|
||||
// unset on any non-changes_requested status.
|
||||
counter_payload?: Record<string, unknown> | null;
|
||||
next_request_id?: string;
|
||||
// Per-viewer eligibility flags resolved server-side against the caller
|
||||
// (t-paliad-202). Used to grey out actions the server would reject.
|
||||
// Optional so an older payload still renders — falsy means "treat as
|
||||
@@ -204,6 +210,11 @@ interface ApprovalDetail {
|
||||
viewer_is_requester?: boolean;
|
||||
}
|
||||
|
||||
// Pending-row action set. suggest_changes was added in t-paliad-216 as
|
||||
// the fourth action — the approver authors a counter-proposal which
|
||||
// becomes a NEW pending row authored by them.
|
||||
type ApprovalAction = "approve" | "reject" | "revoke" | "suggest_changes";
|
||||
|
||||
function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list views-approval-list";
|
||||
@@ -262,13 +273,20 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// All three actions are stamped on every pending row; the per-viewer
|
||||
// All four actions are stamped on every pending row; the per-viewer
|
||||
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
|
||||
// decide which are enabled vs. greyed out with a tooltip. m's ask
|
||||
// (2026-05-17): show what's possible but disable what isn't, rather
|
||||
// than alert-after-click. The server still enforces — disabled buttons
|
||||
// are a UI hint, not a security gate.
|
||||
//
|
||||
// suggest_changes is hidden for non-update lifecycles (the backend
|
||||
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
|
||||
// so we don't even render the button for them).
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
if (detail.lifecycle_event === "update") {
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
}
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
} else if (detail.status) {
|
||||
@@ -285,6 +303,22 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
// Back-link from the OLD changes_requested row to the NEW pending
|
||||
// counter row (t-paliad-216). Hydrated server-side as
|
||||
// detail.next_request_id; the surface renders a link that scrolls
|
||||
// / filters to the new row. Falsy next_request_id = no link (e.g.
|
||||
// older rows pre-mig-103, or rows where the server hasn't joined the
|
||||
// back-pointer).
|
||||
if (detail.status === "changes_requested" && detail.next_request_id) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "inbox-row-next-request";
|
||||
link.href = `#request-${detail.next_request_id}`;
|
||||
link.dataset.nextRequestId = detail.next_request_id;
|
||||
const deciderName = detail.decider_name || "";
|
||||
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
|
||||
li.appendChild(link);
|
||||
}
|
||||
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
@@ -321,17 +355,24 @@ function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
||||
}
|
||||
|
||||
function approvalActionBtn(
|
||||
action: "approve" | "reject" | "revoke",
|
||||
action: ApprovalAction,
|
||||
detail: ApprovalDetail,
|
||||
): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.action = action;
|
||||
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
|
||||
// suggest_changes shares the secondary style with revoke; reject is
|
||||
// danger (terminal "no"); approve is primary.
|
||||
const cls = action === "approve"
|
||||
? "btn-primary"
|
||||
: action === "reject"
|
||||
? "btn-danger"
|
||||
: "btn-secondary";
|
||||
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
|
||||
// approve / reject share the eligibility gate; revoke is requester-only.
|
||||
// approve / reject / suggest_changes share the canApprove eligibility
|
||||
// gate; revoke is requester-only.
|
||||
const reason = disabledReasonFor(action, detail);
|
||||
if (reason) {
|
||||
btn.disabled = true;
|
||||
@@ -341,13 +382,13 @@ function approvalActionBtn(
|
||||
}
|
||||
|
||||
function disabledReasonFor(
|
||||
action: "approve" | "reject" | "revoke",
|
||||
action: ApprovalAction,
|
||||
detail: ApprovalDetail,
|
||||
): I18nKey | null {
|
||||
if (action === "revoke") {
|
||||
return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester";
|
||||
}
|
||||
// approve + reject — same gate as the server's canApprove.
|
||||
// approve / reject / suggest_changes — same gate as the server's canApprove.
|
||||
if (detail.viewer_can_approve) return null;
|
||||
if (detail.viewer_is_requester) return "approvals.disabled.self_approval";
|
||||
return "approvals.disabled.not_authorized";
|
||||
|
||||
67
frontend/src/client/views/verfahrensablauf-core.test.ts
Normal file
67
frontend/src/client/views/verfahrensablauf-core.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
deadlineCardHtml,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
// cells (m/paliad#59). When CardOpts.editable=true the card renderer must
|
||||
// emit `class="… frist-date-edit"` with `data-rule-code` + `data-current-
|
||||
// date` on the date span. Pages then attach a delegated click handler that
|
||||
// resolves that selector to swap in an inline `<input type="date">`. If a
|
||||
// future refactor drops the attrs, /tools/verfahrensablauf and
|
||||
// /tools/fristenrechner both silently lose click-to-edit (no script error,
|
||||
// nothing happens on click). These tests pin the contract.
|
||||
//
|
||||
// Fixture leaves ruleRef/legalSource* empty so deadlineCardHtml stays
|
||||
// inside its non-DOM code paths (escHtml is DOM-backed and bun test runs
|
||||
// in plain Node without jsdom).
|
||||
|
||||
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
|
||||
code: "upc-rop-12",
|
||||
name: "Klageerwiderung",
|
||||
nameEN: "Statement of Defence",
|
||||
party: "defendant",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-07-15",
|
||||
originalDate: "2026-07-15",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
test("date span carries frist-date-edit class + data-rule-code + data-current-date", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true, editable: true });
|
||||
expect(html).toContain('class="timeline-date frist-date-edit"');
|
||||
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||
expect(html).toContain('data-current-date="2026-07-15"');
|
||||
expect(html).toContain('role="button"');
|
||||
expect(html).toContain('tabindex="0"');
|
||||
});
|
||||
|
||||
test("editable=false (default) emits the date span without click-to-edit attrs", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).toContain("timeline-date");
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
expect(html).not.toContain('role="button"');
|
||||
});
|
||||
|
||||
test("root event suppresses editable even when editable=true (root has no override semantic)", () => {
|
||||
const html = deadlineCardHtml(dl({ isRootEvent: true }), { showParty: true, editable: true });
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
|
||||
test("isCourtSet renders the court-set placeholder with click-to-edit so users can pin a real date", () => {
|
||||
const html = deadlineCardHtml(dl({ isCourtSet: true }), { showParty: true, editable: true });
|
||||
expect(html).toContain("timeline-court-set frist-date-edit");
|
||||
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||
});
|
||||
|
||||
test("empty rule code with editable=true still suppresses click-to-edit (no anchor target)", () => {
|
||||
const html = deadlineCardHtml(dl({ code: "" }), { showParty: true, editable: true });
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
});
|
||||
@@ -95,8 +95,21 @@ export function priorityRendering(
|
||||
export interface DeadlineResponse {
|
||||
proceedingType: string;
|
||||
proceedingName: string;
|
||||
// proceedingNameEN: English label of the picked proceeding. Empty
|
||||
// when not populated server-side; frontend falls back to
|
||||
// proceedingName. Used for the "Trigger event" fallback when the
|
||||
// timeline has no root rule. (m/paliad#58)
|
||||
proceedingNameEN?: string;
|
||||
triggerDate: string;
|
||||
deadlines: CalculatedDeadline[];
|
||||
// contextualNote / contextualNoteEN render as a banner above the
|
||||
// timeline. Populated when the picked proceeding is a sub-track of
|
||||
// another proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
|
||||
// with_ccr) — the server routes to the parent's rules but keeps the
|
||||
// picked proceeding's identity in the response, and the note
|
||||
// explains the framing. (m/paliad#58)
|
||||
contextualNote?: string;
|
||||
contextualNoteEN?: string;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -219,6 +232,13 @@ export interface CardOpts {
|
||||
// verfahrensablauf abstract-browse surface keeps editable=false because
|
||||
// there's no anchor-override state on that page in Slice 1.
|
||||
editable?: boolean;
|
||||
// showNotes controls how the per-rule descriptive notes render:
|
||||
// true → expanded `<div class="timeline-notes">…</div>` below the card
|
||||
// false → compact ⓘ icon next to the meta line, full text on hover
|
||||
// (browser-native `title` attribute) and screen-reader-readable
|
||||
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
|
||||
// re-renders. Default false — notes are noisy on long timelines.
|
||||
showNotes?: boolean;
|
||||
}
|
||||
|
||||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||||
@@ -264,14 +284,19 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
}
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const notes = noteText
|
||||
const showNotes = opts.showNotes === true;
|
||||
const notesBlock = noteText && showNotes
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
: "";
|
||||
const noteHint = noteText && !showNotes
|
||||
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef)
|
||||
const meta = (opts.showParty || ruleRef || noteHint)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${ruleRef}
|
||||
${noteHint}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
@@ -284,7 +309,88 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
${notes}`;
|
||||
${notesBlock}`;
|
||||
}
|
||||
|
||||
// ─── inline date editor (click-to-edit per-rule due date) ────────────────
|
||||
//
|
||||
// The renderer emits `<span class="frist-date-edit" data-rule-code="…"
|
||||
// data-current-date="YYYY-MM-DD" role="button" tabindex="0">…</span>` when
|
||||
// CardOpts.editable is true. Pages call wireDateEditClicks() on their
|
||||
// result container once, and the delegated click/keydown handlers swap a
|
||||
// clicked span for a `<input type="date">` editor via openInlineDateEditor.
|
||||
// The caller's onCommit callback receives (ruleCode, newValue) — an empty
|
||||
// newValue means "revert" (clear the anchor override and let the calculator
|
||||
// re-project). The actual recompute is the caller's job — they own the
|
||||
// anchor-overrides map + the calc dispatch.
|
||||
|
||||
export function openInlineDateEditor(
|
||||
span: HTMLElement,
|
||||
onCommit: (ruleCode: string, newValue: string) => void,
|
||||
): void {
|
||||
const ruleCode = span.dataset.ruleCode || "";
|
||||
if (!ruleCode) return;
|
||||
const current = span.dataset.currentDate || "";
|
||||
const editor = document.createElement("input");
|
||||
editor.type = "date";
|
||||
editor.className = "frist-date-edit-input";
|
||||
editor.value = current;
|
||||
|
||||
let done = false;
|
||||
const cancel = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
editor.replaceWith(span);
|
||||
};
|
||||
const commit = (newValue: string) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
onCommit(ruleCode, newValue);
|
||||
};
|
||||
|
||||
editor.addEventListener("blur", () => {
|
||||
if (editor.value !== current) commit(editor.value);
|
||||
else cancel();
|
||||
});
|
||||
editor.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key === "Enter") {
|
||||
e.preventDefault();
|
||||
editor.blur();
|
||||
} else if (ke.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
span.replaceWith(editor);
|
||||
editor.focus();
|
||||
if (editor.value) editor.select();
|
||||
}
|
||||
|
||||
// wireDateEditClicks attaches delegated click + keyboard handlers to the
|
||||
// timeline result container so click-to-edit survives every innerHTML
|
||||
// rewrite the page does on recalc. Idempotent — re-calling on the same
|
||||
// container does nothing (the dataset flag short-circuits).
|
||||
export function wireDateEditClicks(
|
||||
container: HTMLElement,
|
||||
onCommit: (ruleCode: string, newValue: string) => void,
|
||||
): void {
|
||||
if (container.dataset.dateEditWired === "1") return;
|
||||
container.dataset.dateEditWired = "1";
|
||||
container.addEventListener("click", (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
openInlineDateEditor(target, onCommit);
|
||||
});
|
||||
container.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
e.preventDefault();
|
||||
openInlineDateEditor(target, onCommit);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
@@ -358,7 +464,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function ProjectFormFields(): string {
|
||||
<option value="patent" data-i18n="projects.type.patent">Patent</option>
|
||||
<option value="case" data-i18n="projects.type.case">Verfahren</option>
|
||||
<option value="project" data-i18n="projects.type.project">Projekt (generisch)</option>
|
||||
<option value="other" data-i18n="projects.type.other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -139,6 +140,24 @@ export function ProjectFormFields(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Litigation-specific */}
|
||||
<div className="projekt-fields projekt-fields-litigation" id="fields-litigation" style="display:none">
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-opponent-code" data-i18n="projects.field.opponent_code">Gegner-Kürzel</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project-opponent-code"
|
||||
maxLength={16}
|
||||
pattern="[A-Z0-9-]{1,16}"
|
||||
placeholder="OPNT"
|
||||
data-i18n-placeholder="projects.field.opponent_code.placeholder"
|
||||
/>
|
||||
<p className="form-hint" data-i18n="projects.field.opponent_code.hint">
|
||||
Kurzes Kürzel der Gegenseite (Grossbuchstaben, Ziffern, Bindestriche, max. 16 Zeichen). Wird als mittleres Segment in automatisch abgeleiteten Projekt-Codes verwendet (z.B. EXMPL.OPNT.567.INF.CFI).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Case-specific */}
|
||||
<div className="projekt-fields projekt-fields-case" id="fields-case" style="display:none">
|
||||
<div className="form-field-row">
|
||||
@@ -151,20 +170,29 @@ export function ProjectFormFields(): string {
|
||||
<input type="text" id="project-case-number" placeholder="UPC_CFI_123/2026" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.our_side">Wir vertreten</label>
|
||||
<select id="project-our-side">
|
||||
<option value="" data-i18n="projects.field.our_side.unset">Unbekannt / nicht gesetzt</option>
|
||||
<option value="claimant" data-i18n="projects.field.our_side.claimant">Klägerseite</option>
|
||||
<option value="defendant" data-i18n="projects.field.our_side.defendant">Beklagtenseite</option>
|
||||
<option value="court" data-i18n="projects.field.our_side.court">Gericht / Tribunal</option>
|
||||
<option value="both" data-i18n="projects.field.our_side.both">Beide Seiten</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.our_side.hint">
|
||||
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.client_role">Mandantenrolle</label>
|
||||
<select id="project-our-side">
|
||||
<option value="" data-i18n="projects.field.client_role.unset">Unbekannt</option>
|
||||
<optgroup data-i18n-label="projects.field.client_role.group.active" label="Aktiv (wir greifen an)">
|
||||
<option value="claimant" data-i18n="projects.field.client_role.claimant">Klägerseite</option>
|
||||
<option value="applicant" data-i18n="projects.field.client_role.applicant">Antragsteller</option>
|
||||
<option value="appellant" data-i18n="projects.field.client_role.appellant">Berufungsführer</option>
|
||||
</optgroup>
|
||||
<optgroup data-i18n-label="projects.field.client_role.group.reactive" label="Reaktiv (wir verteidigen)">
|
||||
<option value="defendant" data-i18n="projects.field.client_role.defendant">Beklagtenseite</option>
|
||||
<option value="respondent" data-i18n="projects.field.client_role.respondent">Antragsgegner</option>
|
||||
</optgroup>
|
||||
<optgroup data-i18n-label="projects.field.client_role.group.other" label="Dritte / Sonstige">
|
||||
<option value="third_party" data-i18n="projects.field.client_role.third_party">Streithelfer / Dritter</option>
|
||||
<option value="other" data-i18n="projects.field.client_role.other">Sonstige Beteiligte</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.client_role.hint">
|
||||
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator: Aktiv → Klägerseite, Reaktiv → Beklagtenseite. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -5,12 +5,14 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// The /* __PALIAD_DASHBOARD_DATA__ */ token below is replaced at request time
|
||||
// by the Go handler (internal/handlers/dashboard_shell.go) with a JSON blob
|
||||
// assigned to window.__PALIAD_DASHBOARD__. Keep the token intact and exactly
|
||||
// once in the output.
|
||||
// The three /* __PALIAD_DASHBOARD_*__ */ tokens below are replaced at
|
||||
// request time by the Go handler (internal/handlers/dashboard_shell.go)
|
||||
// with JSON blobs assigned to window.__PALIAD_DASHBOARD__,
|
||||
// window.__PALIAD_DASHBOARD_LAYOUT__, and window.__PALIAD_DASHBOARD_CATALOG__.
|
||||
// Keep each token intact and exactly once in the output. The latter two
|
||||
// power the per-user configurable layout (t-paliad-219).
|
||||
const HYDRATION_SCRIPT =
|
||||
"/*__PALIAD_DASHBOARD_DATA__*/";
|
||||
"/*__PALIAD_DASHBOARD_DATA__*//*__PALIAD_DASHBOARD_LAYOUT__*//*__PALIAD_DASHBOARD_CATALOG__*/";
|
||||
|
||||
// Chevron used as the collapsible-section disclosure indicator. CSS rotates
|
||||
// it 90deg clockwise when the section is open via the
|
||||
@@ -23,12 +25,13 @@ const ICON_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
// renders all sections expanded so unstyled fallback is sensible.
|
||||
function CollapsibleSection(props: {
|
||||
id: string;
|
||||
widgetKey: string;
|
||||
headingI18n: string;
|
||||
headingDe: string;
|
||||
children: any;
|
||||
}): string {
|
||||
return (
|
||||
<section className="dashboard-section" data-collapse-key={props.id} aria-expanded="true">
|
||||
<section className="dashboard-section" data-collapse-key={props.id} data-widget-key={props.widgetKey} aria-expanded="true">
|
||||
<button type="button" className="dashboard-section-toggle" aria-expanded="true">
|
||||
<h3 className="dashboard-section-heading" data-i18n={props.headingI18n}>{props.headingDe}</h3>
|
||||
<span className="dashboard-section-chevron" aria-hidden="true"
|
||||
@@ -88,7 +91,7 @@ export function renderDashboard(): string {
|
||||
</div>
|
||||
|
||||
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
|
||||
<CollapsibleSection id="summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<CollapsibleSection id="summary" widgetKey="deadline-summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
|
||||
@@ -116,7 +119,7 @@ export function renderDashboard(): string {
|
||||
{/* Matter summary card — single tappable card, kept outside the
|
||||
collapsible scaffold because its h3 is internal to the card
|
||||
and doubles as the navigation affordance. */}
|
||||
<section className="dashboard-matters">
|
||||
<section className="dashboard-matters" data-widget-key="matter-summary">
|
||||
<a href="/projects" className="dashboard-matter-card">
|
||||
<div className="dashboard-matter-header">
|
||||
<h3 data-i18n="dashboard.matters.heading">Meine Akten</h3>
|
||||
@@ -145,14 +148,14 @@ export function renderDashboard(): string {
|
||||
layout still applies; collapse hides the body of each col
|
||||
but leaves the heading row in the grid. */}
|
||||
<div className="dashboard-columns">
|
||||
<CollapsibleSection id="deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
<CollapsibleSection id="deadlines" widgetKey="upcoming-deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
|
||||
Keine Fristen in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection id="appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<CollapsibleSection id="appointments" widgetKey="upcoming-appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
|
||||
Keine Termine in den nächsten 7 Tagen.
|
||||
@@ -166,7 +169,7 @@ export function renderDashboard(): string {
|
||||
no chip filters, no URL state — a 30-day window of
|
||||
upcoming items grouped by day. The standalone /agenda
|
||||
route is unchanged for direct-link compatibility. */}
|
||||
<CollapsibleSection id="agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<CollapsibleSection id="agenda" widgetKey="inline-agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<div className="dashboard-agenda">
|
||||
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
|
||||
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
|
||||
@@ -178,9 +181,26 @@ export function renderDashboard(): string {
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Inbox-approvals widget (t-paliad-219 — new in v1). The
|
||||
list mirrors /inbox's "Approver" axis but capped at the
|
||||
widget's count setting. Renders the empty state when
|
||||
the user has no open approvals to review. */}
|
||||
<CollapsibleSection id="inbox-approvals" widgetKey="inbox-approvals" headingI18n="dashboard.inbox.heading" headingDe="Offene Freigaben">
|
||||
<div className="dashboard-inbox">
|
||||
<p className="dashboard-inbox-summary" id="dashboard-inbox-summary" style="display:none"></p>
|
||||
<ul className="dashboard-list" id="dashboard-inbox-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-inbox-empty" style="display:none" data-i18n="dashboard.inbox.empty">
|
||||
Keine offenen Freigaben.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/inbox" data-i18n="dashboard.inbox.full_link">Vollständigen Posteingang öffnen →</a>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Activity feed — moved under Agenda per m's design call
|
||||
(t-paliad-162). */}
|
||||
<CollapsibleSection id="activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<CollapsibleSection id="activity" widgetKey="recent-activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
|
||||
Noch keine Aktivität erfasst.
|
||||
|
||||
@@ -82,15 +82,21 @@ export function renderDeadlinesDetail(): string {
|
||||
<input type="date" id="deadline-due-edit" style="display:none" />
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
|
||||
{/* m/paliad#56 — Verfahrenshandlung block.
|
||||
Event type (parent concept) renders first; rule
|
||||
sits beneath as the citation under that event
|
||||
type. Editor splits them back into separate
|
||||
pickers but the read-only stack reads as one
|
||||
compound "Typ — Regel" surface. */}
|
||||
<dt data-i18n="deadlines.field.event_type">Typ (optional)</dt>
|
||||
<dd>
|
||||
<span id="deadline-event-types-display">—</span>
|
||||
<div id="deadline-event-types-edit" className="event-type-picker-host" style="display:none" />
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.source">Quelle</dt>
|
||||
<dd id="deadline-source-display" />
|
||||
|
||||
|
||||
@@ -101,18 +101,19 @@ export function renderDeadlinesNew(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-due" data-i18n="deadlines.field.due">Fälligkeitsdatum</label>
|
||||
<input type="date" id="deadline-due" required />
|
||||
</div>
|
||||
{/* m/paliad#56 — Regel sits directly beneath the Typ
|
||||
picker so the parent/child relationship reads at a
|
||||
glance. Due date is its own row below. */}
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-due" data-i18n="deadlines.field.due">Fälligkeitsdatum</label>
|
||||
<input type="date" id="deadline-due" required />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -161,19 +161,19 @@ export function renderFristenrechner(): string {
|
||||
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
|
||||
data-i18n="deadlines.step1.adhoc.upc">
|
||||
Custom UPC proceeding
|
||||
UPC proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
|
||||
data-i18n="deadlines.step1.adhoc.de">
|
||||
Custom DE proceeding
|
||||
DE proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
|
||||
data-i18n="deadlines.step1.adhoc.epa">
|
||||
Custom EPA proceeding
|
||||
EPA proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
|
||||
data-i18n="deadlines.step1.adhoc.dpma">
|
||||
Custom DPMA proceeding
|
||||
DPMA proceeding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,7 +485,10 @@ export function renderFristenrechner(): string {
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</label>
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
@@ -546,6 +549,10 @@ export function renderFristenrechner(): string {
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
|
||||
@@ -440,7 +440,23 @@ export type I18nKey =
|
||||
| "admin.section.planned"
|
||||
| "admin.subtitle"
|
||||
| "admin.team.add.direct"
|
||||
| "admin.team.add.full"
|
||||
| "admin.team.add.invite"
|
||||
| "admin.team.add_full.body"
|
||||
| "admin.team.add_full.cancel"
|
||||
| "admin.team.add_full.email"
|
||||
| "admin.team.add_full.error.email_exists"
|
||||
| "admin.team.add_full.error.generic"
|
||||
| "admin.team.add_full.error.unavailable"
|
||||
| "admin.team.add_full.feedback.added"
|
||||
| "admin.team.add_full.job_title"
|
||||
| "admin.team.add_full.lang"
|
||||
| "admin.team.add_full.name"
|
||||
| "admin.team.add_full.office"
|
||||
| "admin.team.add_full.profession"
|
||||
| "admin.team.add_full.send_welcome"
|
||||
| "admin.team.add_full.submit"
|
||||
| "admin.team.add_full.title"
|
||||
| "admin.team.col.actions"
|
||||
| "admin.team.col.additional"
|
||||
| "admin.team.col.created"
|
||||
@@ -583,6 +599,7 @@ export type I18nKey =
|
||||
| "approvals.action.approve"
|
||||
| "approvals.action.reject"
|
||||
| "approvals.action.revoke"
|
||||
| "approvals.action.suggest_changes"
|
||||
| "approvals.agent.byline"
|
||||
| "approvals.agent.label"
|
||||
| "approvals.agent.suggestion_pending"
|
||||
@@ -595,6 +612,7 @@ export type I18nKey =
|
||||
| "approvals.disabled.not_authorized"
|
||||
| "approvals.disabled.revoke_not_requester"
|
||||
| "approvals.disabled.self_approval"
|
||||
| "approvals.disabled.suggest_lifecycle"
|
||||
| "approvals.empty.mine"
|
||||
| "approvals.empty.pending_mine"
|
||||
| "approvals.entity.appointment"
|
||||
@@ -605,6 +623,8 @@ export type I18nKey =
|
||||
| "approvals.error.not_authorized"
|
||||
| "approvals.error.request_not_pending"
|
||||
| "approvals.error.self_approval"
|
||||
| "approvals.error.suggestion_lifecycle_invalid"
|
||||
| "approvals.error.suggestion_requires_change"
|
||||
| "approvals.heading"
|
||||
| "approvals.lifecycle.complete"
|
||||
| "approvals.lifecycle.create"
|
||||
@@ -631,11 +651,33 @@ export type I18nKey =
|
||||
| "approvals.required_role.pa"
|
||||
| "approvals.required_role.senior_pa"
|
||||
| "approvals.status.approved"
|
||||
| "approvals.status.changes_requested"
|
||||
| "approvals.status.pending"
|
||||
| "approvals.status.rejected"
|
||||
| "approvals.status.revoked"
|
||||
| "approvals.status.superseded"
|
||||
| "approvals.subtitle"
|
||||
| "approvals.suggest.cancel"
|
||||
| "approvals.suggest.context.approval_status"
|
||||
| "approvals.suggest.context.project"
|
||||
| "approvals.suggest.context.requested_at"
|
||||
| "approvals.suggest.context.requester"
|
||||
| "approvals.suggest.event_type_picker_unavailable"
|
||||
| "approvals.suggest.field.description"
|
||||
| "approvals.suggest.field.original_due_date"
|
||||
| "approvals.suggest.field.rule_code"
|
||||
| "approvals.suggest.field.warning_date"
|
||||
| "approvals.suggest.intro"
|
||||
| "approvals.suggest.modal_title"
|
||||
| "approvals.suggest.next_request_link"
|
||||
| "approvals.suggest.note_label"
|
||||
| "approvals.suggest.note_placeholder"
|
||||
| "approvals.suggest.section.context"
|
||||
| "approvals.suggest.section.editable"
|
||||
| "approvals.suggest.section.event_type_rule"
|
||||
| "approvals.suggest.submit"
|
||||
| "approvals.suggest.submit_disabled_hint"
|
||||
| "approvals.suggest.unsupported_lifecycle"
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
@@ -684,6 +726,43 @@ export type I18nKey =
|
||||
| "cal.view.week"
|
||||
| "cal.week.next"
|
||||
| "cal.week.prev"
|
||||
| "caldav.bindings.add"
|
||||
| "caldav.bindings.card.edit"
|
||||
| "caldav.bindings.card.enabled"
|
||||
| "caldav.bindings.card.remove"
|
||||
| "caldav.bindings.delete.confirm"
|
||||
| "caldav.bindings.delete.failed"
|
||||
| "caldav.bindings.empty"
|
||||
| "caldav.bindings.error.create_name_required"
|
||||
| "caldav.bindings.error.create_name_taken"
|
||||
| "caldav.bindings.error.create_unsupported"
|
||||
| "caldav.bindings.error.path"
|
||||
| "caldav.bindings.error.scope"
|
||||
| "caldav.bindings.error.scope_project"
|
||||
| "caldav.bindings.heading"
|
||||
| "caldav.bindings.hint"
|
||||
| "caldav.bindings.modal.add_title"
|
||||
| "caldav.bindings.modal.display_name"
|
||||
| "caldav.bindings.modal.display_name.placeholder"
|
||||
| "caldav.bindings.modal.edit_title"
|
||||
| "caldav.bindings.modal.scope"
|
||||
| "caldav.bindings.modal.scope.all_visible"
|
||||
| "caldav.bindings.modal.scope.personal_only"
|
||||
| "caldav.bindings.modal.scope.project"
|
||||
| "caldav.bindings.modal.scope.project.loading"
|
||||
| "caldav.bindings.modal.source"
|
||||
| "caldav.bindings.modal.source.create"
|
||||
| "caldav.bindings.modal.source.custom"
|
||||
| "caldav.bindings.modal.source.degrade"
|
||||
| "caldav.bindings.modal.source.discover_empty"
|
||||
| "caldav.bindings.modal.source.discover_failed"
|
||||
| "caldav.bindings.modal.source.existing"
|
||||
| "caldav.bindings.modal.source.loading"
|
||||
| "caldav.bindings.modal.submit_add"
|
||||
| "caldav.bindings.modal.submit_edit"
|
||||
| "caldav.bindings.scope.all_visible"
|
||||
| "caldav.bindings.scope.personal_only"
|
||||
| "caldav.bindings.scope.project"
|
||||
| "caldav.delete"
|
||||
| "caldav.delete.confirm"
|
||||
| "caldav.delete.done"
|
||||
@@ -865,6 +944,11 @@ export type I18nKey =
|
||||
| "dashboard.deadlines.empty"
|
||||
| "dashboard.deadlines.heading"
|
||||
| "dashboard.greeting.prefix"
|
||||
| "dashboard.inbox.empty"
|
||||
| "dashboard.inbox.entity.appointment"
|
||||
| "dashboard.inbox.entity.deadline"
|
||||
| "dashboard.inbox.full_link"
|
||||
| "dashboard.inbox.heading"
|
||||
| "dashboard.matters.active"
|
||||
| "dashboard.matters.archived"
|
||||
| "dashboard.matters.heading"
|
||||
@@ -904,7 +988,9 @@ export type I18nKey =
|
||||
| "deadlines.card.calc.flag.with_cci"
|
||||
| "deadlines.card.calc.flag.with_ccr"
|
||||
| "deadlines.card.calc.flags.label"
|
||||
| "deadlines.card.calc.pill_picker.change"
|
||||
| "deadlines.card.calc.pill_picker.label"
|
||||
| "deadlines.card.calc.pill_picker.locked_label"
|
||||
| "deadlines.card.calc.result.calculating"
|
||||
| "deadlines.card.calc.result.court_set"
|
||||
| "deadlines.card.calc.result.due"
|
||||
@@ -1069,6 +1155,7 @@ export type I18nKey =
|
||||
| "deadlines.neu.submit"
|
||||
| "deadlines.neu.subtitle"
|
||||
| "deadlines.neu.title"
|
||||
| "deadlines.notes.show"
|
||||
| "deadlines.optional.badge"
|
||||
| "deadlines.party.both"
|
||||
| "deadlines.party.both.label"
|
||||
@@ -1286,6 +1373,7 @@ 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"
|
||||
@@ -1294,6 +1382,7 @@ export type I18nKey =
|
||||
| "event.description.appointment_project_changed"
|
||||
| "event.description.appointment_updated"
|
||||
| "event.description.deadline_approval_approved"
|
||||
| "event.description.deadline_approval_changes_suggested"
|
||||
| "event.description.deadline_approval_rejected"
|
||||
| "event.description.deadline_approval_requested"
|
||||
| "event.description.deadline_approval_revoked"
|
||||
@@ -1309,6 +1398,7 @@ export type I18nKey =
|
||||
| "event.note.parent.deadline"
|
||||
| "event.note.parent.project"
|
||||
| "event.title.appointment_approval_approved"
|
||||
| "event.title.appointment_approval_changes_suggested"
|
||||
| "event.title.appointment_approval_rejected"
|
||||
| "event.title.appointment_approval_requested"
|
||||
| "event.title.appointment_approval_revoked"
|
||||
@@ -1323,6 +1413,7 @@ export type I18nKey =
|
||||
| "event.title.checklist_reset"
|
||||
| "event.title.checklist_unlinked"
|
||||
| "event.title.deadline_approval_approved"
|
||||
| "event.title.deadline_approval_changes_suggested"
|
||||
| "event.title.deadline_approval_rejected"
|
||||
| "event.title.deadline_approval_requested"
|
||||
| "event.title.deadline_approval_revoked"
|
||||
@@ -1651,6 +1742,7 @@ export type I18nKey =
|
||||
| "login.tab.login"
|
||||
| "login.tab.register"
|
||||
| "login.title"
|
||||
| "modal.close.label"
|
||||
| "nav.admin.audit"
|
||||
| "nav.admin.bereich"
|
||||
| "nav.admin.event_types"
|
||||
@@ -1899,6 +1991,7 @@ export type I18nKey =
|
||||
| "projects.chip.type.case"
|
||||
| "projects.chip.type.client"
|
||||
| "projects.chip.type.litigation"
|
||||
| "projects.chip.type.other"
|
||||
| "projects.chip.type.patent"
|
||||
| "projects.chip.type.project"
|
||||
| "projects.col.clientmatter"
|
||||
@@ -1935,6 +2028,8 @@ export type I18nKey =
|
||||
| "projects.detail.edit"
|
||||
| "projects.detail.edit.modal.title"
|
||||
| "projects.detail.edit.type_change_warning.title"
|
||||
| "projects.detail.export.button"
|
||||
| "projects.detail.export.tooltip"
|
||||
| "projects.detail.firmwide.off"
|
||||
| "projects.detail.firmwide.on"
|
||||
| "projects.detail.kinder.add"
|
||||
@@ -2030,11 +2125,21 @@ export type I18nKey =
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.submissions.action.generate"
|
||||
| "projects.detail.submissions.action.no_template"
|
||||
| "projects.detail.submissions.col.action"
|
||||
| "projects.detail.submissions.col.name"
|
||||
| "projects.detail.submissions.col.party"
|
||||
| "projects.detail.submissions.col.source"
|
||||
| "projects.detail.submissions.empty"
|
||||
| "projects.detail.submissions.empty.no_proceeding"
|
||||
| "projects.detail.submissions.hint"
|
||||
| "projects.detail.tab.checklisten"
|
||||
| "projects.detail.tab.fristen"
|
||||
| "projects.detail.tab.kinder"
|
||||
| "projects.detail.tab.notizen"
|
||||
| "projects.detail.tab.parteien"
|
||||
| "projects.detail.tab.submissions"
|
||||
| "projects.detail.tab.team"
|
||||
| "projects.detail.tab.termine"
|
||||
| "projects.detail.tab.verlauf"
|
||||
@@ -2074,6 +2179,19 @@ export type I18nKey =
|
||||
| "projects.field.billing_reference"
|
||||
| "projects.field.case_number"
|
||||
| "projects.field.client_number"
|
||||
| "projects.field.client_role"
|
||||
| "projects.field.client_role.appellant"
|
||||
| "projects.field.client_role.applicant"
|
||||
| "projects.field.client_role.claimant"
|
||||
| "projects.field.client_role.defendant"
|
||||
| "projects.field.client_role.group.active"
|
||||
| "projects.field.client_role.group.other"
|
||||
| "projects.field.client_role.group.reactive"
|
||||
| "projects.field.client_role.hint"
|
||||
| "projects.field.client_role.other"
|
||||
| "projects.field.client_role.respondent"
|
||||
| "projects.field.client_role.third_party"
|
||||
| "projects.field.client_role.unset"
|
||||
| "projects.field.clientmatter.hint"
|
||||
| "projects.field.collaborators"
|
||||
| "projects.field.collaborators.hint"
|
||||
@@ -2091,13 +2209,21 @@ export type I18nKey =
|
||||
| "projects.field.matter_number"
|
||||
| "projects.field.netdocuments_url"
|
||||
| "projects.field.office"
|
||||
| "projects.field.opponent_code"
|
||||
| "projects.field.opponent_code.hint"
|
||||
| "projects.field.opponent_code.placeholder"
|
||||
| "projects.field.our_side"
|
||||
| "projects.field.our_side.appellant"
|
||||
| "projects.field.our_side.applicant"
|
||||
| "projects.field.our_side.both"
|
||||
| "projects.field.our_side.claimant"
|
||||
| "projects.field.our_side.court"
|
||||
| "projects.field.our_side.defendant"
|
||||
| "projects.field.our_side.hint"
|
||||
| "projects.field.our_side.none"
|
||||
| "projects.field.our_side.other"
|
||||
| "projects.field.our_side.respondent"
|
||||
| "projects.field.our_side.third_party"
|
||||
| "projects.field.our_side.unset"
|
||||
| "projects.field.parent"
|
||||
| "projects.field.parent.hint"
|
||||
@@ -2144,6 +2270,9 @@ export type I18nKey =
|
||||
| "projects.team.derived.from"
|
||||
| "projects.team.derived.visibility"
|
||||
| "projects.team.direct"
|
||||
| "projects.team.error.forbidden"
|
||||
| "projects.team.error.generic"
|
||||
| "projects.team.error.last_admin"
|
||||
| "projects.team.inherited.hint"
|
||||
| "projects.team.profession.associate"
|
||||
| "projects.team.profession.hint"
|
||||
@@ -2154,6 +2283,8 @@ export type I18nKey =
|
||||
| "projects.team.profession.paralegal"
|
||||
| "projects.team.profession.partner"
|
||||
| "projects.team.profession.senior_pa"
|
||||
| "projects.team.responsibility.admin"
|
||||
| "projects.team.responsibility.admin.hint"
|
||||
| "projects.team.responsibility.external"
|
||||
| "projects.team.responsibility.lead"
|
||||
| "projects.team.responsibility.member"
|
||||
@@ -2202,6 +2333,7 @@ export type I18nKey =
|
||||
| "projects.type.case"
|
||||
| "projects.type.client"
|
||||
| "projects.type.litigation"
|
||||
| "projects.type.other"
|
||||
| "projects.type.patent"
|
||||
| "projects.type.project"
|
||||
| "projects.unavailable"
|
||||
@@ -2268,6 +2400,11 @@ export type I18nKey =
|
||||
| "team.role.senior_associate"
|
||||
| "team.role.trainee"
|
||||
| "team.search.placeholder"
|
||||
| "team.selection.clear"
|
||||
| "team.selection.count"
|
||||
| "team.selection.select_all"
|
||||
| "team.selection.send"
|
||||
| "team.selection.toggle_card"
|
||||
| "team.subtitle"
|
||||
| "team.title"
|
||||
| "theme.toggle.auto"
|
||||
@@ -2299,6 +2436,7 @@ export type I18nKey =
|
||||
| "views.bar.approval_role.approver_eligible"
|
||||
| "views.bar.approval_role.self_requested"
|
||||
| "views.bar.approval_status.approved"
|
||||
| "views.bar.approval_status.changes_requested"
|
||||
| "views.bar.approval_status.pending"
|
||||
| "views.bar.approval_status.rejected"
|
||||
| "views.bar.approval_status.revoked"
|
||||
|
||||
@@ -50,6 +50,14 @@ export function renderProjectsDetail(): string {
|
||||
<div className="entity-detail-meta">
|
||||
<span id="project-type-chip" className="entity-type-chip" />
|
||||
<span className="entity-ref" id="project-ref-display" />
|
||||
{/* Auto-derived project code (t-paliad-222 / m/paliad#50).
|
||||
Rendered as a separate badge so the user can still
|
||||
distinguish a custom reference (left badge) from a
|
||||
tree-derived code (right badge); when reference is
|
||||
blank, the derived code IS reference and only this
|
||||
badge shows. Hidden via inline style until the
|
||||
client populates it. */}
|
||||
<span className="entity-ref entity-ref-code" id="project-code-display" style="display:none" title="Auto-derived project code" />
|
||||
<span id="project-clientmatter" className="entity-ref" />
|
||||
<span id="project-status-chip" className="entity-status-chip" />
|
||||
<a id="project-netdocs" className="netdocs-link" target="_blank" rel="noopener" style="display:none">netDocuments ↗</a>
|
||||
@@ -80,6 +88,21 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="appointments" href="#" data-i18n="projects.detail.tab.termine">Termine</a>
|
||||
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
|
||||
{/* t-paliad-214 Slice 2 — project-subtree export button.
|
||||
Sits at the end of the tab nav. Hidden by default; the
|
||||
client unhides it after /api/me confirms the caller can
|
||||
extract (responsibility ∈ {lead, member} OR global_admin). */}
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="entity-tab entity-tab-action"
|
||||
style="display:none"
|
||||
title=""
|
||||
data-i18n-title="projects.detail.export.tooltip"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
@@ -247,6 +270,7 @@ export function renderProjectsDetail(): string {
|
||||
<div className="form-field">
|
||||
<label htmlFor="team-responsibility" data-i18n="projects.detail.team.form.responsibility">Rolle im Projekt</label>
|
||||
<select id="team-responsibility">
|
||||
<option value="admin" data-i18n="projects.team.responsibility.admin">Admin</option>
|
||||
<option value="lead" data-i18n="projects.team.responsibility.lead">Lead</option>
|
||||
<option value="member" selected data-i18n="projects.team.responsibility.member">Mitglied</option>
|
||||
<option value="observer" data-i18n="projects.team.responsibility.observer">Beobachter</option>
|
||||
@@ -571,6 +595,38 @@ export function renderProjectsDetail(): string {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Submissions (Schriftsätze) — t-paliad-215 Slice 1.
|
||||
Lists the project's filing-type rules with a per-row
|
||||
[Generieren] button when a .docx template resolves
|
||||
in the registry's fallback chain (firm → base/code →
|
||||
base/family → skeleton). Empty for projects with no
|
||||
proceeding bound; otherwise enumerates every active
|
||||
filing rule for the proceeding. */}
|
||||
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
|
||||
<p id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty.no_proceeding">
|
||||
Bitte zuerst einen Verfahrenstyp setzen.
|
||||
</p>
|
||||
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
|
||||
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
|
||||
</p>
|
||||
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="projects.detail.submissions.col.name">Schriftsatz</th>
|
||||
<th data-i18n="projects.detail.submissions.col.party">Partei</th>
|
||||
<th data-i18n="projects.detail.submissions.col.source">Rechtsgrundlage</th>
|
||||
<th data-i18n="projects.detail.submissions.col.action" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="project-submissions-body" />
|
||||
</table>
|
||||
</div>
|
||||
<p className="tool-subtitle submissions-hint" data-i18n="projects.detail.submissions.hint">
|
||||
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="entity-detail-footer" id="project-delete-wrap" style="display:none">
|
||||
<button id="project-delete-btn" className="btn-secondary" type="button" data-i18n="projects.detail.delete">
|
||||
Projekt archivieren
|
||||
|
||||
@@ -127,7 +127,8 @@ export function renderProjects(): string {
|
||||
<label><input type="checkbox" value="litigation" /><span data-i18n="projects.chip.type.litigation">Streitsache</span></label>
|
||||
<label><input type="checkbox" value="patent" /><span data-i18n="projects.chip.type.patent">Patent</span></label>
|
||||
<label><input type="checkbox" value="case" /><span data-i18n="projects.chip.type.case">Verfahren</span></label>
|
||||
<label><input type="checkbox" value="project" data-i18n-text="projects.chip.type.project"><span data-i18n="projects.chip.type.project">Projekt</span></input></label>
|
||||
<label><input type="checkbox" value="project" /><span data-i18n="projects.chip.type.project">Projekt</span></label>
|
||||
<label><input type="checkbox" value="other" /><span data-i18n="projects.chip.type.other">Sonstiges</span></label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" className="projects-chip" data-chip="has_open_deadlines" data-i18n="projects.chip.has_open_deadlines">Mit aktiven Fristen</button>
|
||||
|
||||
@@ -323,6 +323,25 @@ export function renderSettings(): string {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* t-paliad-212 Slice 2b — multi-calendar bindings.
|
||||
Each card is one (calendar, scope) binding layered on the
|
||||
single CalDAV server connection above. */}
|
||||
<div className="caldav-bindings-section" id="caldav-bindings-section" style="display:none">
|
||||
<div className="caldav-bindings-header">
|
||||
<h2 data-i18n="caldav.bindings.heading">Kalender</h2>
|
||||
<button type="button" id="caldav-bindings-add-btn" className="btn-secondary" data-i18n="caldav.bindings.add">
|
||||
+ Kalender hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-hint" data-i18n="caldav.bindings.hint">
|
||||
Verbinde mehrere Kalender mit Paliad — einen Master für alles oder eigene Kalender pro Projekt.
|
||||
</p>
|
||||
<div id="caldav-bindings-list" className="caldav-bindings-list" />
|
||||
<p className="entity-events-empty" id="caldav-bindings-empty" data-i18n="caldav.bindings.empty" style="display:none">
|
||||
Noch keine Kalender konfiguriert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="caldav-log-card">
|
||||
<h2 data-i18n="caldav.log.heading">Letzte Synchronisationen</h2>
|
||||
<table className="entity-table entity-table--readonly caldav-log-table">
|
||||
@@ -392,6 +411,89 @@ export function renderSettings(): string {
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
{/* t-paliad-212 Slice 2b — single-step Add/Edit modal for
|
||||
calendar bindings. Source picker (existing dropdown or
|
||||
custom URL) + scope radio + display name. Edit mode hides
|
||||
the source picker (path is fixed). */}
|
||||
<div id="caldav-binding-modal" className="modal-backdrop" style="display:none">
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-header">
|
||||
<h2 id="caldav-binding-modal-title" data-i18n="caldav.bindings.modal.add_title">Kalender hinzufügen</h2>
|
||||
<button type="button" className="modal-close" id="caldav-binding-modal-close" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<form id="caldav-binding-form" className="entity-form modal-body" autocomplete="off">
|
||||
<div className="form-field" id="caldav-binding-source-field">
|
||||
<label data-i18n="caldav.bindings.modal.source">Kalender</label>
|
||||
<div className="caldav-binding-source-modes" id="caldav-binding-source-modes">
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="existing" checked />
|
||||
<span data-i18n="caldav.bindings.modal.source.existing">Vorhandenen Kalender wählen</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label" id="caldav-binding-source-mode-create-row" style="display:none">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="create" />
|
||||
<span data-i18n="caldav.bindings.modal.source.create">Neuen Kalender erstellen</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="custom" />
|
||||
<span data-i18n="caldav.bindings.modal.source.custom">Eigene URL eingeben</span>
|
||||
</label>
|
||||
</div>
|
||||
<select id="caldav-binding-discover-select">
|
||||
<option value="" data-i18n="caldav.bindings.modal.source.loading">Lädt…</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="caldav-binding-custom-path"
|
||||
placeholder="https://..."
|
||||
style="display:none"
|
||||
/>
|
||||
{/* Slice 2c — Google-degrade notice. Shown when
|
||||
supports_mkcalendar=false; the create-new radio is
|
||||
hidden in that state, so users are nudged to the
|
||||
custom-URL path. */}
|
||||
<p className="form-hint caldav-binding-degrade-notice" id="caldav-binding-degrade-notice" style="display:none" data-i18n="caldav.bindings.modal.source.degrade">
|
||||
Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV.
|
||||
Erstelle den Kalender direkt in der Anbieter-Oberfläche und füge ihn hier per URL hinzu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="caldav-binding-display-name" data-i18n="caldav.bindings.modal.display_name">Anzeigename (optional)</label>
|
||||
<input type="text" id="caldav-binding-display-name" data-i18n-placeholder="caldav.bindings.modal.display_name.placeholder" placeholder="z.B. Projekt Acme v Bosch" />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label data-i18n="caldav.bindings.modal.scope">Inhalt</label>
|
||||
<div className="caldav-binding-scope-radios">
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="all_visible" checked />
|
||||
<span data-i18n="caldav.bindings.modal.scope.all_visible">Alles, was ich sehe</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="personal_only" />
|
||||
<span data-i18n="caldav.bindings.modal.scope.personal_only">Nur persönliche Termine</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="project" />
|
||||
<span data-i18n="caldav.bindings.modal.scope.project">Ein Projekt:</span>
|
||||
<select id="caldav-binding-project-select" disabled>
|
||||
<option value="" data-i18n="caldav.bindings.modal.scope.project.loading">Lädt…</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="form-msg" id="caldav-binding-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" id="caldav-binding-cancel-btn" data-i18n="common.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" id="caldav-binding-submit-btn" data-i18n="caldav.bindings.modal.submit_add">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -59,6 +59,14 @@
|
||||
--color-overlay-strong: rgba(0, 0, 0, 0.10);
|
||||
--color-overlay-modal: rgba(0, 0, 0, 0.4); /* modal/drawer scrim */
|
||||
|
||||
/* Segmented-control active pill — brand-lime accent so every density /
|
||||
view-mode toggle reads as the same primary action (m/paliad#52).
|
||||
Surfaces consuming these tokens: .filter-bar-segment (FilterBar
|
||||
density + future view-mode segments). Override on dark mode below. */
|
||||
--color-segment-active-bg: var(--color-accent);
|
||||
--color-segment-active-fg: var(--color-accent-dark);
|
||||
--color-segment-active-border: var(--color-accent);
|
||||
|
||||
/* Status palette — five buckets (red/amber/green/blue/neutral) shared
|
||||
across dashboard cards, frist-due-chips, agenda urgency, termin
|
||||
badges, login forms. Light values match the existing pastel-on-dark
|
||||
@@ -173,6 +181,13 @@
|
||||
--color-overlay-strong: rgba(255, 255, 255, 0.12);
|
||||
--color-overlay-modal: rgba(0, 0, 0, 0.65);
|
||||
|
||||
/* Segmented active pill — lime stays the brand on dark mode too; the
|
||||
--color-accent-dark token already resolves to midnight in both
|
||||
themes, keeping the foreground WCAG-AA on lime. */
|
||||
--color-segment-active-bg: var(--color-accent);
|
||||
--color-segment-active-fg: var(--color-accent-dark);
|
||||
--color-segment-active-border: var(--color-accent);
|
||||
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.55);
|
||||
@@ -2670,6 +2685,61 @@ input[type="range"]::-moz-range-thumb {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* m/paliad#57 part 4 — once a card is expanded into a calc panel,
|
||||
the rule-pill list is redundant with the calc panel's context
|
||||
picker (locked caption or fieldset). Hide it so the user isn't
|
||||
asked the same thing twice. The cross-cutting section stays —
|
||||
those pills are alternative concepts to explore, not the same
|
||||
proceeding context. */
|
||||
.fristen-card.is-expanded .fristen-card-pills-section--rules {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Locked-context caption when the user clicked a specific rule pill
|
||||
to expand. Shows the picked (proceeding, rule) tuple compactly
|
||||
with a small "ändern" button to swap back to the radio picker. */
|
||||
.fristen-card-calc-pill-locked {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border: 1px solid var(--color-border-subtle, #ececec);
|
||||
border-radius: 5px;
|
||||
background: rgba(198, 244, 28, 0.06);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.fristen-card-calc-pill-locked-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-muted, #777);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.fristen-card-calc-pill-locked-proc {
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #222);
|
||||
}
|
||||
.fristen-card-calc-pill-locked-rule {
|
||||
color: var(--color-text, #222);
|
||||
}
|
||||
.fristen-card-calc-pill-locked-source {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted, #888);
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.fristen-card-calc-pill-change {
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--color-link, #1267a8);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.fristen-card-calc-pill-change:hover { text-decoration: none; }
|
||||
|
||||
.fristen-card-calc-inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -3234,6 +3304,23 @@ input[type="range"]::-moz-range-thumb {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Sub-track contextual note banner (m/paliad#58). Renders above the
|
||||
timeline body when the picked proceeding is a sub-track of another
|
||||
proceeding (e.g. UPC CCR rendered standalone). Plain-text content;
|
||||
white-space: pre-line preserves paragraph breaks if server copy
|
||||
ever uses them. */
|
||||
.timeline-context-note {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: rgba(198, 244, 28, 0.10);
|
||||
border-left: 3px solid var(--brand-lime, #c6f41c);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text, #222);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
}
|
||||
@@ -3441,6 +3528,49 @@ input[type="range"]::-moz-range-thumb {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Notes toggle — checkbox affordance in the view-toggle bar that flips
|
||||
per-card descriptive notes between compact (ⓘ tooltip icon) and
|
||||
expanded (timeline-notes block). Sits with a leading separator so it
|
||||
reads as a distinct control from the radio view picker. */
|
||||
.fristen-notes-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
margin-left: auto;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.fristen-notes-option input[type=checkbox] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Compact note hint — sits in the timeline-meta line when the notes
|
||||
toggle is off. Native browser tooltip via title= attribute carries
|
||||
the full text on hover; tabindex=0 + aria-label make it
|
||||
keyboard / screen-reader accessible. */
|
||||
.timeline-note-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: help;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.timeline-note-hint:hover,
|
||||
.timeline-note-hint:focus-visible {
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Fristenrechner — three-column lane view (Proactive | Court | Reactive).
|
||||
Each lane is independently date-ordered; party=both rows render below
|
||||
as full-width spans because they apply to all sides. */
|
||||
@@ -3839,7 +3969,177 @@ input[type="range"]::-moz-range-thumb {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* --- Modal --- */
|
||||
/* --- Unified modal primitive (t-paliad-217) ---
|
||||
Native <dialog>-backed. Layered on top of the legacy .modal-overlay /
|
||||
.modal-card / .modal-content / .modal classes below; those stay in
|
||||
place until each call site migrates to openModal(). The new BEM-style
|
||||
.modal__* selectors avoid colliding with the legacy class hierarchy. */
|
||||
|
||||
dialog.modal {
|
||||
border: none;
|
||||
border-radius: calc(var(--radius) * 1.5);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: 0;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
max-width: min(90vw, var(--modal-max-w, 480px));
|
||||
max-height: min(90vh, 40rem);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dialog.modal[data-size="sm"] { --modal-max-w: 380px; }
|
||||
dialog.modal[data-size="lg"] { --modal-max-w: 640px; }
|
||||
dialog.modal[data-size="full"] {
|
||||
--modal-max-w: 100vw;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
dialog.modal::backdrop {
|
||||
background: var(--color-overlay-modal);
|
||||
}
|
||||
|
||||
/* Phone breakpoint — full-screen takeover ABOVE the PWA bottom-nav.
|
||||
m's 2026-05-20 lock-in: the modal must not cover the bottom-nav and
|
||||
must close via the browser back-button (handled in modal.ts). */
|
||||
@media (max-width: 32rem) {
|
||||
dialog.modal {
|
||||
--modal-max-w: 100vw;
|
||||
border-radius: 0;
|
||||
max-height: calc(100vh - var(--bottom-nav-height, 56px));
|
||||
margin-bottom: var(--bottom-nav-height, 56px);
|
||||
}
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
flex-shrink: 0;
|
||||
padding: 1.25rem 1.5rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.25rem 0.5rem;
|
||||
line-height: 1;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.modal__close:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.modal__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1.5rem 1.25rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* --- approval-suggest modal body (t-paliad-217) ---
|
||||
The body is laid out as three sections (editable / context /
|
||||
comment), separated by light rules. Reuses the existing .form-field
|
||||
shapes so input typography matches /deadlines/new + views editor. */
|
||||
|
||||
.approval-suggest-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.approval-suggest-intro {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.approval-suggest-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.approval-suggest-section-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.approval-suggest-section--context {
|
||||
border-top: 1px dashed var(--color-border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.approval-suggest-context-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.4rem 1rem;
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.approval-suggest-context-grid dt {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.approval-suggest-context-grid dd {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.approval-suggest-prehint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.approval-suggest-section--note {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.approval-suggest-event-type-picker {
|
||||
/* Picker styles its own internals (.event-type-picker). */
|
||||
}
|
||||
|
||||
|
||||
/* Legacy modal classes follow — kept until the other ~7 modals migrate. */
|
||||
|
||||
/* --- Modal (legacy) --- */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
@@ -5204,6 +5504,40 @@ input[type="range"]::-moz-range-thumb {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Submissions panel — t-paliad-215 Slice 1. */
|
||||
.submission-row td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.submission-name {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.submission-code {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85em;
|
||||
font-family: var(--font-mono, monospace);
|
||||
display: block;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.submission-action-cell {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.submission-no-template {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.submissions-hint {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.checklist-instance-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
@@ -6476,6 +6810,17 @@ input[type="range"]::-moz-range-thumb {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Auto-derived project code badge (t-paliad-222 / m/paliad#50).
|
||||
Distinct from the user's manual reference badge — same mono shape,
|
||||
subtly bracketed so the reader knows it's a derived/computed value
|
||||
rather than something typed by hand. Renders only when distinct
|
||||
from the manual reference (see renderHeader in projects-detail.ts). */
|
||||
.entity-ref-code {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.entity-ref-code::before { content: "[ "; }
|
||||
.entity-ref-code::after { content: " ]"; }
|
||||
|
||||
.entity-detail-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -9280,7 +9625,7 @@ label.caldav-toggle-label {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border, #e5e5ed);
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.team-card:hover {
|
||||
@@ -9288,6 +9633,95 @@ label.caldav-toggle-label {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* t-paliad-223 (#53) — selected card highlight. */
|
||||
.team-card[data-selected="true"] {
|
||||
border-color: var(--color-accent, var(--hlc-lime));
|
||||
background: var(--color-bg-lime-tint, rgba(198, 244, 28, 0.08));
|
||||
box-shadow: 0 0 0 1px var(--color-accent, var(--hlc-lime)) inset;
|
||||
}
|
||||
|
||||
.team-card-select {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.team-card-select-input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-accent, var(--hlc-lime));
|
||||
}
|
||||
|
||||
/* Master "select all visible" row, sits above the team list. */
|
||||
.team-select-master-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted, #64647a);
|
||||
}
|
||||
|
||||
.team-select-master-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.team-select-master-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-accent, var(--hlc-lime));
|
||||
}
|
||||
|
||||
/* Sticky footer that takes over the broadcast action when ≥ 1 row is
|
||||
selected. z-index 150 sits above the mobile bottom-nav (100) and well
|
||||
below modal overlays (1000+), per t-paliad-223 design §4.5. */
|
||||
.team-selection-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 150;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 1.25rem;
|
||||
background: var(--color-surface, #ffffff);
|
||||
border-top: 2px solid var(--color-accent, var(--hlc-lime));
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.team-selection-count {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, var(--hlc-midnight));
|
||||
}
|
||||
|
||||
/* Reserve a small bottom margin on the main content while the footer is
|
||||
visible so the last row of cards doesn't tuck under the bar. */
|
||||
body.team-has-selection main {
|
||||
padding-bottom: 4.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.team-selection-footer {
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: calc(0.8rem + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
.team-selection-count {
|
||||
width: 100%;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.team-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
@@ -12125,37 +12559,12 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Broadcast compose modal — extends .modal-overlay / .modal pattern. */
|
||||
.modal-broadcast {
|
||||
width: 720px;
|
||||
max-width: 92vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-broadcast .modal-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.modal-broadcast label {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal-broadcast input[type="text"],
|
||||
.modal-broadcast textarea,
|
||||
.modal-broadcast select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal-broadcast textarea {
|
||||
/* Broadcast compose modal body styling. The shell (width, modal-body
|
||||
padding, base form-field rules) is owned by the unified modal
|
||||
primitive — these rules below cover only the broadcast-specific
|
||||
content. Textarea gets a code-monospace face so the placeholder
|
||||
syntax reads correctly. (Migrated onto openModal in t-paliad-217.) */
|
||||
.broadcast-body [data-broadcast-body] {
|
||||
resize: vertical;
|
||||
min-height: 200px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
@@ -13881,8 +14290,9 @@ dialog.quick-add-sheet::backdrop {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.filter-bar-segment .filter-bar-chip.agenda-chip-active {
|
||||
background: var(--color-surface, #ffffff);
|
||||
border-color: var(--color-border, #e5e7eb);
|
||||
background: var(--color-segment-active-bg);
|
||||
color: var(--color-segment-active-fg);
|
||||
border-color: var(--color-segment-active-border);
|
||||
}
|
||||
|
||||
.filter-bar-chip-pending {
|
||||
|
||||
@@ -75,6 +75,14 @@ export function renderTeam(): string {
|
||||
<div className="team-broadcast-wrap" id="team-broadcast-wrap" style="display:none">
|
||||
</div>
|
||||
|
||||
{/* t-paliad-223 (#53) — master "select all visible" checkbox. */}
|
||||
<div className="team-select-master-row">
|
||||
<label className="team-select-master-label">
|
||||
<input type="checkbox" id="team-select-master" />
|
||||
<span data-i18n="team.selection.select_all">Alle sichtbaren auswählen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="team-list" id="team-list" />
|
||||
|
||||
<div className="glossar-empty" id="team-empty" style="display:none">
|
||||
|
||||
@@ -163,7 +163,10 @@ export function renderVerfahrensablauf(): string {
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</label>
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
@@ -225,6 +228,10 @@ export function renderVerfahrensablauf(): string {
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
|
||||
7
go.mod
7
go.mod
@@ -4,20 +4,21 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/excelize/v2 v2.10.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
)
|
||||
|
||||
58
go.sum
58
go.sum
@@ -1,39 +1,11 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
@@ -43,26 +15,14 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
@@ -71,22 +31,12 @@ github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzx
|
||||
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -1,46 +1,78 @@
|
||||
// Package db owns the Paliad Postgres connection and embedded schema migrations.
|
||||
//
|
||||
// Migrations are golang-migrate format (NNN_description.up.sql / .down.sql) and
|
||||
// live in the migrations/ subdirectory, embedded into the binary so a single
|
||||
// artifact ships with its schema. The server applies pending migrations at
|
||||
// startup before binding the HTTP listener.
|
||||
// Migrations are NNN_description.up.sql / .down.sql files in the migrations/
|
||||
// subdirectory, embedded into the binary so a single artifact ships with its
|
||||
// schema. The server applies pending migrations at startup before binding
|
||||
// the HTTP listener.
|
||||
//
|
||||
// The runner tracks applied state as a set, not a counter: every applied
|
||||
// migration gets its own row in paliad.applied_migrations(version PK, name,
|
||||
// applied_at, checksum). On every deploy, pending = on_disk \ applied, in
|
||||
// ascending version order. Gaps in the version space are first-class — a
|
||||
// version that's missing from applied_migrations runs on the next deploy,
|
||||
// regardless of which higher versions are already applied.
|
||||
//
|
||||
// This is what closes the parallel-merge skip-hole that the single-counter
|
||||
// tracker (golang-migrate) silently fell into on 2026-05-20 (m/paliad#44).
|
||||
// Background and design: docs/design-migration-runner-applied-set-2026-05-20.md.
|
||||
//
|
||||
// .down.sql files ship in the embedded FS as reference material but are not
|
||||
// auto-applied — there are no call sites for rolling back, and operator
|
||||
// recovery (psql .down.sql + DELETE FROM paliad.applied_migrations WHERE
|
||||
// version=N) is the documented path. If a real call site for auto-rollback
|
||||
// materializes later, add it as a focused follow-up.
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// migrationsTable is the name of the golang-migrate tracking table. We use a
|
||||
// uniquely-named table (not the default "schema_migrations") because the
|
||||
// production Supabase instance hosts multiple apps in the `public` schema,
|
||||
// and a differently-shaped `public.schema_migrations` already exists there.
|
||||
// Using "paliad_schema_migrations" prevents collision at startup.
|
||||
// advisoryLockID is the Postgres advisory-lock id the runner takes around
|
||||
// the apply loop. Derived once from the table name so the value is stable
|
||||
// across processes — two concurrent deploys (rolling Dokploy update, dev
|
||||
// laptop hitting the same scratch DB as CI) serialize on this id rather
|
||||
// than racing on the pending set.
|
||||
//
|
||||
// The table lives in the `public` schema (golang-migrate's default) rather
|
||||
// than `paliad`. Rationale: migration 001's down-step is
|
||||
// DROP SCHEMA IF EXISTS paliad CASCADE
|
||||
// which would take the tracking table with it — breaking any subsequent
|
||||
// migrate.Up() call. Keeping the tracker in `public` makes the down-path
|
||||
// safe and idempotent.
|
||||
const migrationsTable = "paliad_schema_migrations"
|
||||
// FNV-1a-64 is good enough: the id only has to be a stable int64, not
|
||||
// cryptographically uniform. Process-wide constant.
|
||||
var advisoryLockID = func() int64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte("paliad.applied_migrations"))
|
||||
return int64(h.Sum64())
|
||||
}()
|
||||
|
||||
// ApplyMigrations runs all pending up-migrations against the given database
|
||||
// URL. Returns nil if no migrations were pending. Safe to call repeatedly.
|
||||
// migration is one *.up.sql file from the embedded FS.
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
filename string
|
||||
}
|
||||
|
||||
// ApplyMigrations applies every pending up-migration to the given database.
|
||||
//
|
||||
// Pre-creates the `paliad` schema before invoking golang-migrate because the
|
||||
// first migration creates it and golang-migrate's tracking table would
|
||||
// otherwise be created in whatever `current_schema()` happens to be.
|
||||
// Safe to call repeatedly; a fully-applied tree is a no-op. Returns the
|
||||
// first error encountered (with the offending migration filename wrapped
|
||||
// in the message) and leaves the rest of pending unapplied — same fail-fast
|
||||
// posture as the previous golang-migrate runner.
|
||||
//
|
||||
// On first deploy of this code path against a database that still has the
|
||||
// legacy paliad.paliad_schema_migrations counter at version N, the runner
|
||||
// seeds paliad.applied_migrations with rows 1..N (checksum NULL) before
|
||||
// applying anything new. The first deploy is therefore effectively a
|
||||
// no-op against the schema — the bootstrap just relabels existing state.
|
||||
func ApplyMigrations(databaseURL string) error {
|
||||
if databaseURL == "" {
|
||||
return errors.New("database URL is empty")
|
||||
@@ -51,39 +83,250 @@ func ApplyMigrations(databaseURL string) error {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.Ping(); err != nil {
|
||||
return fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
// Bootstrap the paliad schema so later migrations can target it cleanly.
|
||||
// This duplicates migration 001, but is idempotent via IF NOT EXISTS and
|
||||
// ensures the schema exists before golang-migrate touches the DB.
|
||||
// Ensure the paliad schema exists. Mig 001 also creates it; the
|
||||
// applied_migrations table lives in paliad.* and gets created before
|
||||
// any migrations run, so the schema must exist first.
|
||||
if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil {
|
||||
return fmt.Errorf("ensure paliad schema: %w", err)
|
||||
}
|
||||
|
||||
source, err := iofs.New(migrationFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open migration source: %w", err)
|
||||
if _, err := conn.Exec(`SELECT pg_advisory_lock($1)`, advisoryLockID); err != nil {
|
||||
return fmt.Errorf("acquire advisory lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(`SELECT pg_advisory_unlock($1)`, advisoryLockID)
|
||||
}()
|
||||
|
||||
if _, err := conn.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS paliad.applied_migrations (
|
||||
version int NOT NULL PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||
checksum text NULL
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create applied_migrations: %w", err)
|
||||
}
|
||||
|
||||
driver, err := postgres.WithInstance(conn, &postgres.Config{
|
||||
// Unique tracking-table name avoids collision with pre-existing
|
||||
// public.schema_migrations owned by other apps on this Postgres.
|
||||
MigrationsTable: migrationsTable,
|
||||
})
|
||||
onDisk, err := scanEmbeddedMigrations()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create migration driver: %w", err)
|
||||
return fmt.Errorf("scan embedded migrations: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create migrator: %w", err)
|
||||
if err := bootstrapFromLegacyTracker(conn, onDisk); err != nil {
|
||||
return fmt.Errorf("bootstrap from legacy tracker: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("apply migrations: %w", err)
|
||||
applied, err := readAppliedMigrations(conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read applied_migrations: %w", err)
|
||||
}
|
||||
|
||||
if err := checkNameAgreement(onDisk, applied); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, m := range onDisk {
|
||||
if _, ok := applied[m.version]; ok {
|
||||
continue
|
||||
}
|
||||
if err := applyOne(conn, m); err != nil {
|
||||
return fmt.Errorf("apply %s: %w", m.filename, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanEmbeddedMigrations returns every NNN_*.up.sql in the embedded FS,
|
||||
// sorted by version ascending. Hard-fails on two files sharing the same
|
||||
// version prefix — that's the failure mode the parallel-merge incident
|
||||
// exposed, and the runner refuses to start rather than silently picking one.
|
||||
func scanEmbeddedMigrations() ([]migration, error) {
|
||||
entries, err := migrationFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
seen := map[int]string{}
|
||||
var out []migration
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".up.sql") {
|
||||
continue
|
||||
}
|
||||
v, n, ok := parseMigrationFilename(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unparseable migration filename %q "+
|
||||
"(expected NNN_description.up.sql)", name)
|
||||
}
|
||||
if prior, dup := seen[v]; dup {
|
||||
return nil, fmt.Errorf("two migrations at version %d: %q and %q — "+
|
||||
"rename one and redeploy", v, prior, name)
|
||||
}
|
||||
seen[v] = name
|
||||
out = append(out, migration{version: v, name: n, filename: name})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseMigrationFilename splits "NNN_description.up.sql" into (NNN, description).
|
||||
// Returns ok=false on any deviation from that shape.
|
||||
func parseMigrationFilename(filename string) (version int, name string, ok bool) {
|
||||
base := strings.TrimSuffix(filename, ".up.sql")
|
||||
if base == filename {
|
||||
return 0, "", false
|
||||
}
|
||||
underscore := strings.IndexByte(base, '_')
|
||||
if underscore <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
v, err := strconv.Atoi(base[:underscore])
|
||||
if err != nil {
|
||||
return 0, "", false
|
||||
}
|
||||
return v, base[underscore+1:], true
|
||||
}
|
||||
|
||||
// readAppliedMigrations returns a map version → name from
|
||||
// paliad.applied_migrations. Returns an empty map (no error) if the table
|
||||
// is missing — that's the fresh-DB path before the CREATE TABLE in
|
||||
// ApplyMigrations runs against it.
|
||||
func readAppliedMigrations(conn *sql.DB) (map[int]string, error) {
|
||||
rows, err := conn.Query(`SELECT version, name FROM paliad.applied_migrations`)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return map[int]string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int]string{}
|
||||
for rows.Next() {
|
||||
var v int
|
||||
var n string
|
||||
if err := rows.Scan(&v, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[v] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// bootstrapFromLegacyTracker seeds paliad.applied_migrations from
|
||||
// paliad.paliad_schema_migrations on the first deploy of the new runner
|
||||
// against a DB that previously ran golang-migrate.
|
||||
//
|
||||
// Behavior:
|
||||
// - applied_migrations already has rows → no-op (idempotent).
|
||||
// - applied_migrations empty AND legacy tracker missing → no-op
|
||||
// (virgin DB; the apply loop will run everything from scratch).
|
||||
// - applied_migrations empty AND legacy tracker present, clean, version N
|
||||
// → INSERT rows for every on-disk version ≤ N with checksum NULL.
|
||||
// - applied_migrations empty AND legacy tracker dirty → hard-fail.
|
||||
// The operator must recover the legacy tracker first (it being dirty
|
||||
// means a prior golang-migrate run crashed mid-flight); we will not
|
||||
// paper over an unknown state by guessing what landed.
|
||||
//
|
||||
// Backfilled rows have checksum NULL because the legacy runner didn't hash
|
||||
// anything — we can't fabricate a provenance hash today without falsely
|
||||
// claiming we know the byte-identity of what shipped historically.
|
||||
func bootstrapFromLegacyTracker(conn *sql.DB, onDisk []migration) error {
|
||||
var count int
|
||||
if err := conn.QueryRow(`SELECT count(*) FROM paliad.applied_migrations`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("count applied_migrations: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var legacyVer int
|
||||
var legacyDirty bool
|
||||
err := conn.QueryRow(`SELECT version, dirty FROM paliad.paliad_schema_migrations LIMIT 1`).
|
||||
Scan(&legacyVer, &legacyDirty)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read legacy tracker: %w", err)
|
||||
}
|
||||
if legacyDirty {
|
||||
return fmt.Errorf("legacy paliad.paliad_schema_migrations is dirty at version %d — "+
|
||||
"recover manually before deploying", legacyVer)
|
||||
}
|
||||
|
||||
for _, m := range onDisk {
|
||||
if m.version > legacyVer {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.Exec(`
|
||||
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
|
||||
VALUES ($1, $2, now(), NULL)
|
||||
ON CONFLICT (version) DO NOTHING
|
||||
`, m.version, m.name); err != nil {
|
||||
return fmt.Errorf("backfill version %d: %w", m.version, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNameAgreement hard-fails if a version that's already applied has a
|
||||
// different name on disk than in the DB. Catches the post-merge rename
|
||||
// accident where someone renames `098_foo.up.sql` to `098_bar.up.sql` —
|
||||
// the SQL has already run on prod with the old name, so the rename is a
|
||||
// lie about history. Operator recovery: revert the rename, or update the
|
||||
// DB row if the rename is intentional.
|
||||
//
|
||||
// Backfilled rows have a name pulled from the on-disk filename, so an
|
||||
// out-of-the-box backfill never trips this check.
|
||||
func checkNameAgreement(onDisk []migration, applied map[int]string) error {
|
||||
for _, m := range onDisk {
|
||||
dbName, ok := applied[m.version]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if dbName != m.name {
|
||||
return fmt.Errorf("migration %d: disk name %q != DB name %q "+
|
||||
"(renamed after apply? revert the rename, or UPDATE paliad.applied_migrations "+
|
||||
"SET name=%q WHERE version=%d if the rename is intentional)",
|
||||
m.version, m.name, dbName, m.name, m.version)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyOne runs one migration's .up.sql plus its INSERT row in a single
|
||||
// transaction. All-or-nothing per migration: if the SQL fails, the row
|
||||
// isn't inserted and the next deploy re-tries from the same point. If
|
||||
// the INSERT fails (e.g. PK violation because the lock wasn't held), the
|
||||
// SQL rolls back too.
|
||||
func applyOne(conn *sql.DB, m migration) error {
|
||||
body, err := migrationFS.ReadFile("migrations/" + m.filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", m.filename, err)
|
||||
}
|
||||
checksum := fmt.Sprintf("%x", sha256.Sum256(body))
|
||||
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(string(body)); err != nil {
|
||||
return fmt.Errorf("exec sql: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
|
||||
VALUES ($1, $2, now(), $3)
|
||||
`, m.version, m.name, checksum); err != nil {
|
||||
return fmt.Errorf("record applied: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -1,60 +1,49 @@
|
||||
// Package db tests — migration dry-run gate.
|
||||
//
|
||||
// This is the test that catches mig-N crash-loops before they reach prod.
|
||||
// The convention since t-paliad-098/099 is that paliad migrations land in
|
||||
// numeric order on a single trunk; the next deploy runs whichever ones are
|
||||
// pending against the live `public.paliad_schema_migrations` tracker. A
|
||||
// migration that compiles cleanly but fails on apply (typo, missing column,
|
||||
// wrong CHECK shape) crashes the Dokploy container loop before paliad.de
|
||||
// finishes binding :8080, and the only way to learn about it today is to
|
||||
// watch the deploy log.
|
||||
// The new runner tracks applied state as a set in paliad.applied_migrations
|
||||
// (one row per migration; see migrate.go). A migration that compiles cleanly
|
||||
// but fails on apply (typo, missing column, wrong CHECK shape) crashes the
|
||||
// Dokploy container loop before paliad.de finishes binding :8080, and the
|
||||
// only way to learn about it today is to watch the deploy log.
|
||||
//
|
||||
// TestMigrations_DryRun closes that gap: for every *.up.sql in this
|
||||
// directory whose version is greater than the scratch DB's current tracker
|
||||
// version, it opens a transaction, runs the SQL, and ROLLBACKs. Any error
|
||||
// fails the test with the file name + Postgres error. Always non-destructive
|
||||
// — the ROLLBACK runs even on success, so the scratch DB stays at its
|
||||
// starting version.
|
||||
// directory whose version is NOT present in paliad.applied_migrations on
|
||||
// the scratch DB, it opens a transaction, runs the SQL, and ROLLBACKs.
|
||||
// Any error fails the test with the file name + Postgres error. Always
|
||||
// non-destructive — the ROLLBACK runs even on success, so the scratch DB
|
||||
// stays at its starting set.
|
||||
//
|
||||
// "Pending" means: a version that's on disk but not in applied_migrations.
|
||||
// In CI against a fresh scratch DB (where applied_migrations either
|
||||
// doesn't exist or is empty), every migration is pending and gets
|
||||
// verified. On a developer laptop whose scratch DB is already at HEAD,
|
||||
// no migrations are pending and the test logs and passes — the protection
|
||||
// only kicks in the moment a new *.up.sql lands in the tree before the
|
||||
// developer runs `db.ApplyMigrations` against the same scratch DB.
|
||||
//
|
||||
// Requires TEST_DATABASE_URL (same pattern as the rest of the live-DB
|
||||
// tests). Skipped without it.
|
||||
//
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1.
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
|
||||
// docs/design-migration-runner-applied-set-2026-05-20.md §6.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// migration is one *.up.sql file from the embedded migrations FS.
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
filename string
|
||||
}
|
||||
|
||||
// TestMigrations_DryRun walks every pending *.up.sql in numeric order,
|
||||
// applies each inside its own BEGIN/ROLLBACK against the scratch DB, and
|
||||
// fails the test on the first SQL error. Reports per-file as a sub-test so
|
||||
// `go test -v` shows which migration failed.
|
||||
//
|
||||
// What "pending" means: greater than the scratch DB's current tracker
|
||||
// version (or 0 if the tracker doesn't exist yet). In CI against a fresh
|
||||
// scratch DB, every migration is pending and gets verified. On a developer
|
||||
// laptop whose scratch DB is already at HEAD, no migrations are pending and
|
||||
// the test logs the start version and passes — the protection only kicks in
|
||||
// the moment a new *.up.sql lands in the tree before the developer runs
|
||||
// `db.ApplyMigrations` against the same scratch DB.
|
||||
func TestMigrations_DryRun(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
@@ -79,28 +68,32 @@ func TestMigrations_DryRun(t *testing.T) {
|
||||
t.Fatalf("ensure paliad schema: %v", err)
|
||||
}
|
||||
|
||||
startVersion, dirty, err := currentTrackerVersion(conn)
|
||||
applied, err := readAppliedVersions(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read tracker: %v", err)
|
||||
t.Fatalf("read applied_migrations: %v", err)
|
||||
}
|
||||
if dirty {
|
||||
t.Fatalf("tracker is dirty at version %d — fix that first (DROP the tracker row "+
|
||||
"or restore from backup); the dry-run cannot trust a dirty starting state",
|
||||
startVersion)
|
||||
}
|
||||
t.Logf("scratch DB tracker at version %d; walking pending migrations from %d upward",
|
||||
startVersion, startVersion+1)
|
||||
|
||||
migs, err := loadPendingMigrations(startVersion)
|
||||
onDisk, err := scanEmbeddedMigrations()
|
||||
if err != nil {
|
||||
t.Fatalf("load migrations: %v", err)
|
||||
t.Fatalf("scan embedded migrations: %v", err)
|
||||
}
|
||||
if len(migs) == 0 {
|
||||
t.Logf("no pending migrations — scratch DB is at HEAD (%d)", startVersion)
|
||||
|
||||
var pending []migration
|
||||
for _, m := range onDisk {
|
||||
if !applied[m.version] {
|
||||
pending = append(pending, m)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
t.Logf("no pending migrations — scratch DB applied set covers every on-disk version (%d total)",
|
||||
len(onDisk))
|
||||
return
|
||||
}
|
||||
t.Logf("scratch DB has %d/%d on-disk migrations applied; walking %d pending",
|
||||
len(applied), len(onDisk), len(pending))
|
||||
|
||||
for _, m := range migs {
|
||||
for _, m := range pending {
|
||||
t.Run(fmt.Sprintf("%03d_%s", m.version, m.name), func(t *testing.T) {
|
||||
body, err := migrationFS.ReadFile("migrations/" + m.filename)
|
||||
if err != nil {
|
||||
@@ -110,10 +103,10 @@ func TestMigrations_DryRun(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
// Always rollback; the dry-run must not leave the scratch DB
|
||||
// at a different version than where it started. Rollback is
|
||||
// safe to call even after a failed Exec — Postgres aborts the
|
||||
// transaction internally on the first error.
|
||||
// Always rollback; the dry-run must not leave the scratch
|
||||
// DB at a different applied set than where it started.
|
||||
// Rollback is safe after a failed Exec — Postgres aborts
|
||||
// the transaction internally on the first error.
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(string(body)); err != nil {
|
||||
@@ -123,76 +116,30 @@ func TestMigrations_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// currentTrackerVersion reads the latest version + dirty flag from the
|
||||
// `public.paliad_schema_migrations` tracker. Returns (0, false, nil) when the
|
||||
// tracker doesn't exist yet — that's the "fresh scratch DB" path.
|
||||
// readAppliedVersions returns the set of versions present in
|
||||
// paliad.applied_migrations on the scratch DB. Missing table → empty set
|
||||
// (fresh-DB path; the table only exists after the runner has been called).
|
||||
//
|
||||
// We don't use golang-migrate's API to read this because golang-migrate's
|
||||
// driver locks the tracker row on read; a test runner that calls this while
|
||||
// the developer has paliad running locally would race. A plain SELECT is
|
||||
// race-safe and matches what `psql` would show.
|
||||
func currentTrackerVersion(conn *sql.DB) (version int, dirty bool, err error) {
|
||||
const q = `SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`
|
||||
row := conn.QueryRow(q)
|
||||
if scanErr := row.Scan(&version, &dirty); scanErr != nil {
|
||||
// Missing table → fresh DB → start at 0. lib/pq surfaces this
|
||||
// as `pq.Error.Code = "42P01"` (undefined_table); the simpler
|
||||
// sql.ErrNoRows fires if the table exists but is empty (also
|
||||
// fresh-DB-shaped).
|
||||
if errors.Is(scanErr, sql.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if strings.Contains(scanErr.Error(), "does not exist") {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, scanErr
|
||||
}
|
||||
return version, dirty, nil
|
||||
}
|
||||
|
||||
// loadPendingMigrations returns every *.up.sql in the embedded FS whose
|
||||
// version is greater than startVersion, sorted by version ascending. A
|
||||
// filename like "098_submission_codes_prefix_and_rename.up.sql" yields
|
||||
// version=98, name="submission_codes_prefix_and_rename".
|
||||
func loadPendingMigrations(startVersion int) ([]migration, error) {
|
||||
entries, err := migrationFS.ReadDir("migrations")
|
||||
// We don't pre-create the table here because the dry-run is supposed to be
|
||||
// a passive observer — it must not mutate the scratch DB outside of its
|
||||
// own per-mig BEGIN/ROLLBACK probes. A "table doesn't exist" outcome is
|
||||
// the right read against a virgin scratch DB.
|
||||
func readAppliedVersions(conn *sql.DB) (map[int]bool, error) {
|
||||
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return map[int]bool{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var out []migration
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".up.sql") {
|
||||
continue
|
||||
defer rows.Close()
|
||||
out := map[int]bool{}
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, n, ok := parseMigrationName(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unparseable migration filename: %s "+
|
||||
"(expected NNN_description.up.sql)", name)
|
||||
}
|
||||
if v <= startVersion {
|
||||
continue
|
||||
}
|
||||
out = append(out, migration{version: v, name: n, filename: name})
|
||||
out[v] = true
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseMigrationName splits "NNN_description.up.sql" into (NNN, description).
|
||||
// Returns ok=false on any deviation from that shape.
|
||||
func parseMigrationName(filename string) (version int, name string, ok bool) {
|
||||
base := strings.TrimSuffix(filename, ".up.sql")
|
||||
if base == filename { // suffix wasn't present
|
||||
return 0, "", false
|
||||
}
|
||||
underscore := strings.IndexByte(base, '_')
|
||||
if underscore <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
v, err := strconv.Atoi(base[:underscore])
|
||||
if err != nil {
|
||||
return 0, "", false
|
||||
}
|
||||
return v, base[underscore+1:], true
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
27
internal/db/migrations/103_approval_suggest_changes.down.sql
Normal file
27
internal/db/migrations/103_approval_suggest_changes.down.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Reverse of 103_approval_suggest_changes.up.sql.
|
||||
--
|
||||
-- Drops the previous_request_id index + column, drops counter_payload, and
|
||||
-- restores the original status CHECK (without 'changes_requested'). If any
|
||||
-- live rows are at status='changes_requested' OR carry a non-NULL
|
||||
-- counter_payload OR previous_request_id, the down will fail on the CHECK
|
||||
-- restore. That is intentional: it forces an explicit cleanup decision
|
||||
-- before tearing the schema back.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 103 DOWN: revert suggest-changes schema extensions (t-paliad-216)',
|
||||
true);
|
||||
|
||||
DROP INDEX IF EXISTS paliad.approval_requests_previous_idx;
|
||||
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP COLUMN IF EXISTS previous_request_id;
|
||||
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP COLUMN IF EXISTS counter_payload;
|
||||
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD CONSTRAINT approval_requests_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded'));
|
||||
57
internal/db/migrations/103_approval_suggest_changes.up.sql
Normal file
57
internal/db/migrations/103_approval_suggest_changes.up.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- t-paliad-216 Slice A — add the "Suggest changes" action to the approval
|
||||
-- flow alongside Approve / Reject / Revoke. Design:
|
||||
-- docs/design-approval-suggest-changes-2026-05-19.md.
|
||||
--
|
||||
-- Mental model (m's 2026-05-19 decisions, §0a of the design doc):
|
||||
-- "Suggest changes" is not a soft-reject with a hint. It is the approver
|
||||
-- AUTHORING A COUNTER-PROPOSAL that gets re-injected into the approval
|
||||
-- flow as a fresh `pending` row. The original requester (no longer the
|
||||
-- new row's requested_by) becomes potentially-eligible to approve the
|
||||
-- counter — 4-Augen still holds via the standard self-approval guard.
|
||||
--
|
||||
-- Three schema additions to paliad.approval_requests:
|
||||
-- 1. Extend the status CHECK to allow 'changes_requested'.
|
||||
-- 2. counter_payload jsonb NULL — the approver's edited values,
|
||||
-- stored on the OLD (changes_requested) row so the audit chain
|
||||
-- can show "approver edited X, Y, Z" without joining forward.
|
||||
-- Also used as the `payload` for the NEW row spawned in the same
|
||||
-- tx by ApprovalService.SuggestChanges.
|
||||
-- 3. previous_request_id uuid NULL FK — back-pointer on the NEW row
|
||||
-- to the OLD (changes_requested) row that spawned it. ON DELETE
|
||||
-- SET NULL keeps a survivor row intact if either end is ever
|
||||
-- pruned. Partial index covers chain traversal.
|
||||
--
|
||||
-- The set_config('paliad.audit_reason', ...) line is the universal
|
||||
-- convention for paliad migrations (mig 079 trigger pattern) — even
|
||||
-- pure-DDL migrations set it so an audit trigger that fires on any
|
||||
-- migration-touched table has a non-NULL reason string to record.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 103: add suggest-changes action — extend approval_requests.status CHECK with changes_requested, add counter_payload jsonb + previous_request_id FK (t-paliad-216 Slice A)',
|
||||
true);
|
||||
|
||||
-- 1. Extend approval_requests.status CHECK.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD CONSTRAINT approval_requests_status_check
|
||||
CHECK (status IN (
|
||||
'pending', 'approved', 'rejected', 'revoked', 'superseded', 'changes_requested'
|
||||
));
|
||||
|
||||
-- 2. counter_payload — the approver's edited values when suggesting
|
||||
-- changes. Stays NULL for every status other than changes_requested.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN counter_payload jsonb;
|
||||
|
||||
-- 3. previous_request_id — back-pointer FK. NULL for first-attempt rows;
|
||||
-- set to the prior (changes_requested) row's id on the NEW row spawned
|
||||
-- by SuggestChanges. ON DELETE SET NULL keeps survivor rows intact.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN previous_request_id uuid
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS approval_requests_previous_idx
|
||||
ON paliad.approval_requests (previous_request_id)
|
||||
WHERE previous_request_id IS NOT NULL;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Revert mig 104 — restore the bracket-bearing Einspruch names and
|
||||
-- flip the CCR priority back to 'informational'.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 104 down: restore "Einspruch (R. 19 VerfO)" and "Einspruch (R. 19 i.V.m. R. 46 VerfO)" names + flip upc.inf.cfi.ccr priority back to informational',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection (RoP 19)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch (R. 19 VerfO)'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET priority = 'informational'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.ccr'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.priority = 'optional';
|
||||
@@ -0,0 +1,89 @@
|
||||
-- t-paliad-207 (m's interactive session) — two label/priority polish
|
||||
-- fixes on upc.inf.cfi / upc.rev.cfi:
|
||||
--
|
||||
-- 1. **CCR priority informational → optional.** m's correction
|
||||
-- 2026-05-18 18:01: the Nichtigkeitswiderklage is a substantive
|
||||
-- defensive choice the defendant makes — not just an informational
|
||||
-- notice. priority='optional' renders it as an unchecked save row
|
||||
-- the user can opt into. The fermi amend (commit e8d658a) flipping
|
||||
-- this didn't land in main — paliadin's merge of mig 100 (commit
|
||||
-- c10f8cf, merge 4ddcd28) picked up the pre-amend 'informational'
|
||||
-- version. This is the recovery.
|
||||
--
|
||||
-- 2. **Strip rule citation from Einspruch names.** m's correction
|
||||
-- 2026-05-18 18:08: every other rule name in the corpus carries
|
||||
-- the act-name without a parenthetical rule cite (Klageerwiderung,
|
||||
-- Antrag auf Patentänderung, Replik, etc.). The Einspruch rule
|
||||
-- names are the outliers:
|
||||
-- upc.inf.cfi.prelim "Einspruch (R. 19 VerfO)" → "Einspruch"
|
||||
-- upc.rev.cfi.prelim "Einspruch (R. 19 i.V.m. R. 46 VerfO)" → "Einspruch"
|
||||
-- and EN equivalents:
|
||||
-- "Preliminary Objection (RoP 19)" → "Preliminary Objection"
|
||||
-- "Preliminary Objection (RoP 19 in conjunction with RoP 46)"
|
||||
-- → "Preliminary Objection"
|
||||
-- The legal_source / rule_code columns already carry the citation
|
||||
-- and render in the deadline card's meta line, so the name stays
|
||||
-- clean. The R.46-i.V.m. distinction is preserved in the legal
|
||||
-- source field (RoP.019.1 for both — m may want to further
|
||||
-- differentiate; flagged in description text instead).
|
||||
--
|
||||
-- audit_reason set_config required at the top — the deadline_rules
|
||||
-- audit trigger raises EXCEPTION 'audit reason required' on any
|
||||
-- mutation without it (cf. mig 099 hotfix history).
|
||||
--
|
||||
-- Idempotency:
|
||||
-- * Priority UPDATE guarded on the current 'informational' value.
|
||||
-- * Name UPDATEs guarded on the current parenthetical-bearing names.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 104: flip upc.inf.cfi.ccr priority informational→optional + strip rule-cite brackets from R.19 Einspruch names on both upc.inf.cfi.prelim and upc.rev.cfi.prelim (m''s corrections 2026-05-18, t-paliad-207 interactive session)',
|
||||
true);
|
||||
|
||||
-- 1) Flip CCR priority
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET priority = 'optional'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.ccr'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.priority = 'informational';
|
||||
|
||||
-- 2a) Strip "(R. 19 VerfO)" from upc.inf.cfi.prelim DE/EN names
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch (R. 19 VerfO)';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.inf.cfi'
|
||||
AND dr.submission_code = 'upc.inf.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection (RoP 19)';
|
||||
|
||||
-- 2b) Strip "(R. 19 i.V.m. R. 46 VerfO)" from upc.rev.cfi.prelim DE/EN names
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name = 'Einspruch'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)';
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET name_en = 'Preliminary Objection'
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE dr.proceeding_type_id = pt.id
|
||||
AND pt.code = 'upc.rev.cfi'
|
||||
AND dr.submission_code = 'upc.rev.cfi.prelim'
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Revert mig 105 — restore the pre-mig-105 sequence_order values
|
||||
-- (post-mig-100 state). Same two-phase swap pattern.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 105 down: restore pre-track-aware sequence_order on upc.inf.cfi rules',
|
||||
true);
|
||||
|
||||
-- Phase 1: park
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1011 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 20;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1012 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 22;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1013 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 30;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1020 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 12;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1021 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 32;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1022 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 24;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1030 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 14;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1031 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 34;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1032 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 26;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 1033 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 36;
|
||||
|
||||
-- Phase 2: assign originals
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 11 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1011;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 12 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1012;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 13 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1013;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 20 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1020;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 21 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1021;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 22 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1022;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 30 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1030;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 31 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1031;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 32 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1032;
|
||||
UPDATE paliad.deadline_rules SET sequence_order = 33 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1033;
|
||||
211
internal/db/migrations/105_upc_inf_track_aware_sequence.up.sql
Normal file
211
internal/db/migrations/105_upc_inf_track_aware_sequence.up.sql
Normal file
@@ -0,0 +1,211 @@
|
||||
-- t-paliad-207 — re-sequence upc.inf.cfi rules so within any tied-date
|
||||
-- group the infringement-track responses sit ABOVE the revocation-
|
||||
-- track responses ABOVE the amendment-track responses. m's ask
|
||||
-- 2026-05-18 18:08: "the infringement parts (like Replik) should show
|
||||
-- above the part for the revocation (Erwiderung Nichtigkeitswider-
|
||||
-- klage)".
|
||||
--
|
||||
-- Three tracks coexist on upc.inf.cfi once the with_ccr / with_amend
|
||||
-- flags are set. They share calendar dates because R.29 / R.30 / R.32
|
||||
-- all key off the SoD or its descendants. The current sequence_orders
|
||||
-- (post-mig 100) interleave them; the user sees Erwiderung-zur-CCR
|
||||
-- before Replik even though Replik is the infringement-side response
|
||||
-- to the same triggering event.
|
||||
--
|
||||
-- New sequence_order assignment (preserves the soc=0, prelim=5,
|
||||
-- sod=10, ccr=11 anchors at the head; phase markers interim/oral/
|
||||
-- decision/cost_app/appeal_spawn keep their existing 40/50/60/70/80
|
||||
-- slots at the tail):
|
||||
--
|
||||
-- Old → New submission_code track date
|
||||
-- --- --- --------------- ----- ----
|
||||
-- 0 0 upc.inf.cfi.soc — D+0
|
||||
-- 5 5 upc.inf.cfi.prelim — D+1mo
|
||||
-- 10 10 upc.inf.cfi.sod infringement D+3mo
|
||||
-- 11 20 upc.inf.cfi.ccr revocation D+3mo
|
||||
-- 20 12 upc.inf.cfi.reply infringement D+5mo ← MOVED UP
|
||||
-- 12 22 upc.inf.cfi.def_to_ccr revocation D+5mo
|
||||
-- 13 30 upc.inf.cfi.app_to_amend amendment D+5mo
|
||||
-- 30 14 upc.inf.cfi.rejoin infringement D+6mo ← MOVED UP
|
||||
-- 22 24 upc.inf.cfi.reply_def_ccr revocation D+7mo
|
||||
-- 21 32 upc.inf.cfi.def_to_amend amendment D+7mo
|
||||
-- 32 26 upc.inf.cfi.rejoin_reply_ccr revocation D+8mo
|
||||
-- 31 34 upc.inf.cfi.reply_def_amd amendment D+8mo
|
||||
-- 33 36 upc.inf.cfi.rejoin_amd amendment D+9mo
|
||||
-- 40 40 upc.inf.cfi.interim phase later
|
||||
-- 50 50 upc.inf.cfi.oral phase later
|
||||
-- 60 60 upc.inf.cfi.decision phase later
|
||||
-- 70 70 upc.inf.cfi.cost_app phase later
|
||||
-- 80 80 upc.inf.cfi.appeal_spawn phase later
|
||||
--
|
||||
-- Order within each tied-date group after the reshuffle:
|
||||
-- D+3mo: sod(10), ccr(20) — SoD then its CCR
|
||||
-- D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
|
||||
-- D+7mo: reply_def_ccr(24), def_to_amend(32) — rev → amd
|
||||
-- D+8mo: rejoin_reply_ccr(26), reply_def_amd(34) — rev → amd
|
||||
--
|
||||
-- (no infringement-track rule at +7mo or +8mo so revocation leads
|
||||
-- those dates; rejoin sits alone at +6mo so it has no peers to order
|
||||
-- against.)
|
||||
--
|
||||
-- audit_reason set_config required at the top — the deadline_rules
|
||||
-- audit trigger raises EXCEPTION 'audit reason required' on any
|
||||
-- mutation without it (cf. mig 099 hotfix history).
|
||||
--
|
||||
-- Idempotency: every UPDATE is guarded by both the submission_code
|
||||
-- AND the SOURCE sequence_order, so re-apply is a no-op once the new
|
||||
-- numbers are in place.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 105: re-sequence upc.inf.cfi rules track-aware (infringement → revocation → amendment within tied-date groups; m''s 2026-05-18 ask, t-paliad-207 interactive session)',
|
||||
true);
|
||||
|
||||
-- Two-phase swap to avoid sequence collisions during the UPDATE
|
||||
-- (otherwise two rules can briefly share a sequence_order if Postgres
|
||||
-- evaluates them in parallel). Phase 1: move every reshuffled rule to
|
||||
-- a high temporary number (1000+). Phase 2: assign final numbers.
|
||||
|
||||
-- ─── Phase 1: park reshuffled rules at 1000+ ────────────────────────
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1011
|
||||
WHERE submission_code = 'upc.inf.cfi.ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 11;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1012
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 12;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1013
|
||||
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 13;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1020
|
||||
WHERE submission_code = 'upc.inf.cfi.reply'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 20;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1021
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 21;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1022
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 22;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1030
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 30;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1031
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 31;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1032
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 32;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 1033
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 33;
|
||||
|
||||
-- ─── Phase 2: assign final track-aware numbers ──────────────────────
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 12
|
||||
WHERE submission_code = 'upc.inf.cfi.reply'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1020;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 14
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1030;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 20
|
||||
WHERE submission_code = 'upc.inf.cfi.ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1011;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 22
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1012;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 24
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1022;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 26
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1032;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 30
|
||||
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1013;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 32
|
||||
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1021;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 34
|
||||
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1031;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET sequence_order = 36
|
||||
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
|
||||
AND proceeding_type_id = 8
|
||||
AND lifecycle_state = 'published'
|
||||
AND sequence_order = 1033;
|
||||
28
internal/db/migrations/106_add_madrid_office.down.sql
Normal file
28
internal/db/migrations/106_add_madrid_office.down.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Revert mig 106 — drop 'madrid' from the office CHECK constraints.
|
||||
--
|
||||
-- Will fail if any users.office or partner_units.office row carries
|
||||
-- 'madrid' — that's intentional (the down has no opinion on the data;
|
||||
-- caller must clean up first or accept the failure).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 106 down: restore pre-madrid office CHECK on users + partner_units',
|
||||
true);
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_office_check;
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan'
|
||||
));
|
||||
|
||||
ALTER TABLE paliad.partner_units
|
||||
DROP CONSTRAINT IF EXISTS partner_units_office_check;
|
||||
ALTER TABLE paliad.partner_units
|
||||
ADD CONSTRAINT partner_units_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan'
|
||||
));
|
||||
42
internal/db/migrations/106_add_madrid_office.up.sql
Normal file
42
internal/db/migrations/106_add_madrid_office.up.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- mig 106 — add 'madrid' to firm office CHECK constraints
|
||||
--
|
||||
-- m's ask 2026-05-20 09:42: add Madrid as an HLC office, alongside the
|
||||
-- existing seven (munich, duesseldorf, hamburg, amsterdam, london,
|
||||
-- paris, milan). Two active CHECK constraints to extend:
|
||||
-- - paliad.users.office (mig 002)
|
||||
-- - paliad.partner_units.office (mig 018; renamed mig 024 + mig 027)
|
||||
--
|
||||
-- The Go-side source of truth lives in internal/offices/offices.go;
|
||||
-- this migration keeps the DB in sync.
|
||||
--
|
||||
-- Long-term, the admin area will let firms manage their own office
|
||||
-- list (separate issue) — but for now the list is hard-coded here
|
||||
-- + offices.go.
|
||||
--
|
||||
-- Non-blocking: extending a CHECK constraint is a metadata-only change
|
||||
-- on a small enum-style column.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 106: add madrid to firm office CHECK on users + partner_units',
|
||||
true);
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_office_check;
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan',
|
||||
'madrid'
|
||||
));
|
||||
|
||||
ALTER TABLE paliad.partner_units
|
||||
DROP CONSTRAINT IF EXISTS partner_units_office_check;
|
||||
ALTER TABLE paliad.partner_units
|
||||
ADD CONSTRAINT partner_units_office_check
|
||||
CHECK (office IN (
|
||||
'munich', 'duesseldorf', 'hamburg',
|
||||
'amsterdam', 'london', 'paris', 'milan',
|
||||
'madrid'
|
||||
));
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Reverse of 107: drop the binding_id column from caldav_sync_log.
|
||||
-- The associated index drops automatically with the column.
|
||||
|
||||
ALTER TABLE paliad.caldav_sync_log
|
||||
DROP COLUMN IF EXISTS binding_id;
|
||||
53
internal/db/migrations/107_caldav_sync_log_binding_id.up.sql
Normal file
53
internal/db/migrations/107_caldav_sync_log_binding_id.up.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- t-paliad-212 — Slice 2a of CalDAV multi-calendar.
|
||||
--
|
||||
-- Adds paliad.caldav_sync_log.binding_id so the per-tick sync log
|
||||
-- records which binding the entry belongs to. NULL for legacy rows
|
||||
-- and for "global" log entries that aren't per-binding (Slice 2a
|
||||
-- still writes one row per user per tick — Slice 2b's sync rewrite
|
||||
-- moves to one row per (user, binding) per tick).
|
||||
--
|
||||
-- FK uses ON DELETE SET NULL so deleting a binding doesn't blow away
|
||||
-- its historical sync log (audit trail wins over referential tidiness).
|
||||
--
|
||||
-- Idempotent: column added via DO block with information_schema check.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 107: add caldav_sync_log.binding_id for per-binding sync log entries (t-paliad-212 Slice 2a)',
|
||||
true);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'caldav_sync_log'
|
||||
AND column_name = 'binding_id'
|
||||
) THEN
|
||||
ALTER TABLE paliad.caldav_sync_log
|
||||
ADD COLUMN binding_id uuid
|
||||
REFERENCES paliad.user_calendar_bindings(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS caldav_sync_log_binding_idx
|
||||
ON paliad.caldav_sync_log (binding_id, occurred_at DESC)
|
||||
WHERE binding_id IS NOT NULL;
|
||||
|
||||
-- Assertion: column exists and is nullable.
|
||||
DO $$
|
||||
DECLARE
|
||||
col_nullable text;
|
||||
BEGIN
|
||||
SELECT is_nullable INTO col_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'caldav_sync_log'
|
||||
AND column_name = 'binding_id';
|
||||
IF col_nullable IS NULL THEN
|
||||
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id missing';
|
||||
END IF;
|
||||
IF col_nullable <> 'YES' THEN
|
||||
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id is NOT NULL (must be nullable)';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Reverse of 108: drop the capability columns.
|
||||
|
||||
ALTER TABLE paliad.user_caldav_config
|
||||
DROP COLUMN IF EXISTS supports_mkcalendar,
|
||||
DROP COLUMN IF EXISTS mkcalendar_probed_at;
|
||||
@@ -0,0 +1,67 @@
|
||||
-- t-paliad-212 — Slice 2c of CalDAV multi-calendar.
|
||||
--
|
||||
-- Adds the MKCALENDAR-capability tri-state to paliad.user_caldav_config:
|
||||
-- * supports_mkcalendar = NULL → unprobed (probe runs lazily on
|
||||
-- the first /api/caldav-discover or
|
||||
-- /api/caldav-mkcalendar call).
|
||||
-- * supports_mkcalendar = TRUE → server accepts MKCALENDAR; the
|
||||
-- "Create new calendar" affordance
|
||||
-- in the picker is visible.
|
||||
-- * supports_mkcalendar = FALSE → Google-style degrade; UI hides the
|
||||
-- create button and surfaces the
|
||||
-- "create it in your provider's UI"
|
||||
-- notice with a manual-URL input.
|
||||
-- The probed_at timestamp lets us re-probe stale-cached results when
|
||||
-- the user changes credentials (SaveConfig invalidates by SetNull in
|
||||
-- the Go service layer; the column is here so the next round of
|
||||
-- probing has somewhere to land).
|
||||
--
|
||||
-- Idempotent (column-exists DO block) + assertion at the bottom.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 108: add user_caldav_config.supports_mkcalendar tri-state for t-paliad-212 Slice 2c capability probe',
|
||||
true);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'supports_mkcalendar'
|
||||
) THEN
|
||||
ALTER TABLE paliad.user_caldav_config
|
||||
ADD COLUMN supports_mkcalendar boolean;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'mkcalendar_probed_at'
|
||||
) THEN
|
||||
ALTER TABLE paliad.user_caldav_config
|
||||
ADD COLUMN mkcalendar_probed_at timestamptz;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Assertion — both columns present and nullable.
|
||||
DO $$
|
||||
DECLARE
|
||||
sup_nullable text;
|
||||
probed_nullable text;
|
||||
BEGIN
|
||||
SELECT is_nullable INTO sup_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'supports_mkcalendar';
|
||||
SELECT is_nullable INTO probed_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'mkcalendar_probed_at';
|
||||
IF sup_nullable <> 'YES' OR probed_nullable <> 'YES' THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 108 assertion failed: expected both columns nullable, got supports=% probed=%',
|
||||
sup_nullable, probed_nullable;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 109_user_dashboard_layouts.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.user_dashboard_layouts;
|
||||
29
internal/db/migrations/109_user_dashboard_layouts.up.sql
Normal file
29
internal/db/migrations/109_user_dashboard_layouts.up.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- t-paliad-219 Slice A1: per-user dashboard layout.
|
||||
--
|
||||
-- Design: docs/design-dashboard-configurable-2026-05-20.md §5.1 (newton,
|
||||
-- m-locked 2026-05-20: single layout per user, Q2).
|
||||
--
|
||||
-- Stores one configurable dashboard layout per user as a single jsonb
|
||||
-- column. The layout is an ordered list of (widget_key, visible, settings)
|
||||
-- triples; see internal/services/dashboard_layout_spec.go DashboardLayoutSpec.
|
||||
--
|
||||
-- Single-row-per-user PK because m's Q2 pick is one layout per user (v1) —
|
||||
-- no named-layout switcher. Forward path to named layouts (drop the PK, add
|
||||
-- id+name+is_default columns) stays open if m later changes course.
|
||||
--
|
||||
-- RLS owner-only mirrors user_card_layouts / user_views — personal working
|
||||
-- state, not auditable infrastructure. global_admin gets no override.
|
||||
|
||||
CREATE TABLE paliad.user_dashboard_layouts (
|
||||
user_id uuid PRIMARY KEY REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
layout_json jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.user_dashboard_layouts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY user_dashboard_layouts_owner_all
|
||||
ON paliad.user_dashboard_layouts FOR ALL
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
22
internal/db/migrations/110_project_type_other.down.sql
Normal file
22
internal/db/migrations/110_project_type_other.down.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- mig 110 (down) — revert 'other' addition to paliad.projects.type
|
||||
--
|
||||
-- Coerces any 'other' rows back to 'project' (the historical catch-all)
|
||||
-- so the narrower CHECK constraint can re-attach. This is a lossy
|
||||
-- rollback: rows that were genuinely 'other' lose that distinction.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 110 (down): revert ''other'' from projects.type CHECK; coerce rows to ''project''',
|
||||
true);
|
||||
|
||||
UPDATE paliad.projects
|
||||
SET type = 'project'
|
||||
WHERE type = 'other';
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_type_check;
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_type_check
|
||||
CHECK (type IN (
|
||||
'client', 'litigation', 'patent', 'case', 'project'
|
||||
));
|
||||
33
internal/db/migrations/110_project_type_other.up.sql
Normal file
33
internal/db/migrations/110_project_type_other.up.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- mig 110 — add 'other' as a sixth paliad.projects.type value
|
||||
--
|
||||
-- m/paliad#51 (t-paliad-221): the type chip filter on /projects used to
|
||||
-- treat unclassified projects as a synthetic "Empty" bucket. We replace
|
||||
-- that with a real 'other' type so every row carries a meaningful label
|
||||
-- and the filter UI stops needing a NULL/Empty shim.
|
||||
--
|
||||
-- Defensive backfill: NOT NULL + the original IN-list CHECK already
|
||||
-- forbid NULL rows, but we coerce any stray rows just in case a future
|
||||
-- migration ever relaxed the constraint. As of 2026-05-20 production
|
||||
-- carries zero rows that would change here (live query confirmed).
|
||||
--
|
||||
-- The Go-side source of truth lives in
|
||||
-- internal/services/project_service.go (ProjectType constants +
|
||||
-- isValidProjectType); this migration keeps the DB in sync.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 110: add ''other'' to projects.type CHECK + backfill NULLs (m/paliad#51)',
|
||||
true);
|
||||
|
||||
-- Backfill first so the new CHECK never rejects a pre-existing row.
|
||||
UPDATE paliad.projects
|
||||
SET type = 'other'
|
||||
WHERE type IS NULL;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_type_check;
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_type_check
|
||||
CHECK (type IN (
|
||||
'client', 'litigation', 'patent', 'case', 'project', 'other'
|
||||
));
|
||||
65
internal/db/migrations/111_project_admin_and_select.down.sql
Normal file
65
internal/db/migrations/111_project_admin_and_select.down.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
-- Reverse of 111_project_admin_and_select.up.sql.
|
||||
--
|
||||
-- Drops effective_project_admin, restores the original RLS policies,
|
||||
-- and shrinks the responsibility CHECK back to four values. Any rows
|
||||
-- still carrying responsibility='admin' would violate the restored
|
||||
-- CHECK; the down-migration backfills them to 'lead' (the closest
|
||||
-- existing role) before re-adding the constraint.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Backfill any responsibility='admin' rows to 'lead'.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.project_teams
|
||||
SET responsibility = 'lead'
|
||||
WHERE responsibility = 'admin';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Restore the original CHECK (lead/member/observer/external).
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
DROP CONSTRAINT IF EXISTS project_teams_responsibility_check;
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
ADD CONSTRAINT project_teams_responsibility_check
|
||||
CHECK (responsibility IN ('lead', 'member', 'observer', 'external'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Restore the pre-110 RLS policies.
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams;
|
||||
CREATE POLICY project_teams_update
|
||||
ON paliad.project_teams FOR UPDATE
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_insert ON paliad.project_teams;
|
||||
CREATE POLICY project_teams_insert
|
||||
ON paliad.project_teams FOR INSERT
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
OR paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_delete ON paliad.project_teams;
|
||||
CREATE POLICY project_teams_delete
|
||||
ON paliad.project_teams FOR DELETE
|
||||
USING (
|
||||
paliad.can_see_project(project_id)
|
||||
AND (
|
||||
user_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. Drop the predicate function.
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.effective_project_admin(uuid, uuid);
|
||||
152
internal/db/migrations/111_project_admin_and_select.up.sql
Normal file
152
internal/db/migrations/111_project_admin_and_select.up.sql
Normal file
@@ -0,0 +1,152 @@
|
||||
-- t-paliad-223 Slice A: Project Admin role on project_teams.responsibility +
|
||||
-- inheritable role-edit gate.
|
||||
--
|
||||
-- Design: docs/design-team-admin-rework-2026-05-20.md (gauss, m-locked
|
||||
-- 2026-05-20 via head's "all R approved").
|
||||
--
|
||||
-- Adds a fifth 'admin' value to the project_teams.responsibility enum
|
||||
-- (orthogonal to the profession-driven approval ladder — admin does NOT
|
||||
-- open the 4-Augen gate by itself). Introduces paliad.effective_project_admin
|
||||
-- which mirrors paliad.can_see_project's shape and walks the ltree path
|
||||
-- to compute inheritance. Replaces the three write-side RLS policies on
|
||||
-- paliad.project_teams so role edits are gated on the new predicate
|
||||
-- instead of "anyone with visibility".
|
||||
--
|
||||
-- Day-1 deploy = no behaviour change for callers who never use the admin
|
||||
-- value: existing lead/member/observer/external rows keep their meaning,
|
||||
-- and the global_admin shortcut + self-join INSERT / self-DELETE remain
|
||||
-- intact.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. ALTER project_teams.responsibility CHECK to include 'admin'.
|
||||
-- 2. CREATE paliad.effective_project_admin(uuid, uuid).
|
||||
-- 3. Replace project_teams_update policy: gated on effective_project_admin.
|
||||
-- 4. Replace project_teams_insert policy: self-join OR effective_project_admin.
|
||||
-- 5. Replace project_teams_delete policy: self / global_admin / effective_project_admin.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Extend responsibility CHECK to include 'admin'.
|
||||
--
|
||||
-- 'admin' inherits down the project tree (see effective_project_admin in §2).
|
||||
-- A user marked admin on a Mandant-level project is implicitly admin on
|
||||
-- every Litigation / Patent / Case descendant — same shape as how 'lead'
|
||||
-- already inherits.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
DROP CONSTRAINT IF EXISTS project_teams_responsibility_check;
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
ADD CONSTRAINT project_teams_responsibility_check
|
||||
CHECK (responsibility IN ('admin', 'lead', 'member', 'observer', 'external'));
|
||||
|
||||
COMMENT ON COLUMN paliad.project_teams.responsibility IS
|
||||
'Per-project responsibility. admin = can manage team + roles on this '
|
||||
'project and descendants (inherited via paliad.effective_project_admin). '
|
||||
'lead/member open the 4-Augen approval gate; observer/external close it. '
|
||||
'admin is orthogonal to the approval gate — it does NOT open it by itself.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.effective_project_admin(_user_id, _project_id)
|
||||
--
|
||||
-- Mirrors paliad.can_see_project: STABLE SECURITY DEFINER, ltree path-walk
|
||||
-- against projects.path. Two branches:
|
||||
-- (a) global_admin short-circuit — firm-wide admins are always admin.
|
||||
-- (b) ancestor-or-self project_teams row with responsibility='admin'.
|
||||
--
|
||||
-- Used by the project_teams_update / _insert / _delete policies below
|
||||
-- and by ProjectService for the effective_admin payload field.
|
||||
--
|
||||
-- The ltree-array cast is the same pattern can_see_project uses; the
|
||||
-- existing GiST index on projects.path is the load-bearing index. No new
|
||||
-- index needed.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.effective_project_admin(_user_id uuid, _project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = _user_id
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.project_teams pt
|
||||
ON pt.user_id = _user_id
|
||||
AND pt.responsibility = 'admin'
|
||||
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
WHERE target.id = _project_id
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.effective_project_admin(uuid, uuid) IS
|
||||
'True iff the user is global_admin OR has responsibility=admin on the '
|
||||
'project itself or any ancestor in the materialised ltree path. '
|
||||
'Drives the role-edit gate on project_teams (UPDATE/INSERT/DELETE RLS).';
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. project_teams_update policy: gated on effective_project_admin.
|
||||
--
|
||||
-- Before: USING + CHECK = can_see_project (anyone with visibility could
|
||||
-- edit anyone's responsibility — the load-bearing gap that t-paliad-223
|
||||
-- closes).
|
||||
-- After: USING + CHECK = effective_project_admin (only project-admins
|
||||
-- and global_admins can change roles).
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams;
|
||||
|
||||
CREATE POLICY project_teams_update
|
||||
ON paliad.project_teams FOR UPDATE
|
||||
USING (paliad.effective_project_admin(auth.uid(), project_id))
|
||||
WITH CHECK (paliad.effective_project_admin(auth.uid(), project_id));
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. project_teams_insert policy: self-join OR effective_project_admin.
|
||||
--
|
||||
-- The self-join branch (user_id = auth.uid()) preserves the legacy
|
||||
-- creator-as-lead INSERT in ProjectService.Create: the project creator
|
||||
-- auto-joins their own project with responsibility='lead' before any
|
||||
-- admin exists. Without this branch, the first-ever team row on a new
|
||||
-- project would fail because no admin has been granted yet.
|
||||
--
|
||||
-- For all other inserts (adding other users), the caller must be an
|
||||
-- effective_project_admin on the target project.
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_insert ON paliad.project_teams;
|
||||
|
||||
CREATE POLICY project_teams_insert
|
||||
ON paliad.project_teams FOR INSERT
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
OR paliad.effective_project_admin(auth.uid(), project_id)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. project_teams_delete policy: self / global_admin / effective_project_admin.
|
||||
--
|
||||
-- Additive: self-remove + global_admin still work; project-admin can now
|
||||
-- also remove members.
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_delete ON paliad.project_teams;
|
||||
|
||||
CREATE POLICY project_teams_delete
|
||||
ON paliad.project_teams FOR DELETE
|
||||
USING (
|
||||
paliad.can_see_project(project_id)
|
||||
AND (
|
||||
user_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
OR paliad.effective_project_admin(auth.uid(), project_id)
|
||||
)
|
||||
);
|
||||
30
internal/db/migrations/112_client_role_rework.down.sql
Normal file
30
internal/db/migrations/112_client_role_rework.down.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Down migration for 112_client_role_rework.
|
||||
--
|
||||
-- Restores the original 4-value CHECK ('claimant','defendant',
|
||||
-- 'court','both', NULL) and backfills any rows that landed on a new
|
||||
-- sub-role value (applicant / appellant / respondent / third_party /
|
||||
-- other) to NULL so the schema is internally consistent after the
|
||||
-- step-down.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Backfill new sub-role values to NULL so the old CHECK doesn't reject.
|
||||
UPDATE paliad.projects
|
||||
SET our_side = NULL
|
||||
WHERE our_side IN ('applicant', 'appellant', 'respondent', 'third_party', 'other');
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_our_side_check;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_our_side_check
|
||||
CHECK (our_side IS NULL
|
||||
OR our_side IN ('claimant', 'defendant', 'court', 'both'));
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.our_side IS
|
||||
'Which side the firm represents on this project. Used by the '
|
||||
'Fristenrechner Determinator (Slice 3c) to predefine the '
|
||||
'perspective chip from the project context. Allowed: claimant, '
|
||||
'defendant, court, both.';
|
||||
|
||||
COMMIT;
|
||||
51
internal/db/migrations/112_client_role_rework.up.sql
Normal file
51
internal/db/migrations/112_client_role_rework.up.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- mig 112 — t-paliad-222 / m/paliad#47 — Client Role rework.
|
||||
--
|
||||
-- Widens paliad.projects.our_side CHECK to seven sub-role values and
|
||||
-- drops the legacy 'court' / 'both' entries. The DB column name stays
|
||||
-- as 'our_side' (UI label changes only — see design doc §2.2 Q1).
|
||||
--
|
||||
-- New allowed sub-roles, grouped at display time:
|
||||
-- Active (we initiate) : claimant, applicant, appellant
|
||||
-- Reactive (we defend) : defendant, respondent
|
||||
-- Third Party / Other : third_party, other
|
||||
-- NULL : unknown / not set
|
||||
--
|
||||
-- Backfill: any rows still on 'court' / 'both' fall back to NULL.
|
||||
-- Verified 2026-05-20: all 12 production rows are NULL, so this is
|
||||
-- a no-op on prod; the UPDATE runs defensively for staging / test
|
||||
-- fixtures that may carry the legacy values.
|
||||
--
|
||||
-- Idempotent so re-runs against a partially-applied state stay safe.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Backfill any 'court' / 'both' rows to NULL.
|
||||
UPDATE paliad.projects
|
||||
SET our_side = NULL
|
||||
WHERE our_side IN ('court', 'both');
|
||||
|
||||
-- 2. Swap the CHECK constraint for the widened sub-role set.
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_our_side_check;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_our_side_check
|
||||
CHECK (our_side IS NULL OR our_side IN (
|
||||
'claimant', 'defendant',
|
||||
'applicant', 'appellant',
|
||||
'respondent',
|
||||
'third_party', 'other'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.our_side IS
|
||||
'Which side the firm represents on this case project (renamed in '
|
||||
'the UI to "Client Role" / "Mandantenrolle" — t-paliad-222 / '
|
||||
'm/paliad#47). Allowed sub-roles, grouped at display time: Active '
|
||||
'(claimant, applicant, appellant); Reactive (defendant, '
|
||||
'respondent); Third Party / Other (third_party, other). NULL = '
|
||||
'unknown. The form hides the field on non-case project types. '
|
||||
'Drives the Fristenrechner Determinator perspective chip — Active '
|
||||
'group → claimant-perspective, Reactive → defendant-perspective, '
|
||||
'Third Party / Other → null (chip free-pick).';
|
||||
|
||||
COMMIT;
|
||||
11
internal/db/migrations/113_projects_opponent_code.down.sql
Normal file
11
internal/db/migrations/113_projects_opponent_code.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Down migration for 113_projects_opponent_code.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_opponent_code_check;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP COLUMN IF EXISTS opponent_code;
|
||||
|
||||
COMMIT;
|
||||
50
internal/db/migrations/113_projects_opponent_code.up.sql
Normal file
50
internal/db/migrations/113_projects_opponent_code.up.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- mig 113 — t-paliad-222 / m/paliad#50 — auto-derived project codes.
|
||||
--
|
||||
-- Adds an opponent-code slug field on litigation projects. Used as
|
||||
-- the middle segment when BuildProjectCode assembles an auto-derived
|
||||
-- project code from the ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI).
|
||||
--
|
||||
-- NULL = segment skipped silently. Existing litigation rows yield
|
||||
-- codes without an opponent segment until the user fills the field.
|
||||
-- No backfill from `title` — the litigation title is free-text
|
||||
-- ("Siemens AG ./. Huawei", "Mandant vs Gegner") and any regex would
|
||||
-- be brittle; the user enters the slug once at project creation /
|
||||
-- next edit.
|
||||
--
|
||||
-- Slug shape: uppercase letters / digits / dashes, max 16 chars.
|
||||
-- Constraint also gates on type='litigation' so a stray value on a
|
||||
-- non-litigation row is rejected at the DB level (defence in depth;
|
||||
-- the form already hides the field on other types).
|
||||
--
|
||||
-- Idempotent.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS opponent_code text;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'projects_opponent_code_check'
|
||||
AND conrelid = 'paliad.projects'::regclass
|
||||
) THEN
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_opponent_code_check
|
||||
CHECK (opponent_code IS NULL
|
||||
OR (opponent_code ~ '^[A-Z0-9-]{1,16}$'
|
||||
AND type = 'litigation'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.opponent_code IS
|
||||
'Short slug for the opposing party on a litigation project '
|
||||
'(uppercase letters, digits, dashes, max 16 chars). Used as the '
|
||||
'middle segment when BuildProjectCode walks the ancestor tree to '
|
||||
'assemble a dotted project code — e.g. EXMPL.OPNT.567.INF.CFI '
|
||||
'(t-paliad-222 / m/paliad#50). NULL = segment skipped silently. '
|
||||
'Only meaningful on type=''litigation'' rows; the CHECK enforces '
|
||||
'that pairing.';
|
||||
|
||||
COMMIT;
|
||||
@@ -44,6 +44,78 @@ func handleAdminListUnonboarded(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/admin/users/full — create BOTH an auth.users row (via Supabase
|
||||
// Admin API) and a paliad.users row in one operation. t-paliad-223 Slice B
|
||||
// (#49). Lets a global_admin onboard a colleague without forcing them
|
||||
// through the email-invitation round-trip; the new user is visible in
|
||||
// dropdowns immediately and can log in via the emailed magic-link.
|
||||
//
|
||||
// Requires SUPABASE_SERVICE_ROLE_KEY at the server. Returns 503 when
|
||||
// unset so a deploy that hasn't provisioned the credential yet gets a
|
||||
// clear diagnostic instead of a cryptic 500.
|
||||
//
|
||||
// Error mapping:
|
||||
// - ErrSupabaseAdminUnavailable → 503
|
||||
// - ErrSupabaseEmailExists → 409 (hint to use "Onboard existing")
|
||||
// - ErrUserAlreadyOnboarded → 409 (paliad.users dup; should be unreachable)
|
||||
// - ErrInvalidInput → 400 (bad shape)
|
||||
// - email domain not on whitelist → 403 (mirrors handleAdminCreateUser)
|
||||
// - other → 500
|
||||
func handleAdminCreateFullUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.AdminCreateFullInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if !isAllowedEmailDomain(input.Email) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "email domain not on the " + branding.Name + " allow-list",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the inviter (the calling admin) so the welcome email and
|
||||
// audit row carry their identity. Failures here shouldn't block the
|
||||
// create; we just degrade to empty fields.
|
||||
inviter, err := dbSvc.users.GetByID(r.Context(), uid)
|
||||
if err == nil && inviter != nil {
|
||||
input.InviterID = inviter.ID
|
||||
input.InviterName = inviter.DisplayName
|
||||
input.InviterEmail = inviter.Email
|
||||
}
|
||||
|
||||
u, err := dbSvc.users.AdminCreateUserFull(r.Context(), input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrSupabaseAdminUnavailable):
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "add-user flow requires SUPABASE_SERVICE_ROLE_KEY on the server",
|
||||
})
|
||||
case errors.Is(err, services.ErrSupabaseEmailExists):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "auth account already exists — please use 'Onboard existing' instead",
|
||||
})
|
||||
case errors.Is(err, services.ErrUserAlreadyOnboarded):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "user already onboarded",
|
||||
})
|
||||
case errors.Is(err, services.ErrInvalidInput):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, u)
|
||||
}
|
||||
|
||||
// POST /api/admin/users — direct-create a paliad.users row for an existing
|
||||
// auth.users entry. The recipient email's domain must already match the
|
||||
// allowed-email policy (Supabase wouldn't have let them sign up otherwise),
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -311,6 +312,226 @@ func handleTestCalDAVConfig(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// GET /api/caldav-bindings — list the authenticated user's CalDAV
|
||||
// bindings (the (calendar, scope) entries layered on the single CalDAV
|
||||
// server connection). Read-only in Slice 2a; full CRUD lands in Slice 2b.
|
||||
func handleListCalDAVBindings(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.caldavBindings == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]any{
|
||||
"error": "CalDAV bindings unavailable (CalDAV service not configured)",
|
||||
})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.caldavBindings.ListForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []models.UserCalendarBinding{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/caldav-bindings — create a new binding for the
|
||||
// authenticated user and synchronously fire a first push so the modal
|
||||
// closes with events already landed. Returns 201 with the binding row.
|
||||
func handleCreateCalDAVBinding(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCalDAV(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.caldavBindings == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
|
||||
return
|
||||
}
|
||||
var input services.CreateBindingInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
// Default to enabled=true so the modal "Hinzufügen" button does the
|
||||
// expected thing without forcing the user to toggle anything.
|
||||
if !input.Enabled {
|
||||
input.Enabled = true
|
||||
}
|
||||
binding, err := dbSvc.caldavBindings.Create(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
writeCalDAVError(w, err)
|
||||
return
|
||||
}
|
||||
// Synchronous first push per Q5 of the Slice 2 design (m's 2026-05-20
|
||||
// pick): block the request so the user sees events already landed
|
||||
// when the modal closes. PushBindingNow logs per-event failures and
|
||||
// returns; we only surface a hard config/cipher error.
|
||||
pushed, pushErr := dbSvc.caldav.PushBindingNow(r.Context(), uid, binding)
|
||||
if pushErr != nil {
|
||||
// Binding was created; sync failed. Tell the UI both bits so it
|
||||
// can show "binding added, initial sync had a problem".
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"binding": binding,
|
||||
"initial_pushed": pushed,
|
||||
"initial_sync_error": pushErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Ensure the per-user goroutine is running so future ticks happen.
|
||||
dbSvc.caldav.EnsureLoop(uid)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"binding": binding,
|
||||
"initial_pushed": pushed,
|
||||
})
|
||||
}
|
||||
|
||||
// PATCH /api/caldav-bindings/{id} — partial update. Lazy scope cleanup
|
||||
// per Q6: stale targets get dropped on the next sync tick, not here.
|
||||
func handlePatchCalDAVBinding(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.caldavBindings == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var input services.UpdateBindingInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
binding, err := dbSvc.caldavBindings.Update(r.Context(), uid, id, input)
|
||||
if err != nil {
|
||||
writeCalDAVError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, binding)
|
||||
}
|
||||
|
||||
// DELETE /api/caldav-bindings/{id} — best-effort remote cleanup of every
|
||||
// .ics this binding pushed, then drop the binding row. On partial remote
|
||||
// failure the binding is disabled (not deleted) so the next sync tick
|
||||
// can retry; the response is 202 Accepted in that case.
|
||||
func handleDeleteCalDAVBinding(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.caldav == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
fully, err := dbSvc.caldav.RemoveBinding(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeCalDAVError(w, err)
|
||||
return
|
||||
}
|
||||
if !fully {
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"status": "partial",
|
||||
"message": "Binding disabled; some remote events could not be deleted. Retry on next sync tick.",
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/caldav-mkcalendar — creates a new calendar on the user's
|
||||
// CalDAV server via MKCALENDAR + a matching binding row in one logical
|
||||
// transaction. Slice 2c only — visible when /api/caldav-discover
|
||||
// reports supports_mkcalendar=true. Errors:
|
||||
// - 501 when supports_mkcalendar=false (caller should show the
|
||||
// Google-degrade UX with the manual-URL input).
|
||||
// - 409 when the slugified name + 3 retries all collide on the
|
||||
// server. UI should ask the user to type their own name.
|
||||
func handleCalDAVMakeCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCalDAV(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.CreateCalendarInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
result, err := dbSvc.caldav.MakeCalendar(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrMKCalendarUnsupported):
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]any{
|
||||
"error": err.Error(),
|
||||
"supports_mkcalendar": false,
|
||||
})
|
||||
case errors.Is(err, services.ErrCalendarNameTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
default:
|
||||
// Binding-create / push errors carry the partial result so
|
||||
// the UI can surface "created remotely but binding failed".
|
||||
if result != nil {
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"calendar_path": result.CalendarPath,
|
||||
"binding": result.Binding,
|
||||
"initial_pushed": result.InitialPushed,
|
||||
"initial_sync_error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeCalDAVError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
|
||||
// GET /api/caldav-discover — walks the calendar-home-set chain on the
|
||||
// user's CalDAV server and returns the calendars they own. Cached
|
||||
// server-side for 5 minutes per user (Q4 of Slice 2 brief).
|
||||
func handleCalDAVDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCalDAV(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := dbSvc.caldav.DiscoverCalendars(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeCalDAVError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GET /api/caldav-config/log — last 5 sync attempts.
|
||||
func handleCalDAVSyncLog(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCalDAV(w) {
|
||||
|
||||
@@ -270,7 +270,8 @@ func isValidInboxStatus(s string) bool {
|
||||
services.RequestStatusApproved,
|
||||
services.RequestStatusRejected,
|
||||
services.RequestStatusRevoked,
|
||||
services.RequestStatusSuperseded:
|
||||
services.RequestStatusSuperseded,
|
||||
services.RequestStatusChangesRequested:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -325,6 +326,67 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
handleApprovalDecision(w, r, "revoke")
|
||||
}
|
||||
|
||||
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
|
||||
// counter_payload is an entity-shaped jsonb of the approver's edited
|
||||
// values (allowlist enforced server-side); note is the optional free-text
|
||||
// explanation. The service rejects the call with
|
||||
// ErrSuggestionRequiresChange when both are no-ops (counter is identical
|
||||
// to the old row's payload AND note is empty).
|
||||
type suggestChangesBody struct {
|
||||
CounterPayload map[string]any `json:"counter_payload"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// POST /api/approval-requests/{id}/suggest-changes — t-paliad-216.
|
||||
//
|
||||
// In one transaction: close the pending request as 'changes_requested'
|
||||
// (with the caller's note + counter_payload on the row), revert the entity
|
||||
// from pre_image, then spawn a NEW pending approval_request authored by
|
||||
// the caller carrying the counter_payload. Returns the new request id.
|
||||
//
|
||||
// Status mapping (see writeApprovalError → mapApprovalError):
|
||||
//
|
||||
// 400 suggestion_requires_change — counter == old payload AND no note
|
||||
// 400 suggestion_lifecycle_invalid — old row's lifecycle ∉ (update, complete)
|
||||
// 403 self_approval_blocked — caller == old row's requested_by
|
||||
// 403 not_authorized — caller doesn't satisfy canApprove
|
||||
// 404 — request not found / not visible
|
||||
// 409 request_not_pending — old row already decided
|
||||
// 409 no_qualified_approver — deadlock on the new row
|
||||
func handleSuggestChangesApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requestID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
||||
return
|
||||
}
|
||||
var body suggestChangesBody
|
||||
if r.Body != nil && r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"code": "invalid_body",
|
||||
"message": "Ungültiger Body.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
newID, err := dbSvc.approval.SuggestChanges(r.Context(), requestID, uid, body.CounterPayload, body.Note)
|
||||
if err != nil {
|
||||
writeApprovalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
"new_request_id": newID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func handleApprovalDecision(w http.ResponseWriter, r *http.Request, action string) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
|
||||
@@ -82,6 +82,44 @@ func TestMapApprovalError_MissReturnsFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapApprovalError_SuggestionRequiresChange400 pins t-paliad-216:
|
||||
// a no-op suggest-changes (no counter diff + no note) surfaces as a 400
|
||||
// with code suggestion_requires_change so the frontend can disable the
|
||||
// submit button instead of letting the user click into a dead-end alert.
|
||||
func TestMapApprovalError_SuggestionRequiresChange400(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
if !mapApprovalError(w, services.ErrSuggestionRequiresChange) {
|
||||
t.Fatal("mapApprovalError returned false for ErrSuggestionRequiresChange")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["code"] != "suggestion_requires_change" {
|
||||
t.Errorf("code = %q, want suggestion_requires_change", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapApprovalError_SuggestionLifecycleInvalid400 pins t-paliad-216:
|
||||
// suggest-changes on a create/delete lifecycle is rejected with a clean
|
||||
// 400 + code suggestion_lifecycle_invalid so the frontend can hide the
|
||||
// button for those rows.
|
||||
func TestMapApprovalError_SuggestionLifecycleInvalid400(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
if !mapApprovalError(w, services.ErrSuggestionLifecycleInvalid) {
|
||||
t.Fatal("mapApprovalError returned false for ErrSuggestionLifecycleInvalid")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["code"] != "suggestion_lifecycle_invalid" {
|
||||
t.Errorf("code = %q, want suggestion_lifecycle_invalid", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseInboxFilter_DropsUnknownStatus pins t-paliad-160 §D regression
|
||||
// hardening: a stray ?status=foo from a stale frontend build (or an
|
||||
// attacker scoping us out of our own list) must NOT shadow rows out of
|
||||
@@ -97,6 +135,7 @@ func TestParseInboxFilter_DropsUnknownStatus(t *testing.T) {
|
||||
{"rejected", "rejected"},
|
||||
{"revoked", "revoked"},
|
||||
{"superseded", "superseded"},
|
||||
{"changes_requested", "changes_requested"}, // t-paliad-216
|
||||
{"foo", ""}, // unknown — dropped
|
||||
{"DROP+TABLE", ""}, // hostile — dropped
|
||||
{"PENDING", ""}, // case mismatch — dropped (we don't normalise)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/dashboard — returns the DashboardData JSON for the logged-in user.
|
||||
@@ -24,21 +25,29 @@ func handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GET /dashboard — protected shell page. The client boots, reads the initial
|
||||
// payload inlined by the server into window.__PALIAD_DASHBOARD__, and renders
|
||||
// without a second round-trip (audit §2.3: no skeleton→fetch waterfall).
|
||||
// GET /dashboard — protected shell page. The client boots, reads three
|
||||
// initial payloads inlined by the server (data, layout, catalog), and
|
||||
// renders without a second round-trip (audit §2.3: no skeleton→fetch
|
||||
// waterfall). Each inline is best-effort: if any read fails the
|
||||
// corresponding blob is left null and the client falls back to fetch.
|
||||
func handleDashboardPage(w http.ResponseWriter, r *http.Request) {
|
||||
uid, hasUser := auth.UserIDFromContext(r.Context())
|
||||
var payload []byte
|
||||
var payload, layout []byte
|
||||
if hasUser && dbSvc != nil {
|
||||
// Best-effort server-render. If the DB read fails we still serve the
|
||||
// shell; the client will show the inline error state instead of the
|
||||
// zero-count cards.
|
||||
if data, err := dbSvc.dashboard.Get(r.Context(), uid); err == nil {
|
||||
payload = mustJSON(data)
|
||||
}
|
||||
if dbSvc.dashboardLayout != nil {
|
||||
if spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid); err == nil {
|
||||
layout = mustJSON(spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
serveDashboardShell(w, r, payload)
|
||||
// Catalog is code-resident — always inline it so the widget picker
|
||||
// and dispatch logic can boot without an extra fetch even on
|
||||
// knowledge-platform-only deployments without DATABASE_URL.
|
||||
catalog := mustJSON(services.WidgetCatalog())
|
||||
serveDashboardShell(w, r, payload, layout, catalog)
|
||||
}
|
||||
|
||||
// handleRootPage is the public `/` route. Unauthenticated visitors get the
|
||||
|
||||
109
internal/handlers/dashboard_layout.go
Normal file
109
internal/handlers/dashboard_layout.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package handlers
|
||||
|
||||
// HTTP handlers for the per-user dashboard layout (t-paliad-219 Slice A2).
|
||||
//
|
||||
// Design: docs/design-dashboard-configurable-2026-05-20.md §9.
|
||||
//
|
||||
// Four endpoints:
|
||||
// GET /api/me/dashboard-layout → read (auto-seeds factory default)
|
||||
// PUT /api/me/dashboard-layout → replace (validates against catalog)
|
||||
// POST /api/me/dashboard-layout/reset → overwrite with factory default
|
||||
// GET /api/dashboard-widget-catalog → catalog metadata for the picker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/me/dashboard-layout — returns the caller's layout, seeding the
|
||||
// factory default on first call. Always returns 200 with a valid
|
||||
// DashboardLayoutSpec.
|
||||
func handleGetDashboardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dashboardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
|
||||
return
|
||||
}
|
||||
spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, spec)
|
||||
}
|
||||
|
||||
// PUT /api/me/dashboard-layout — replaces the caller's layout. Body must
|
||||
// be a complete DashboardLayoutSpec; the service validates against the
|
||||
// catalog and 400s on a bad spec.
|
||||
func handlePutDashboardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dashboardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
|
||||
return
|
||||
}
|
||||
var spec services.DashboardLayoutSpec
|
||||
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.dashboardLayout.Update(r.Context(), uid, spec)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// POST /api/me/dashboard-layout/reset — overwrites the caller's layout
|
||||
// with the factory default. The previous layout is discarded.
|
||||
func handleResetDashboardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dashboardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
|
||||
return
|
||||
}
|
||||
spec, err := dbSvc.dashboardLayout.ResetToDefault(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, spec)
|
||||
}
|
||||
|
||||
// GET /api/dashboard-widget-catalog — returns the widget catalog. Auth-
|
||||
// gated only because the catalog includes user-facing copy; nothing
|
||||
// security-sensitive is exposed. The handler is DB-independent (the
|
||||
// catalog is code-resident) so the requireDB gate is intentionally
|
||||
// skipped — knowledge-platform-only deployments can still surface the
|
||||
// catalog and we never want this endpoint to 503.
|
||||
func handleGetWidgetCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, services.WidgetCatalog())
|
||||
}
|
||||
@@ -11,10 +11,15 @@ import (
|
||||
)
|
||||
|
||||
// The dashboard shell is pre-rendered by bun (`renderDashboard()` → dist/dashboard.html)
|
||||
// and contains the placeholder token below. On each request we splice in a
|
||||
// JSON blob as `window.__PALIAD_DASHBOARD__` so the client can paint the real
|
||||
// data on first frame — no skeleton + /api/dashboard waterfall.
|
||||
const dashboardDataPlaceholder = "/*__PALIAD_DASHBOARD_DATA__*/"
|
||||
// and contains three placeholder tokens (data, layout, catalog). On each
|
||||
// request we splice in JSON blobs as window.__PALIAD_DASHBOARD__ /
|
||||
// __PALIAD_DASHBOARD_LAYOUT__ / __PALIAD_DASHBOARD_CATALOG__ so the client
|
||||
// can paint the real data on first frame — no skeleton + /api/* waterfall.
|
||||
const (
|
||||
dashboardDataPlaceholder = "/*__PALIAD_DASHBOARD_DATA__*/"
|
||||
dashboardLayoutPlaceholder = "/*__PALIAD_DASHBOARD_LAYOUT__*/"
|
||||
dashboardCatalogPlaceholder = "/*__PALIAD_DASHBOARD_CATALOG__*/"
|
||||
)
|
||||
|
||||
var (
|
||||
dashboardShellOnce sync.Once
|
||||
@@ -38,28 +43,19 @@ func loadDashboardShell() ([]byte, error) {
|
||||
return dashboardShellBytes, dashboardShellErr
|
||||
}
|
||||
|
||||
// serveDashboardShell writes dist/dashboard.html with the JSON payload spliced
|
||||
// into the placeholder. A nil payload disables server-side hydration; the
|
||||
// client then falls back to fetching /api/dashboard on mount.
|
||||
func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload []byte) {
|
||||
// serveDashboardShell writes dist/dashboard.html with three JSON blobs
|
||||
// spliced in (data, layout, catalog). A nil payload disables server-side
|
||||
// hydration of that slot; the client falls back to fetching the
|
||||
// corresponding /api/* endpoint on mount.
|
||||
func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload, layout, catalog []byte) {
|
||||
shell, err := loadDashboardShell()
|
||||
if err != nil {
|
||||
http.Error(w, "dashboard shell unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var body []byte
|
||||
if len(payload) > 0 {
|
||||
// JSON is wrapped so the script block is self-contained even when the
|
||||
// payload contains `</script>` sequences (defensive: our data is
|
||||
// server-owned, but future event.description fields could contain
|
||||
// arbitrary text).
|
||||
inline := append([]byte("window.__PALIAD_DASHBOARD__="), escapeForScript(payload)...)
|
||||
inline = append(inline, ';')
|
||||
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder), inline, 1)
|
||||
} else {
|
||||
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder),
|
||||
[]byte("window.__PALIAD_DASHBOARD__=null;"), 1)
|
||||
}
|
||||
body := splicePlaceholder(shell, dashboardDataPlaceholder, "window.__PALIAD_DASHBOARD__=", payload)
|
||||
body = splicePlaceholder(body, dashboardLayoutPlaceholder, "window.__PALIAD_DASHBOARD_LAYOUT__=", layout)
|
||||
body = splicePlaceholder(body, dashboardCatalogPlaceholder, "window.__PALIAD_DASHBOARD_CATALOG__=", catalog)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
@@ -67,6 +63,22 @@ func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload []byte)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// splicePlaceholder replaces a single placeholder token with a JS
|
||||
// assignment of the given JSON payload to a window.X global. A nil
|
||||
// payload assigns `null` so the client can detect "no server-side
|
||||
// hydration" and fall back to fetch.
|
||||
func splicePlaceholder(shell []byte, placeholder, prefix string, payload []byte) []byte {
|
||||
var inline []byte
|
||||
if len(payload) > 0 {
|
||||
inline = append(inline, []byte(prefix)...)
|
||||
inline = append(inline, escapeForScript(payload)...)
|
||||
inline = append(inline, ';')
|
||||
} else {
|
||||
inline = append(inline, []byte(prefix+"null;")...)
|
||||
}
|
||||
return bytes.Replace(shell, []byte(placeholder), inline, 1)
|
||||
}
|
||||
|
||||
// escapeForScript makes a JSON blob safe to embed directly in an inline
|
||||
// <script>. JSON strings may contain `</script>` or U+2028/U+2029, both of
|
||||
// which terminate script blocks in some parsers.
|
||||
|
||||
@@ -2,16 +2,19 @@ package handlers
|
||||
|
||||
// Data-export handlers (t-paliad-214).
|
||||
//
|
||||
// Slice 1 ships the personal scope only:
|
||||
//
|
||||
// Slice 1: personal scope
|
||||
// GET /api/me/export → streams a personal-scope export .zip
|
||||
//
|
||||
// Slices 2 + 3 (project + org) layer onto this file when they ship.
|
||||
// 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.
|
||||
//
|
||||
// Authentication: the existing protected mux middleware (auth.Middleware +
|
||||
// auth.WithUserID) populates the user UUID in the context. We do not gate
|
||||
// on global_role here — personal export is available to every authenticated
|
||||
// user.
|
||||
// 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.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -22,6 +25,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -102,7 +107,7 @@ func handleMeExport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := services.ExportFilename(services.ExportScopePersonal, "", spec.GeneratedAt)
|
||||
filename := services.ExportFilename(services.ExportScopePersonal, "", uuid.Nil, spec.GeneratedAt)
|
||||
size := int64(buf.Len())
|
||||
|
||||
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
|
||||
@@ -123,3 +128,163 @@ 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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
@@ -21,6 +22,19 @@ func noCacheAssets(h http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// patentstyleDownload sets a Content-Disposition with the spaced filename
|
||||
// "HL Patents Style.dotm" for .dotm requests under /patentstyle/. The URL
|
||||
// path stays clean (dashes), browsers and download tools land the file
|
||||
// with the name PAs expect to see.
|
||||
func patentstyleDownload(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ".dotm") {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="HL Patents Style.dotm"`)
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// noCachePages wraps a handler so its response always revalidates. Combined
|
||||
// with the build-time `?v=<buildVersion>` stamp on /assets/*.js and /css URLs
|
||||
// in dist/*.html, this is what makes a deploy actually reach users: the HTML
|
||||
@@ -43,6 +57,7 @@ type Services struct {
|
||||
Deadline *services.DeadlineService
|
||||
Appointment *services.AppointmentService
|
||||
CalDAV *services.CalDAVService
|
||||
CalDAVBindings *services.CalendarBindingService
|
||||
Rules *services.DeadlineRuleService
|
||||
Calculator *services.DeadlineCalculator
|
||||
Users *services.UserService
|
||||
@@ -69,9 +84,19 @@ type Services struct {
|
||||
UserView *services.UserViewService
|
||||
Broadcast *services.BroadcastService
|
||||
Pin *services.PinService
|
||||
CardLayout *services.CardLayoutService
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
CardLayout *services.CardLayoutService
|
||||
DashboardLayout *services.DashboardLayoutService
|
||||
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
|
||||
@@ -89,6 +114,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
paliadinSvc = svc.Paliadin
|
||||
}
|
||||
|
||||
// Submission generator singletons (t-paliad-215). All three or
|
||||
// none — the handler short-circuits with 503 when any is nil.
|
||||
if svc != nil {
|
||||
submissionRegistry = svc.SubmissionRegistry
|
||||
submissionVars = svc.SubmissionVars
|
||||
submissionRenderer = svc.SubmissionRenderer
|
||||
}
|
||||
|
||||
if svc != nil {
|
||||
dbSvc = &dbServices{
|
||||
projects: svc.Project,
|
||||
@@ -98,6 +131,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
deadline: svc.Deadline,
|
||||
appointment: svc.Appointment,
|
||||
caldav: svc.CalDAV,
|
||||
caldavBindings: svc.CalDAVBindings,
|
||||
rules: svc.Rules,
|
||||
calc: svc.Calculator,
|
||||
users: svc.Users,
|
||||
@@ -124,9 +158,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
userView: svc.UserView,
|
||||
broadcast: svc.Broadcast,
|
||||
pin: svc.Pin,
|
||||
cardLayout: svc.CardLayout,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
cardLayout: svc.CardLayout,
|
||||
dashboardLayout: svc.DashboardLayout,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,8 +211,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// the installed Word client polls; HL-Patents-Style.dotm is fetched on
|
||||
// version mismatch. Source files live in frontend/public/patentstyle/
|
||||
// (copied into dist/ at build time). noCacheAssets ensures the manifest
|
||||
// is never stale after a release.
|
||||
mux.Handle("GET /patentstyle/", noCacheAssets(http.StripPrefix("/patentstyle/", http.FileServer(http.Dir("dist/patentstyle")))))
|
||||
// is never stale after a release. patentstyleDownload renames the .dotm
|
||||
// to "HL Patents Style.dotm" (with spaces) on download — the on-disk
|
||||
// filename has dashes so the URL is clean, but Word users expect the
|
||||
// spaced name in their downloads folder.
|
||||
mux.Handle("GET /patentstyle/", noCacheAssets(patentstyleDownload(http.StripPrefix("/patentstyle/", http.FileServer(http.Dir("dist/patentstyle"))))))
|
||||
|
||||
// Protected routes
|
||||
protected := http.NewServeMux()
|
||||
@@ -250,9 +288,18 @@ 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)
|
||||
@@ -267,12 +314,18 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PATCH /api/user-card-layouts/{id}", handleUpdateCardLayout)
|
||||
protected.HandleFunc("DELETE /api/user-card-layouts/{id}", handleDeleteCardLayout)
|
||||
protected.HandleFunc("POST /api/user-card-layouts/{id}/set-default", handleSetDefaultCardLayout)
|
||||
// t-paliad-219 — per-user configurable dashboard layout.
|
||||
protected.HandleFunc("GET /api/me/dashboard-layout", handleGetDashboardLayout)
|
||||
protected.HandleFunc("PUT /api/me/dashboard-layout", handlePutDashboardLayout)
|
||||
protected.HandleFunc("POST /api/me/dashboard-layout/reset", handleResetDashboardLayout)
|
||||
protected.HandleFunc("GET /api/dashboard-widget-catalog", handleGetWidgetCatalog)
|
||||
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
|
||||
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
|
||||
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)
|
||||
// Team membership endpoints for Project detail "Team" tab.
|
||||
protected.HandleFunc("GET /api/projects/{id}/team", handleListProjectTeam)
|
||||
protected.HandleFunc("POST /api/projects/{id}/team", handleAddProjectTeamMember)
|
||||
protected.HandleFunc("PATCH /api/projects/{id}/team/{user_id}", handleChangeProjectTeamMemberResponsibility)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/team/{user_id}", handleRemoveProjectTeamMember)
|
||||
// t-paliad-139 — sub-team aggregation surfaces for the Team tab.
|
||||
protected.HandleFunc("GET /api/projects/{id}/team/derived", handleListDerivedTeam)
|
||||
@@ -311,6 +364,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("DELETE /api/caldav-config", handleDeleteCalDAVConfig)
|
||||
protected.HandleFunc("POST /api/caldav-config/test", handleTestCalDAVConfig)
|
||||
protected.HandleFunc("GET /api/caldav-config/log", handleCalDAVSyncLog)
|
||||
// t-paliad-212 Slice 2a/2b — multi-calendar binding CRUD.
|
||||
protected.HandleFunc("GET /api/caldav-bindings", handleListCalDAVBindings)
|
||||
protected.HandleFunc("POST /api/caldav-bindings", handleCreateCalDAVBinding)
|
||||
protected.HandleFunc("PATCH /api/caldav-bindings/{id}", handlePatchCalDAVBinding)
|
||||
protected.HandleFunc("DELETE /api/caldav-bindings/{id}", handleDeleteCalDAVBinding)
|
||||
// /api/caldav-discover — calendar-home-set walk (RFC 6764) for picker.
|
||||
protected.HandleFunc("GET /api/caldav-discover", handleCalDAVDiscover)
|
||||
// Slice 2c — MKCALENDAR ("Create new calendar" affordance in picker).
|
||||
protected.HandleFunc("POST /api/caldav-mkcalendar", handleCalDAVMakeCalendar)
|
||||
|
||||
// t-paliad-088 — Event Types (categorization for Deadlines).
|
||||
protected.HandleFunc("GET /api/event-types", handleListEventTypes)
|
||||
@@ -447,6 +509,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
||||
protected.HandleFunc("GET /api/admin/users/unonboarded", adminGate(users, handleAdminListUnonboarded))
|
||||
protected.HandleFunc("PATCH /api/admin/users/{id}", adminGate(users, handleAdminUpdateUser))
|
||||
protected.HandleFunc("DELETE /api/admin/users/{id}", adminGate(users, handleAdminDeleteUser))
|
||||
@@ -526,6 +589,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
|
||||
|
||||
// t-paliad-154 — form-time effective policy lookup. Reachable by
|
||||
// every authenticated user (NOT admin-gated) so deadline +
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,7 @@ type dbServices struct {
|
||||
deadline *services.DeadlineService
|
||||
appointment *services.AppointmentService
|
||||
caldav *services.CalDAVService
|
||||
caldavBindings *services.CalendarBindingService
|
||||
rules *services.DeadlineRuleService
|
||||
calc *services.DeadlineCalculator
|
||||
users *services.UserService
|
||||
@@ -51,6 +53,7 @@ type dbServices struct {
|
||||
broadcast *services.BroadcastService
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
dashboardLayout *services.DashboardLayoutService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
}
|
||||
@@ -102,6 +105,8 @@ func writeServiceError(w http.ResponseWriter, err error) {
|
||||
})
|
||||
case errors.Is(err, services.ErrEventTypeSlugTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrLastProjectAdmin):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
log.Printf("ERROR service: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||
@@ -170,6 +175,18 @@ func mapApprovalError(w http.ResponseWriter, err error) bool {
|
||||
"message": "Die Anfrage ist nicht mehr offen.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrSuggestionRequiresChange):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"code": "suggestion_requires_change",
|
||||
"message": "Ein Vorschlag braucht entweder geänderte Werte oder einen Kommentar.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrSuggestionLifecycleInvalid):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"code": "suggestion_lifecycle_invalid",
|
||||
"message": "Änderungen vorschlagen ist nur für Update- und Complete-Anfragen möglich.",
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -305,7 +322,24 @@ func handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
// t-paliad-223: piggyback effective_project_admin onto the project
|
||||
// payload so the frontend can drive the inline role-edit affordance
|
||||
// without a second round-trip. JSON-merge via a small wrapper that
|
||||
// embeds the existing Project shape — every existing caller keeps
|
||||
// reading the same fields and gains effective_admin as additive.
|
||||
effAdmin, err := dbSvc.team.IsEffectiveProjectAdmin(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
type projectWithPermissions struct {
|
||||
*models.Project
|
||||
EffectiveAdmin bool `json:"effective_admin"`
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projectWithPermissions{
|
||||
Project: p,
|
||||
EffectiveAdmin: effAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/projects/{id}/children — direct children.
|
||||
@@ -337,7 +371,7 @@ func handleListProjectChildren(w http.ResponseWriter, r *http.Request) {
|
||||
// Query parameters (all optional, additive):
|
||||
// ?scope=all|mine|pinned — chip-driven scope (default "all")
|
||||
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
|
||||
// ?type=client,litigation,patent,case,project — type whitelist
|
||||
// ?type=client,litigation,patent,case,project,other — type whitelist
|
||||
// ?has_open_deadlines=true|false — narrow by deadline activity
|
||||
// ?q=<term> — search title / reference / clientmatter
|
||||
// ?subtree_counts=true|false — populate *_subtree fields (default true)
|
||||
|
||||
@@ -473,6 +473,8 @@ func humanProjectType(t string) string {
|
||||
return "Verfahren"
|
||||
case services.ProjectTypeProject:
|
||||
return "Projekt"
|
||||
case services.ProjectTypeOther:
|
||||
return "Sonstiges"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
387
internal/handlers/submissions.go
Normal file
387
internal/handlers/submissions.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package handlers
|
||||
|
||||
// Submission generator HTTP layer (t-paliad-215 Slice 1).
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// GET /api/projects/{id}/submissions
|
||||
// Lists the project's proceeding-relevant submission codes
|
||||
// and reports template availability for each. Powers the
|
||||
// SubmissionsPanel on the project detail page.
|
||||
//
|
||||
// GET /api/projects/{id}/submissions/{code}/generate
|
||||
// Renders the .docx and streams it as an attachment download.
|
||||
// Writes one paliad.system_audit_log row and one
|
||||
// paliad.project_events row per generation. No server-side
|
||||
// binary persistence (design §3, m's Q3 pick).
|
||||
//
|
||||
// Visibility: every endpoint runs through ProjectService.GetByID
|
||||
// (paliad.can_see_project gate). Unauthorised callers get 404, never
|
||||
// 403 — same convention as the rest of the project surfaces (avoids
|
||||
// project-existence enumeration).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// submissionRenderer + registry + vars are package-level singletons
|
||||
// wired by Register() once at boot. Stateless rendering + thread-safe
|
||||
// caches inside the registry mean no per-request construction.
|
||||
var (
|
||||
submissionRenderer *services.SubmissionRenderer
|
||||
submissionRegistry *services.TemplateRegistry
|
||||
submissionVars *services.SubmissionVarsService
|
||||
)
|
||||
|
||||
// submissionRenderTimeout caps a single generate request. Template
|
||||
// fetch (cache-miss) + rendering of a typical pleading takes well
|
||||
// under a second; the timeout exists to surface "Gitea is unreachable"
|
||||
// quickly rather than letting the browser spin.
|
||||
const submissionRenderTimeout = 30 * time.Second
|
||||
|
||||
// docxMime is the .docx Content-Type per the OOXML spec.
|
||||
const docxMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
|
||||
// submissionListEntry is one row in the SubmissionsPanel.
|
||||
type submissionListEntry struct {
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
}
|
||||
|
||||
// submissionListResponse wraps the list with a project-level header.
|
||||
type submissionListResponse struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
Entries []submissionListEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// handleListProjectSubmissions returns the filing-type rules for the
|
||||
// project's proceeding, annotated with template availability.
|
||||
func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSubmissionsWired(w) {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := submissionListResponse{
|
||||
ProjectID: projectID,
|
||||
ProceedingTypeID: project.ProceedingTypeID,
|
||||
Entries: []submissionListEntry{},
|
||||
}
|
||||
|
||||
if project.ProceedingTypeID == nil {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := dbSvc.rules.List(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
log.Printf("submissions: list rules for proceeding %d: %v", *project.ProceedingTypeID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule.SubmissionCode == nil || *rule.SubmissionCode == "" {
|
||||
continue
|
||||
}
|
||||
if rule.EventType == nil || *rule.EventType != "filing" {
|
||||
// Hearings + decisions don't generate submissions. The
|
||||
// "Schriftsätze" panel only lists filings.
|
||||
continue
|
||||
}
|
||||
if rule.LifecycleState != "published" {
|
||||
continue
|
||||
}
|
||||
entry := submissionListEntry{
|
||||
SubmissionCode: *rule.SubmissionCode,
|
||||
Name: rule.Name,
|
||||
NameEN: rule.NameEN,
|
||||
HasTemplate: submissionRegistry.HasTemplate(ctx, *rule.SubmissionCode),
|
||||
}
|
||||
if rule.EventType != nil {
|
||||
entry.EventType = *rule.EventType
|
||||
}
|
||||
if rule.PrimaryParty != nil {
|
||||
entry.PrimaryParty = *rule.PrimaryParty
|
||||
}
|
||||
if rule.LegalSource != nil {
|
||||
entry.LegalSource = *rule.LegalSource
|
||||
}
|
||||
resp.Entries = append(resp.Entries, entry)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleGenerateProjectSubmission renders the .docx and streams it
|
||||
// back to the browser. Audits the generation; never persists the
|
||||
// rendered bytes server-side.
|
||||
func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSubmissionsWired(w) {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
submissionCode := strings.TrimSpace(r.PathValue("code"))
|
||||
if submissionCode == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "submission code required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
|
||||
defer cancel()
|
||||
|
||||
varsResult, err := submissionVars.Build(ctx, services.SubmissionVarsContext{
|
||||
UserID: uid,
|
||||
ProjectID: projectID,
|
||||
SubmissionCode: submissionCode,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := submissionRegistry.Resolve(ctx, submissionCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrNoTemplate) {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "no template available for this submission",
|
||||
"hint": "ask an admin to upload a .docx template under templates/_base/ in mWorkRepo",
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("submissions: template resolve for %s: %v", submissionCode, err)
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "template repository unreachable",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
missing := services.DefaultMissingMarker(varsResult.Lang)
|
||||
rendered, err := submissionRenderer.Render(tmpl.Bytes, varsResult.Placeholders, missing)
|
||||
if err != nil {
|
||||
log.Printf("submissions: render %s for project %s: %v", submissionCode, projectID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "render failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(varsResult, projectID)
|
||||
|
||||
// Audit + Verlauf writes. Best-effort with a background context so
|
||||
// the user still receives the download even if the audit insert
|
||||
// races a slow DB.
|
||||
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelBG()
|
||||
if err := writeSubmissionAuditRow(bgCtx, varsResult, tmpl, submissionCode); err != nil {
|
||||
log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
if err := writeSubmissionProjectEvent(bgCtx, varsResult, tmpl, submissionCode); err != nil {
|
||||
log.Printf("submissions: project_events insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
if err := writeSubmissionDocumentRow(bgCtx, varsResult, tmpl, submissionCode); err != nil {
|
||||
log.Printf("submissions: documents insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", docxMime)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(rendered)))
|
||||
w.Header().Set("X-Paliad-Template-Sha", tmpl.SHA)
|
||||
w.Header().Set("X-Paliad-Template-Tier", tmpl.FirmTier)
|
||||
if _, err := w.Write(rendered); err != nil {
|
||||
log.Printf("submissions: response write failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
// requireSubmissionsWired returns false (and writes 503) when the
|
||||
// generator wasn't constructed at boot. Happens in DATABASE_URL-less
|
||||
// deployments — knowledge-platform-only stacks don't ship the
|
||||
// submission engine.
|
||||
func requireSubmissionsWired(w http.ResponseWriter) bool {
|
||||
if submissionRenderer == nil || submissionRegistry == nil || submissionVars == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "submission generator not configured",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// submissionFileName builds the user-facing filename per design §7:
|
||||
//
|
||||
// {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx
|
||||
//
|
||||
// Slashes and backslashes in case_number sanitise to underscores so
|
||||
// the file saves cleanly across Windows + macOS + Linux. Missing
|
||||
// case_number falls back to an 8-hex-char stable id from the project
|
||||
// UUID so the file still has a deterministic handle.
|
||||
func submissionFileName(vars *services.SubmissionVarsResult, projectID uuid.UUID) string {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
ruleName := strings.TrimSpace(vars.Rule.Name)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
ruleName = strings.TrimSpace(vars.Rule.NameEN)
|
||||
}
|
||||
if ruleName == "" {
|
||||
ruleName = "submission"
|
||||
}
|
||||
caseNo := ""
|
||||
if vars.Project != nil && vars.Project.CaseNumber != nil {
|
||||
caseNo = strings.TrimSpace(*vars.Project.CaseNumber)
|
||||
}
|
||||
if caseNo == "" {
|
||||
caseNo = projectID.String()[:8]
|
||||
}
|
||||
caseNo = strings.ReplaceAll(caseNo, "/", "_")
|
||||
caseNo = strings.ReplaceAll(caseNo, `\`, "_")
|
||||
return fmt.Sprintf("%s-%s-%s.docx", ruleName, caseNo, day.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// writeSubmissionAuditRow files the org-wide audit entry. Reuses the
|
||||
// system_audit_log convention (event_type='submission.generated')
|
||||
// established in t-paliad-214's mig 102.
|
||||
func writeSubmissionAuditRow(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
|
||||
meta := map[string]any{
|
||||
"submission_code": code,
|
||||
"template_path": tmpl.Path,
|
||||
"template_sha": tmpl.SHA,
|
||||
"template_tier": tmpl.FirmTier,
|
||||
"project_id": vars.Project.ID.String(),
|
||||
"rule_id": vars.Rule.ID.String(),
|
||||
"firm": branding.Name,
|
||||
}
|
||||
body, _ := json.Marshal(meta)
|
||||
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
||||
`INSERT INTO paliad.system_audit_log
|
||||
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||
VALUES ('submission.generated', $1, $2, 'project', $3, $4::jsonb)`,
|
||||
vars.User.ID, vars.User.Email, vars.Project.ID.String(), string(body),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeSubmissionProjectEvent surfaces the generation in the project
|
||||
// Verlauf / SmartTimeline. event_type stays free-text (no CHECK on
|
||||
// paliad.project_events.event_type per Slice 2 of SmartTimeline) so we
|
||||
// don't need a migration to introduce 'submission_generated'.
|
||||
func writeSubmissionProjectEvent(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
|
||||
ruleName := strings.TrimSpace(vars.Rule.Name)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
ruleName = strings.TrimSpace(vars.Rule.NameEN)
|
||||
}
|
||||
title := fmt.Sprintf("%s generiert", ruleName)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
title = fmt.Sprintf("%s generated", ruleName)
|
||||
}
|
||||
meta := map[string]any{
|
||||
"submission_code": code,
|
||||
"template_path": tmpl.Path,
|
||||
"template_sha": tmpl.SHA,
|
||||
"template_tier": tmpl.FirmTier,
|
||||
"rule_id": vars.Rule.ID.String(),
|
||||
}
|
||||
body, _ := json.Marshal(meta)
|
||||
now := time.Now().UTC()
|
||||
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, description, event_date,
|
||||
created_by, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, 'submission_generated', $3, NULL, $4, $5, $6::jsonb, $4, $4)`,
|
||||
uuid.New(), vars.Project.ID, title, now, vars.User.ID, string(body),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeSubmissionDocumentRow files the audit-only paliad.documents
|
||||
// row. file_path stays NULL — the bytes are regenerable from inputs
|
||||
// (m's Q3 pick: no server-side binary). doc_type='generated_submission'
|
||||
// is the additive marker; no CHECK constraint exists on doc_type, so
|
||||
// this requires no migration.
|
||||
func writeSubmissionDocumentRow(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
|
||||
ruleName := strings.TrimSpace(vars.Rule.Name)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
ruleName = strings.TrimSpace(vars.Rule.NameEN)
|
||||
}
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
title := fmt.Sprintf("%s (generiert %s)", ruleName, day.Format("2006-01-02"))
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
title = fmt.Sprintf("%s (generated %s)", ruleName, day.Format("2006-01-02"))
|
||||
}
|
||||
provenance := map[string]any{
|
||||
"submission_code": code,
|
||||
"template_path": tmpl.Path,
|
||||
"template_sha": tmpl.SHA,
|
||||
"template_tier": tmpl.FirmTier,
|
||||
"firm": branding.Name,
|
||||
"rule_id": vars.Rule.ID.String(),
|
||||
}
|
||||
body, _ := json.Marshal(provenance)
|
||||
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
||||
`INSERT INTO paliad.documents
|
||||
(id, project_id, title, doc_type, file_path, file_size, mime_type,
|
||||
ai_extracted, uploaded_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'generated_submission', NULL, NULL, $4, $5::jsonb, $6, now(), now())`,
|
||||
uuid.New(), vars.Project.ID, title, docxMime, string(body), vars.User.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -93,6 +93,53 @@ func handleListMembershipsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// PATCH /api/projects/{id}/team/{user_id} — change a direct member's
|
||||
// responsibility. Body: {"responsibility": "<admin|lead|member|observer|external>"}.
|
||||
//
|
||||
// Authorisation is RLS-enforced (project_teams_update gated on
|
||||
// effective_project_admin in mig 111). Non-admins get a pq permission
|
||||
// error from the UPDATE; we surface that as 404 to avoid leaking that
|
||||
// the row exists. The last-admin guard runs inside the service tx and
|
||||
// returns ErrLastProjectAdmin (mapped to 409 by writeServiceError).
|
||||
func handleChangeProjectTeamMemberResponsibility(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
userID, err := uuid.Parse(r.PathValue("user_id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Responsibility string `json:"responsibility"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
m, err := dbSvc.team.ChangeResponsibility(r.Context(), uid, projectID, userID, body.Responsibility)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": "no direct membership found",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/team/{user_id} — remove a direct member.
|
||||
// Inherited memberships can't be removed at the child level.
|
||||
func handleRemoveProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -159,10 +159,35 @@ type Project struct {
|
||||
// OurSide is which side the firm represents on this project. Used
|
||||
// by the Fristenrechner Determinator to predefine the perspective
|
||||
// chip from the project context (t-paliad-164). NULL = unknown /
|
||||
// not set; Determinator falls back to free-pick. Allowed values:
|
||||
// claimant, defendant, court, both.
|
||||
// not set; Determinator falls back to free-pick.
|
||||
//
|
||||
// Allowed sub-roles (mig 112, t-paliad-222):
|
||||
// Active : claimant, applicant, appellant
|
||||
// Reactive : defendant, respondent
|
||||
// Other : third_party, other
|
||||
//
|
||||
// The DB column name stays as `our_side`; the UI label has moved
|
||||
// to "Client Role" / "Mandantenrolle" on case projects and is
|
||||
// hidden on every other project type.
|
||||
OurSide *string `db:"our_side" json:"our_side,omitempty"`
|
||||
|
||||
// OpponentCode is the short slug for the opposing party on a
|
||||
// litigation project (uppercase letters / digits / dashes, max 16
|
||||
// chars). Used as the middle segment when services.BuildProjectCode
|
||||
// assembles an auto-derived project code from the ancestor tree —
|
||||
// e.g. EXMPL.OPNT.567.INF.CFI (t-paliad-222 / m/paliad#50). NULL
|
||||
// → segment skipped silently. Only meaningful on type='litigation'
|
||||
// rows; CHECK constraint (mig 113) enforces the pairing.
|
||||
OpponentCode *string `db:"opponent_code" json:"opponent_code,omitempty"`
|
||||
|
||||
// Code is the auto-derived (or override) project code, computed at
|
||||
// projection time by services.BuildProjectCode. Not a DB column —
|
||||
// no `db:` tag — populated by service-layer projection helpers
|
||||
// after the row is loaded. Empty on rows for which the helper has
|
||||
// not run (e.g. raw fixtures in tests, internal projection paths
|
||||
// that don't call the helper).
|
||||
Code string `db:"-" json:"code,omitempty"`
|
||||
|
||||
// CounterclaimOf is the parent project this row is a counterclaim
|
||||
// (CCR) against (t-paliad-174 SmartTimeline Slice 3). NULL on
|
||||
// regular projects; non-NULL rows are CCR sub-projects rendered as
|
||||
@@ -425,28 +450,75 @@ type ChecklistInstanceWithProject struct {
|
||||
// UserCalDAVConfig holds one user's external CalDAV connection. The password
|
||||
// is never returned in API responses; only the public fields are exposed.
|
||||
type UserCalDAVConfig struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
URL string `db:"url" json:"url"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
|
||||
CalendarPath string `db:"calendar_path" json:"calendar_path"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
|
||||
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
URL string `db:"url" json:"url"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
|
||||
CalendarPath string `db:"calendar_path" json:"calendar_path"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
|
||||
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// MKCALENDAR-capability tri-state (mig 108, Slice 2c). NULL = unprobed.
|
||||
SupportsMKCalendar *bool `db:"supports_mkcalendar" json:"supports_mkcalendar,omitempty"`
|
||||
MKCalendarProbedAt *time.Time `db:"mkcalendar_probed_at" json:"mkcalendar_probed_at,omitempty"`
|
||||
}
|
||||
|
||||
// CalDAVSyncLogEntry is one historical sync record.
|
||||
// CalDAVSyncLogEntry is one historical sync record. BindingID is populated
|
||||
// for per-binding sync entries written by the post-Slice-2a sync engine;
|
||||
// older rows have it NULL and the entry covers the user's default binding.
|
||||
type CalDAVSyncLogEntry struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
|
||||
Direction string `db:"direction" json:"direction"`
|
||||
ItemsPushed int `db:"items_pushed" json:"items_pushed"`
|
||||
ItemsPulled int `db:"items_pulled" json:"items_pulled"`
|
||||
Error *string `db:"error" json:"error,omitempty"`
|
||||
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
|
||||
Direction string `db:"direction" json:"direction"`
|
||||
ItemsPushed int `db:"items_pushed" json:"items_pushed"`
|
||||
ItemsPulled int `db:"items_pulled" json:"items_pulled"`
|
||||
Error *string `db:"error" json:"error,omitempty"`
|
||||
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
|
||||
BindingID *uuid.UUID `db:"binding_id" json:"binding_id,omitempty"`
|
||||
}
|
||||
|
||||
// UserCalendarBinding is one of N (calendar, scope) bindings a user can
|
||||
// configure on top of their single CalDAV server connection. The same
|
||||
// Appointment can land in multiple bindings (e.g. master + per-project),
|
||||
// with per-binding push state living in AppointmentCalDAVTarget.
|
||||
type UserCalendarBinding struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
CalendarPath string `db:"calendar_path" json:"calendar_path"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
ScopeKind string `db:"scope_kind" json:"scope_kind"`
|
||||
ScopeID *uuid.UUID `db:"scope_id" json:"scope_id,omitempty"`
|
||||
IncludePersonal bool `db:"include_personal" json:"include_personal"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
|
||||
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// Scope-kind enum mirrored from paliad.user_calendar_bindings_scope_kind_chk.
|
||||
const (
|
||||
BindingScopeAllVisible = "all_visible"
|
||||
BindingScopePersonalOnly = "personal_only"
|
||||
BindingScopeProject = "project"
|
||||
BindingScopeClient = "client"
|
||||
BindingScopeLitigation = "litigation"
|
||||
BindingScopePatent = "patent"
|
||||
BindingScopeCase = "case"
|
||||
)
|
||||
|
||||
// AppointmentCalDAVTarget is the per-(appointment, binding) push state.
|
||||
// The caldav_uid is canonical per Appointment (same value across all of
|
||||
// an appointment's targets); caldav_etag varies per binding.
|
||||
type AppointmentCalDAVTarget struct {
|
||||
AppointmentID uuid.UUID `db:"appointment_id" json:"appointment_id"`
|
||||
BindingID uuid.UUID `db:"binding_id" json:"binding_id"`
|
||||
CalDAVUID string `db:"caldav_uid" json:"caldav_uid"`
|
||||
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
|
||||
LastPushedAt time.Time `db:"last_pushed_at" json:"last_pushed_at"`
|
||||
}
|
||||
|
||||
// Party is a party to a Project (Kläger, Beklagter, etc. — typically on
|
||||
@@ -805,6 +877,15 @@ type ApprovalRequest struct {
|
||||
// alongside 👀 with a sparkle ✨ on the eye-pill surface.
|
||||
RequesterKind string `db:"requester_kind" json:"requester_kind"`
|
||||
AgentTurnID *uuid.UUID `db:"agent_turn_id" json:"agent_turn_id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// CounterPayload carries the approver's edited values on a
|
||||
// changes_requested row (mig 103, t-paliad-216). NULL for every
|
||||
// other status. Frontend renders it as a diff against the OLD
|
||||
// payload to show "approver suggested X→Y on the following fields".
|
||||
CounterPayload NullableJSON `db:"counter_payload" json:"counter_payload,omitempty"`
|
||||
// PreviousRequestID is the back-pointer from a row spawned by
|
||||
// SuggestChanges to the prior changes_requested row that birthed it
|
||||
// (mig 103, t-paliad-216). NULL on first-attempt rows.
|
||||
PreviousRequestID *uuid.UUID `db:"previous_request_id" json:"previous_request_id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Package offices is the single source of truth for the firm's office list.
|
||||
//
|
||||
// The keys here must stay in sync with the CHECK constraint on
|
||||
// paliad.users.office and paliad.akten.owning_office (migration 001).
|
||||
// The keys here must stay in sync with the CHECK constraints on
|
||||
// paliad.users.office (mig 002) and paliad.partner_units.office
|
||||
// (mig 018, renamed mig 024 + mig 027). Madrid added mig 106.
|
||||
package offices
|
||||
|
||||
// Office is a single firm office with its i18n-ready labels.
|
||||
@@ -20,6 +21,7 @@ var All = []Office{
|
||||
{Key: "london", LabelDE: "London", LabelEN: "London"},
|
||||
{Key: "paris", LabelDE: "Paris", LabelEN: "Paris"},
|
||||
{Key: "milan", LabelDE: "Mailand", LabelEN: "Milan"},
|
||||
{Key: "madrid", LabelDE: "Madrid", LabelEN: "Madrid"},
|
||||
}
|
||||
|
||||
// IsValid reports whether the given key names a known office.
|
||||
|
||||
@@ -3,7 +3,7 @@ package offices
|
||||
import "testing"
|
||||
|
||||
func TestIsValid(t *testing.T) {
|
||||
for _, key := range []string{"munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"} {
|
||||
for _, key := range []string{"munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"} {
|
||||
if !IsValid(key) {
|
||||
t.Errorf("IsValid(%q) = false, want true", key)
|
||||
}
|
||||
|
||||
@@ -753,6 +753,86 @@ func (s *AppointmentService) AllForUser(ctx context.Context, userID uuid.UUID) (
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ErrUnsupportedScope is returned by ForBinding when the binding's
|
||||
// scope_kind is one of the hierarchy scopes (client / litigation /
|
||||
// patent / case) — those land in Slice 3 of t-paliad-212. Slice 2
|
||||
// only supports all_visible / personal_only / project.
|
||||
var ErrUnsupportedScope = errors.New("binding scope_kind not yet supported")
|
||||
|
||||
// ForBinding returns the slice of the user's appointments that belongs
|
||||
// in this binding's calendar. Implements the §2.3 scope filter from
|
||||
// docs/design-caldav-slice-2-2026-05-20.md.
|
||||
//
|
||||
// - all_visible → AllForUser(userID)
|
||||
// - personal_only → personal (project_id IS NULL) appointments
|
||||
// created by this user
|
||||
// - project → appointments attached to scope_id, gated by the
|
||||
// same visibility predicate as AllForUser. Hidden
|
||||
// projects return an empty slice (the binding stays
|
||||
// in place but receives no events). If
|
||||
// include_personal is true, the user's personal
|
||||
// appointments are unioned in.
|
||||
//
|
||||
// Hierarchy scopes (client / litigation / patent / case) return
|
||||
// ErrUnsupportedScope; Slice 3 wires them via the existing path-based
|
||||
// descendant predicate.
|
||||
func (s *AppointmentService) ForBinding(ctx context.Context, userID uuid.UUID, b *models.UserCalendarBinding) ([]models.Appointment, error) {
|
||||
if b == nil {
|
||||
return nil, fmt.Errorf("%w: nil binding", ErrInvalidInput)
|
||||
}
|
||||
switch b.ScopeKind {
|
||||
case models.BindingScopeAllVisible:
|
||||
return s.AllForUser(ctx, userID)
|
||||
|
||||
case models.BindingScopePersonalOnly:
|
||||
rows := []models.Appointment{}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+appointmentColumns+`
|
||||
FROM paliad.appointments t
|
||||
WHERE t.project_id IS NULL
|
||||
AND t.created_by = $1`, userID); err != nil {
|
||||
return nil, fmt.Errorf("for-binding personal_only: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
|
||||
case models.BindingScopeProject:
|
||||
if b.ScopeID == nil {
|
||||
return nil, fmt.Errorf("%w: project binding missing scope_id", ErrInvalidInput)
|
||||
}
|
||||
var query string
|
||||
if b.IncludePersonal {
|
||||
query = `
|
||||
SELECT ` + appointmentColumns + `
|
||||
FROM paliad.appointments t
|
||||
LEFT JOIN paliad.projects p ON p.id = t.project_id
|
||||
WHERE (
|
||||
t.project_id = $2
|
||||
AND ` + visibilityPredicatePositional("p", 1) + `
|
||||
) OR (
|
||||
t.project_id IS NULL AND t.created_by = $1
|
||||
)`
|
||||
} else {
|
||||
query = `
|
||||
SELECT ` + appointmentColumns + `
|
||||
FROM paliad.appointments t
|
||||
JOIN paliad.projects p ON p.id = t.project_id
|
||||
WHERE t.project_id = $2
|
||||
AND ` + visibilityPredicatePositional("p", 1)
|
||||
}
|
||||
rows := []models.Appointment{}
|
||||
if err := s.db.SelectContext(ctx, &rows, query, userID, *b.ScopeID); err != nil {
|
||||
return nil, fmt.Errorf("for-binding project: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
|
||||
case models.BindingScopeClient, models.BindingScopeLitigation, models.BindingScopePatent, models.BindingScopeCase:
|
||||
return nil, ErrUnsupportedScope
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unknown scope_kind %q", ErrInvalidInput, b.ScopeKind)
|
||||
}
|
||||
}
|
||||
|
||||
// FindByCalDAVUID resolves a Appointment from its external UID.
|
||||
func (s *AppointmentService) FindByCalDAVUID(ctx context.Context, uid string) (*models.Appointment, error) {
|
||||
var t models.Appointment
|
||||
|
||||
@@ -25,7 +25,14 @@ const (
|
||||
|
||||
// Project-level responsibility values on paliad.project_teams.responsibility.
|
||||
// Open the ladder gate (lead/member) or close it (observer/external).
|
||||
//
|
||||
// ResponsibilityAdmin (t-paliad-223) is orthogonal to the approval gate —
|
||||
// it grants role-edit authority on the project + descendants via the
|
||||
// paliad.effective_project_admin predicate, but does NOT by itself open
|
||||
// the 4-Augen approval gate. An Admin who has no profession set is still
|
||||
// not an approver. Use responsibilityOpensGate to test the approval axis.
|
||||
const (
|
||||
ResponsibilityAdmin = "admin"
|
||||
ResponsibilityLead = "lead"
|
||||
ResponsibilityMember = "member"
|
||||
ResponsibilityObserver = "observer"
|
||||
@@ -61,11 +68,12 @@ const (
|
||||
|
||||
// RequestStatus values on paliad.approval_requests.status.
|
||||
const (
|
||||
RequestStatusPending = "pending"
|
||||
RequestStatusApproved = "approved"
|
||||
RequestStatusRejected = "rejected"
|
||||
RequestStatusRevoked = "revoked"
|
||||
RequestStatusSuperseded = "superseded"
|
||||
RequestStatusPending = "pending"
|
||||
RequestStatusApproved = "approved"
|
||||
RequestStatusRejected = "rejected"
|
||||
RequestStatusRevoked = "revoked"
|
||||
RequestStatusSuperseded = "superseded"
|
||||
RequestStatusChangesRequested = "changes_requested"
|
||||
)
|
||||
|
||||
// DecisionKind discriminates 'peer' (normal in-team sign-off) from
|
||||
@@ -142,7 +150,7 @@ func IsValidProfession(p string) bool {
|
||||
// recognised project-responsibility enum values. Used by TeamService.
|
||||
func IsValidResponsibility(r string) bool {
|
||||
switch r {
|
||||
case ResponsibilityLead, ResponsibilityMember,
|
||||
case ResponsibilityAdmin, ResponsibilityLead, ResponsibilityMember,
|
||||
ResponsibilityObserver, ResponsibilityExternal:
|
||||
return true
|
||||
}
|
||||
@@ -158,12 +166,14 @@ func IsValidResponsibility(r string) bool {
|
||||
// ErrRequestNotPending -> 409
|
||||
// ErrUnknownEntityType -> 500 (programming error)
|
||||
var (
|
||||
ErrSelfApproval = errors.New("self-approval blocked")
|
||||
ErrNoQualifiedApprover = errors.New("no qualified approver available")
|
||||
ErrConcurrentPending = errors.New("entity already has a pending approval request")
|
||||
ErrNotApprover = errors.New("not authorized to approve this request")
|
||||
ErrRequestNotPending = errors.New("request is not pending")
|
||||
ErrUnknownEntityType = errors.New("unknown entity type")
|
||||
ErrSelfApproval = errors.New("self-approval blocked")
|
||||
ErrNoQualifiedApprover = errors.New("no qualified approver available")
|
||||
ErrConcurrentPending = errors.New("entity already has a pending approval request")
|
||||
ErrNotApprover = errors.New("not authorized to approve this request")
|
||||
ErrRequestNotPending = errors.New("request is not pending")
|
||||
ErrUnknownEntityType = errors.New("unknown entity type")
|
||||
ErrSuggestionRequiresChange = errors.New("suggestion requires a counter_payload diff or a note")
|
||||
ErrSuggestionLifecycleInvalid = errors.New("suggest-changes is only valid for update / complete lifecycles")
|
||||
)
|
||||
|
||||
// PendingApprovalError wraps ErrConcurrentPending with the in-flight
|
||||
|
||||
@@ -35,6 +35,7 @@ package services
|
||||
// pool, so the deadlock path can't be silently bypassed.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
@@ -363,6 +364,321 @@ 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 counter-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).
|
||||
// Note: validates against the WIDER counter-allowlist (t-paliad-217
|
||||
// Slice B), not the date-only revert-allowlist.
|
||||
if payloadDiffers {
|
||||
if _, _, err := buildCounterSetClauses(old.EntityType, counterPayload); err != nil {
|
||||
// buildCounterSetClauses already wraps ErrSuggestionRequiresChange
|
||||
// for the "no allowlisted fields" + empty-title cases. Propagate.
|
||||
return nil, 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 counter_payload fields onto the entity
|
||||
// row (t-paliad-217 Slice B). Uses the WIDER counter-allowlist
|
||||
// (buildCounterSetClauses) — every editable field on the entity, not
|
||||
// just the date-allowlist that triggers approval. Handles
|
||||
// event_type_ids as a junction-table rewrite when present in payload.
|
||||
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)
|
||||
}
|
||||
|
||||
// 1. Column-level updates via the counter-allowlist.
|
||||
setClauses, args, err := buildCounterSetClauses(entityType, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(setClauses) > 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. event_type_ids junction rewrite (deadline only).
|
||||
if entityType == EntityTypeDeadline {
|
||||
if raw, ok := payload["event_type_ids"]; ok {
|
||||
ids, err := parseUUIDList(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: invalid event_type_ids: %v", ErrSuggestionRequiresChange, err)
|
||||
}
|
||||
if err := rewriteDeadlineEventTypes(ctx, tx, entityID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseUUIDList accepts either []any (from json.Unmarshal of a JSON
|
||||
// array) or []string and returns a []uuid.UUID. Empty list = explicit
|
||||
// clear; nil-typed list also empty.
|
||||
func parseUUIDList(raw any) ([]uuid.UUID, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
arr, ok := raw.([]any)
|
||||
if !ok {
|
||||
// Fallback: caller serialized as []string directly.
|
||||
if sarr, ok := raw.([]string); ok {
|
||||
out := make([]uuid.UUID, 0, len(sarr))
|
||||
for _, s := range sarr {
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not a UUID: %q", s)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("expected array, got %T", raw)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(arr))
|
||||
for _, v := range arr {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected string in array, got %T", v)
|
||||
}
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not a UUID: %q", s)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, 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:
|
||||
@@ -631,11 +947,17 @@ func (s *ApprovalService) applyRevert(ctx context.Context, tx *sqlx.Tx, req *mod
|
||||
}
|
||||
|
||||
// buildRevertSetClauses translates pre_image jsonb keys into SQL SET
|
||||
// fragments. Only the date-bearing allowlist (Q4) is honoured; unknown
|
||||
// keys are silently dropped to defend against malformed pre_image rows
|
||||
// (defence-in-depth: callers should already be sending only allowlisted
|
||||
// fields, but a hostile UPDATE on the request row shouldn't let arbitrary
|
||||
// fields be reverted).
|
||||
// fragments for the Reject / Revoke path. Only the date-bearing
|
||||
// t-paliad-138 §Q4 allowlist is honoured; unknown keys are silently
|
||||
// dropped to defend against malformed pre_image rows (defence-in-depth:
|
||||
// callers should already be sending only allowlisted fields, but a
|
||||
// hostile UPDATE on the request row shouldn't let arbitrary fields be
|
||||
// reverted).
|
||||
//
|
||||
// This is intentionally NARROWER than buildCounterSetClauses (which
|
||||
// handles the SuggestChanges counter-payload). Reject restores ONLY what
|
||||
// was originally captured in pre_image; SuggestChanges can write any
|
||||
// counter-allowlist field the approver chose to author.
|
||||
func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string, []any, error) {
|
||||
var setClauses []string
|
||||
var args []any
|
||||
@@ -685,6 +1007,135 @@ func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string
|
||||
return setClauses, args, nil
|
||||
}
|
||||
|
||||
// buildCounterSetClauses translates a SuggestChanges counter_payload jsonb
|
||||
// into SQL SET fragments for the entity row (t-paliad-217 Slice B). This
|
||||
// is the WIDER counter-allowlist — m's 2026-05-20 lock-in: every "real"
|
||||
// editable field on the entity is in scope for a counter-proposal, not
|
||||
// just the date-allowlist that triggers approval (t-paliad-138 §Q4).
|
||||
//
|
||||
// Unknown keys are silently dropped — defence-in-depth against a hostile
|
||||
// counter_payload making it past the handler's body decode. Returns an
|
||||
// error iff zero allowlisted fields are present (caller surfaces as
|
||||
// ErrSuggestionRequiresChange when paired with an empty note).
|
||||
//
|
||||
// event_type_ids is NOT a column on paliad.deadlines — it's a junction
|
||||
// table (paliad.deadline_event_types). applyEntityUpdate handles it
|
||||
// separately; this function silently ignores the key.
|
||||
func buildCounterSetClauses(entityType string, counter map[string]any) ([]string, []any, error) {
|
||||
var setClauses []string
|
||||
var args []any
|
||||
|
||||
add := func(col string, val any) {
|
||||
args = append(args, val)
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
|
||||
}
|
||||
|
||||
// addText accepts string keys and stores either a non-NULL string or
|
||||
// NULL when the caller explicitly cleared the value with an empty
|
||||
// string. Used for the optional-text columns (description, notes,
|
||||
// location, etc.).
|
||||
addText := func(col string, raw any) {
|
||||
if raw == nil {
|
||||
args = append(args, nil)
|
||||
} else {
|
||||
s, _ := raw.(string)
|
||||
if s == "" {
|
||||
args = append(args, nil)
|
||||
} else {
|
||||
args = append(args, s)
|
||||
}
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
|
||||
}
|
||||
|
||||
switch entityType {
|
||||
case EntityTypeDeadline:
|
||||
// Date allowlist (existing).
|
||||
for _, col := range []string{"due_date", "original_due_date", "warning_date"} {
|
||||
if v, ok := counter[col]; ok {
|
||||
add(col, v)
|
||||
}
|
||||
}
|
||||
// Required text (NOT NULL on the column — refuse empty).
|
||||
if v, ok := counter["title"]; ok {
|
||||
s, _ := v.(string)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil, nil, fmt.Errorf("%w: title cannot be empty", ErrSuggestionRequiresChange)
|
||||
}
|
||||
add("title", s)
|
||||
}
|
||||
// Nullable text (empty string clears).
|
||||
for _, col := range []string{"description", "notes", "rule_code"} {
|
||||
if v, ok := counter[col]; ok {
|
||||
addText(col, v)
|
||||
}
|
||||
}
|
||||
|
||||
case EntityTypeAppointment:
|
||||
// Datetime allowlist (existing).
|
||||
for _, col := range []string{"start_at", "end_at"} {
|
||||
if v, ok := counter[col]; ok {
|
||||
add(col, v)
|
||||
}
|
||||
}
|
||||
if v, ok := counter["title"]; ok {
|
||||
s, _ := v.(string)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil, nil, fmt.Errorf("%w: title cannot be empty", ErrSuggestionRequiresChange)
|
||||
}
|
||||
add("title", s)
|
||||
}
|
||||
for _, col := range []string{"description", "location", "appointment_type"} {
|
||||
if v, ok := counter[col]; ok {
|
||||
addText(col, v)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("%w: %q", ErrUnknownEntityType, entityType)
|
||||
}
|
||||
|
||||
// event_type_ids is handled outside this function (junction-table
|
||||
// write). Its presence alone in the counter doesn't count as "zero
|
||||
// fields" — applyEntityUpdate inspects len(setClauses)==0 against the
|
||||
// combined picture, not this return value.
|
||||
if len(setClauses) == 0 {
|
||||
if _, ok := counter["event_type_ids"]; !ok {
|
||||
return nil, nil, fmt.Errorf("%w: no allowlisted fields in counter for %s", ErrSuggestionRequiresChange, entityType)
|
||||
}
|
||||
}
|
||||
return setClauses, args, nil
|
||||
}
|
||||
|
||||
// rewriteDeadlineEventTypes replaces the deadline_event_types junction
|
||||
// rows for a deadline with the provided list (t-paliad-217 Slice B).
|
||||
// Empty list clears the junction (the deadline has no event-type tags).
|
||||
// nil list = no-op (caller didn't include event_type_ids in the counter).
|
||||
//
|
||||
// We don't validate the event_type ids exist — the FK to paliad.event_types
|
||||
// catches that with an ON DELETE CASCADE-safe failure. Caller wraps in tx.
|
||||
func rewriteDeadlineEventTypes(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID, ids []uuid.UUID) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.deadline_event_types WHERE deadline_id = $1`, deadlineID); err != nil {
|
||||
return fmt.Errorf("clear deadline_event_types: %w", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(ids))
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
args = append(args, deadlineID)
|
||||
for i, id := range ids {
|
||||
values = append(values, fmt.Sprintf("($1, $%d)", i+2))
|
||||
args = append(args, id)
|
||||
}
|
||||
q := `INSERT INTO paliad.deadline_event_types (deadline_id, event_type_id) VALUES ` + strings.Join(values, ", ")
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return fmt.Errorf("insert deadline_event_types: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRequestForUpdate locks an approval_requests row inside the tx for
|
||||
// decision processing.
|
||||
func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx, requestID uuid.UUID) (*models.ApprovalRequest, error) {
|
||||
@@ -692,6 +1143,8 @@ func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx,
|
||||
q := `SELECT id, project_id, entity_type, entity_id, lifecycle_event,
|
||||
pre_image, payload, requested_by, requested_at, required_role,
|
||||
status, decided_by, decided_at, decision_kind, decision_note,
|
||||
requester_kind, agent_turn_id,
|
||||
counter_payload, previous_request_id,
|
||||
created_at, updated_at
|
||||
FROM paliad.approval_requests
|
||||
WHERE id = $1
|
||||
@@ -816,14 +1269,20 @@ func marshalJSONOrNull(m map[string]any) ([]byte, error) {
|
||||
// server would reject, replacing the previous click-then-alert UX.
|
||||
type ApprovalRequestView struct {
|
||||
models.ApprovalRequest
|
||||
ProjectTitle string `db:"project_title" json:"project_title"`
|
||||
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
|
||||
RequesterName string `db:"requester_name" json:"requester_name"`
|
||||
RequesterEmail string `db:"requester_email" json:"requester_email"`
|
||||
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
|
||||
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
|
||||
ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"`
|
||||
ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"`
|
||||
ProjectTitle string `db:"project_title" json:"project_title"`
|
||||
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
|
||||
RequesterName string `db:"requester_name" json:"requester_name"`
|
||||
RequesterEmail string `db:"requester_email" json:"requester_email"`
|
||||
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
|
||||
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
|
||||
ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"`
|
||||
ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"`
|
||||
// NextRequestID is the forward-pointer from a changes_requested row
|
||||
// to the new pending row spawned by SuggestChanges (t-paliad-216).
|
||||
// Hydrated via correlated subquery on previous_request_id; the
|
||||
// partial index approval_requests_previous_idx keeps the lookup O(1).
|
||||
// NULL on every row that hasn't been counter-proposed.
|
||||
NextRequestID *uuid.UUID `db:"next_request_id" json:"next_request_id,omitempty"`
|
||||
}
|
||||
|
||||
// approvalEligibilitySQL is the SELECT-and-WHERE-compatible boolean
|
||||
@@ -875,6 +1334,7 @@ const approvalRequestViewColumns = `
|
||||
ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
|
||||
ar.status, ar.decided_by, ar.decided_at, ar.decision_kind, ar.decision_note,
|
||||
ar.requester_kind, ar.agent_turn_id,
|
||||
ar.counter_payload, ar.previous_request_id,
|
||||
ar.created_at, ar.updated_at,
|
||||
p.title AS project_title,
|
||||
CASE WHEN ar.entity_type = 'deadline' THEN d.title
|
||||
@@ -885,7 +1345,11 @@ const approvalRequestViewColumns = `
|
||||
du.display_name AS decider_name,
|
||||
du.email AS decider_email,
|
||||
(ar.status = 'pending' AND ar.requested_by <> $1 AND ` + approvalEligibilitySQL + `) AS viewer_can_approve,
|
||||
(ar.requested_by = $1) AS viewer_is_requester`
|
||||
(ar.requested_by = $1) AS viewer_is_requester,
|
||||
(SELECT nxt.id FROM paliad.approval_requests nxt
|
||||
WHERE nxt.previous_request_id = ar.id
|
||||
ORDER BY nxt.requested_at DESC
|
||||
LIMIT 1) AS next_request_id`
|
||||
|
||||
const approvalRequestViewJoins = `
|
||||
paliad.approval_requests ar
|
||||
|
||||
@@ -190,7 +190,8 @@ func TestIsValidProfession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsValidResponsibility(t *testing.T) {
|
||||
for _, r := range []string{"lead", "member", "observer", "external"} {
|
||||
// t-paliad-223 added 'admin'; the four legacy values stay valid.
|
||||
for _, r := range []string{"admin", "lead", "member", "observer", "external"} {
|
||||
t.Run(r, func(t *testing.T) {
|
||||
if !IsValidResponsibility(r) {
|
||||
t.Errorf("IsValidResponsibility(%q) must be true", r)
|
||||
@@ -206,6 +207,30 @@ func TestIsValidResponsibility(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-223: admin maps to legacy 'lead' for the deprecated shadow
|
||||
// column. The other mappings are unchanged from t-paliad-148. Pin them
|
||||
// so a future refactor doesn't silently flip them.
|
||||
func TestLegacyRoleFromResponsibility(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{ResponsibilityAdmin, "lead"},
|
||||
{ResponsibilityLead, "lead"},
|
||||
{ResponsibilityObserver, "observer"},
|
||||
{ResponsibilityExternal, "local_counsel"},
|
||||
{ResponsibilityMember, "associate"},
|
||||
{"", "associate"}, // unknown / empty falls through to associate
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
got := legacyRoleFromResponsibility(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("legacyRoleFromResponsibility(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalEventType(t *testing.T) {
|
||||
cases := []struct {
|
||||
entity, step, want string
|
||||
@@ -946,3 +971,470 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SuggestChanges_TitleOnlyCounter pins t-paliad-217
|
||||
// Slice B: the counter-allowlist now accepts the wider field set
|
||||
// (title / description / notes / rule_code / event_type_ids on
|
||||
// deadlines). A counter that ONLY changes the title (no date diff) must
|
||||
// succeed — the new pending row's payload carries the title, and the
|
||||
// entity row's title field is updated in-tx.
|
||||
func TestApprovalService_SuggestChanges_TitleOnlyCounter(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
|
||||
|
||||
counter := map[string]any{"title": "Klageerwiderung — Vorschlag Hertz"}
|
||||
newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "")
|
||||
if err != nil {
|
||||
t.Fatalf("title-only suggest: %v", err)
|
||||
}
|
||||
if newReqID == nil {
|
||||
t.Fatal("expected new request id, got nil")
|
||||
}
|
||||
|
||||
// Entity's title flipped.
|
||||
var gotTitle string
|
||||
if err := env.pool.GetContext(ctx, &gotTitle,
|
||||
`SELECT title FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("read title: %v", err)
|
||||
}
|
||||
if gotTitle != "Klageerwiderung — Vorschlag Hertz" {
|
||||
t.Errorf("entity title = %q, want %q", gotTitle, "Klageerwiderung — Vorschlag Hertz")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SuggestChanges_NotesOnlyCounter pins t-paliad-217
|
||||
// Slice B: notes is in the counter-allowlist and a notes-only counter
|
||||
// must succeed. Empty-string clears the column (NULLable text).
|
||||
func TestApprovalService_SuggestChanges_NotesOnlyCounter(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
|
||||
|
||||
counter := map[string]any{"notes": "Bitte vor Einreichung mit Mandant abstimmen."}
|
||||
if _, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, ""); err != nil {
|
||||
t.Fatalf("notes-only suggest: %v", err)
|
||||
}
|
||||
|
||||
var gotNotes *string
|
||||
if err := env.pool.GetContext(ctx, &gotNotes,
|
||||
`SELECT notes FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("read notes: %v", err)
|
||||
}
|
||||
if gotNotes == nil || *gotNotes != "Bitte vor Einreichung mit Mandant abstimmen." {
|
||||
t.Errorf("entity notes = %v, want set", gotNotes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SuggestChanges_EmptyTitleRejected pins the title
|
||||
// non-empty CHECK on the counter-allowlist: title is NOT NULL on the
|
||||
// deadlines column, so a counter that explicitly sends "" for title
|
||||
// must be rejected with ErrSuggestionRequiresChange (not silently
|
||||
// dropped or written as a NULL).
|
||||
func TestApprovalService_SuggestChanges_EmptyTitleRejected(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, oldReqID, _ := env.seedPendingUpdate(t)
|
||||
|
||||
counter := map[string]any{"title": " "} // whitespace-only
|
||||
_, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "")
|
||||
if !errors.Is(err, ErrSuggestionRequiresChange) {
|
||||
t.Errorf("empty-title suggest: got %v, want ErrSuggestionRequiresChange", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
265
internal/services/binding_service.go
Normal file
265
internal/services/binding_service.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// CalendarBindingService — CRUD on paliad.user_calendar_bindings.
|
||||
//
|
||||
// Each row is one of N (calendar, scope) bindings layered on top of the
|
||||
// user's single CalDAV server connection in paliad.user_caldav_config.
|
||||
// Slice 1 (t-paliad-212) introduced the table + an auto-backfilled
|
||||
// 'all_visible' binding per existing user; Slice 2a wires the service
|
||||
// that owns the rows. The sync engine (CalDAVService) drives off
|
||||
// ListEnabled to discover where to push.
|
||||
//
|
||||
// Validation of (scope_kind, scope_id) combinatorics is enforced both
|
||||
// here (so the API returns a useful 400) and by the table's CHECK
|
||||
// constraints (so direct SQL or older clients can't slip a bad row in).
|
||||
type CalendarBindingService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewCalendarBindingService(db *sqlx.DB) *CalendarBindingService {
|
||||
return &CalendarBindingService{db: db}
|
||||
}
|
||||
|
||||
const bindingColumns = `
|
||||
id, user_id, calendar_path, display_name,
|
||||
scope_kind, scope_id, include_personal, enabled,
|
||||
last_sync_at, last_sync_error, created_at, updated_at`
|
||||
|
||||
// ListForUser returns every binding owned by the user, ordered by
|
||||
// scope_kind then created_at so the all_visible / personal_only roots
|
||||
// always sort to the top.
|
||||
func (s *CalendarBindingService) ListForUser(ctx context.Context, userID uuid.UUID) ([]models.UserCalendarBinding, error) {
|
||||
rows := []models.UserCalendarBinding{}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+bindingColumns+`
|
||||
FROM paliad.user_calendar_bindings
|
||||
WHERE user_id = $1
|
||||
ORDER BY
|
||||
CASE scope_kind
|
||||
WHEN 'all_visible' THEN 0
|
||||
WHEN 'personal_only' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
created_at`, userID); err != nil {
|
||||
return nil, fmt.Errorf("list bindings: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListEnabled returns the user's bindings with enabled = true.
|
||||
// Used by the CalDAVService sync loop.
|
||||
func (s *CalendarBindingService) ListEnabled(ctx context.Context, userID uuid.UUID) ([]models.UserCalendarBinding, error) {
|
||||
rows := []models.UserCalendarBinding{}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+bindingColumns+`
|
||||
FROM paliad.user_calendar_bindings
|
||||
WHERE user_id = $1 AND enabled = true
|
||||
ORDER BY created_at`, userID); err != nil {
|
||||
return nil, fmt.Errorf("list enabled bindings: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListAllEnabled returns every enabled binding across all users.
|
||||
// Used at server boot to spawn one sync goroutine per (user) that
|
||||
// owns at least one enabled binding.
|
||||
func (s *CalendarBindingService) ListAllEnabled(ctx context.Context) ([]models.UserCalendarBinding, error) {
|
||||
rows := []models.UserCalendarBinding{}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+bindingColumns+`
|
||||
FROM paliad.user_calendar_bindings
|
||||
WHERE enabled = true
|
||||
ORDER BY user_id, created_at`); err != nil {
|
||||
return nil, fmt.Errorf("list all enabled bindings: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Get returns one binding scoped to the user; ErrNotVisible when the row
|
||||
// doesn't exist or belongs to someone else.
|
||||
func (s *CalendarBindingService) Get(ctx context.Context, userID, bindingID uuid.UUID) (*models.UserCalendarBinding, error) {
|
||||
var b models.UserCalendarBinding
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`SELECT `+bindingColumns+`
|
||||
FROM paliad.user_calendar_bindings
|
||||
WHERE id = $1 AND user_id = $2`, bindingID, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get binding: %w", err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// CreateInput is the payload for POST /api/caldav-bindings. Slice 2b
|
||||
// wires this; Slice 2a exposes Create for tests + SQL-equivalent
|
||||
// integration tests.
|
||||
type CreateBindingInput struct {
|
||||
CalendarPath string `json:"calendar_path"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ScopeKind string `json:"scope_kind"`
|
||||
ScopeID *uuid.UUID `json:"scope_id,omitempty"`
|
||||
IncludePersonal bool `json:"include_personal"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Create inserts a new binding. Validates scope_kind / scope_id
|
||||
// combinatorics; returns ErrInvalidInput on a bad payload.
|
||||
func (s *CalendarBindingService) Create(ctx context.Context, userID uuid.UUID, in CreateBindingInput) (*models.UserCalendarBinding, error) {
|
||||
if err := validateScope(in.ScopeKind, in.ScopeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.CalendarPath == "" {
|
||||
return nil, fmt.Errorf("%w: calendar_path is required", ErrInvalidInput)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var b models.UserCalendarBinding
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`INSERT INTO paliad.user_calendar_bindings
|
||||
(user_id, calendar_path, display_name, scope_kind, scope_id,
|
||||
include_personal, enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
RETURNING `+bindingColumns,
|
||||
userID, in.CalendarPath, in.DisplayName, in.ScopeKind, in.ScopeID,
|
||||
in.IncludePersonal, in.Enabled, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert binding: %w", err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// UpdateInput captures the PATCH-shaped fields. Pointer fields = "leave
|
||||
// as-is when nil".
|
||||
type UpdateBindingInput struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
ScopeKind *string `json:"scope_kind,omitempty"`
|
||||
ScopeID *uuid.UUID `json:"scope_id,omitempty"`
|
||||
IncludePersonal *bool `json:"include_personal,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// Update mutates the binding. Validates the resulting (scope_kind, scope_id)
|
||||
// combinatorics if either field changes.
|
||||
func (s *CalendarBindingService) Update(ctx context.Context, userID, bindingID uuid.UUID, in UpdateBindingInput) (*models.UserCalendarBinding, error) {
|
||||
existing, err := s.Get(ctx, userID, bindingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.ScopeKind != nil || in.ScopeID != nil {
|
||||
kind := existing.ScopeKind
|
||||
if in.ScopeKind != nil {
|
||||
kind = *in.ScopeKind
|
||||
}
|
||||
var sid *uuid.UUID
|
||||
if in.ScopeID != nil {
|
||||
sid = in.ScopeID
|
||||
} else {
|
||||
sid = existing.ScopeID
|
||||
}
|
||||
if err := validateScope(kind, sid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sets := []string{"updated_at = NOW()"}
|
||||
args := []any{}
|
||||
next := 1
|
||||
addSet := func(col string, val any) {
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
||||
args = append(args, val)
|
||||
next++
|
||||
}
|
||||
if in.DisplayName != nil {
|
||||
addSet("display_name", *in.DisplayName)
|
||||
}
|
||||
if in.ScopeKind != nil {
|
||||
addSet("scope_kind", *in.ScopeKind)
|
||||
}
|
||||
if in.ScopeID != nil {
|
||||
addSet("scope_id", *in.ScopeID)
|
||||
}
|
||||
if in.IncludePersonal != nil {
|
||||
addSet("include_personal", *in.IncludePersonal)
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
addSet("enabled", *in.Enabled)
|
||||
}
|
||||
// Append WHERE clause args last.
|
||||
args = append(args, bindingID, userID)
|
||||
q := fmt.Sprintf(`UPDATE paliad.user_calendar_bindings
|
||||
SET %s
|
||||
WHERE id = $%d AND user_id = $%d
|
||||
RETURNING %s`, strings.Join(sets, ", "), next, next+1, bindingColumns)
|
||||
var b models.UserCalendarBinding
|
||||
if err := s.db.GetContext(ctx, &b, q, args...); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
return nil, fmt.Errorf("update binding: %w", err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// Delete removes the binding row. Caller is responsible for the remote
|
||||
// .ics cleanup (CalDAVService handles that via §2.6 of the Slice 2 brief)
|
||||
// before invoking this; this method is the bare DB delete.
|
||||
func (s *CalendarBindingService) Delete(ctx context.Context, userID, bindingID uuid.UUID) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.user_calendar_bindings
|
||||
WHERE id = $1 AND user_id = $2`, bindingID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete binding: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotVisible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSyncStatus is called by CalDAVService after each sync attempt for
|
||||
// this binding. last_sync_error nil clears the previous error.
|
||||
func (s *CalendarBindingService) SetSyncStatus(ctx context.Context, bindingID uuid.UUID, errStr *string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.user_calendar_bindings
|
||||
SET last_sync_at = NOW(), last_sync_error = $1, updated_at = NOW()
|
||||
WHERE id = $2`, errStr, bindingID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update binding sync status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateScope mirrors the table's CHECK constraints — we duplicate
|
||||
// the rule here so the API can return a useful 400 instead of letting
|
||||
// Postgres reject the row with a generic check_violation.
|
||||
func validateScope(kind string, scopeID *uuid.UUID) error {
|
||||
switch kind {
|
||||
case models.BindingScopeAllVisible, models.BindingScopePersonalOnly:
|
||||
if scopeID != nil {
|
||||
return fmt.Errorf("%w: scope_id must be NULL when scope_kind = %q", ErrInvalidInput, kind)
|
||||
}
|
||||
case models.BindingScopeProject, models.BindingScopeClient, models.BindingScopeLitigation, models.BindingScopePatent, models.BindingScopeCase:
|
||||
if scopeID == nil {
|
||||
return fmt.Errorf("%w: scope_id is required when scope_kind = %q", ErrInvalidInput, kind)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown scope_kind %q", ErrInvalidInput, kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,15 +2,28 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrCalendarNameTaken is returned by MakeCalendar when the server
|
||||
// rejects MKCALENDAR with 405 — name already in use.
|
||||
var ErrCalendarNameTaken = errors.New("calendar name already taken on server")
|
||||
|
||||
// ErrMKCalendarUnsupported is returned by MakeCalendar when the server
|
||||
// outright rejects MKCALENDAR (403/501) — should never fire after a
|
||||
// successful probe, but kept as a defence so we don't loop.
|
||||
var ErrMKCalendarUnsupported = errors.New("server does not support MKCALENDAR")
|
||||
|
||||
// Tiny CalDAV HTTP client — only the verbs Paliad needs:
|
||||
// - PUT (create / replace event)
|
||||
// - GET (fetch event by path)
|
||||
@@ -169,6 +182,77 @@ func (c *calDAVClient) PropfindCalendar(ctx context.Context, calendarPath string
|
||||
return parseMultiStatus(resp.Body)
|
||||
}
|
||||
|
||||
// multigetMaxHrefs caps the number of hrefs in one REPORT request to keep
|
||||
// us well within Google's documented limit (~200) and iCloud's
|
||||
// rate-shaping. Callers chunk larger lists into multiple requests.
|
||||
const multigetMaxHrefs = 100
|
||||
|
||||
// MultigetEvent is one (href, etag, calendar-data) result returned by
|
||||
// ReportMultiget. CalendarData is the raw iCalendar body and is fed
|
||||
// straight into parseICalendar; ETag matches the value that would have
|
||||
// been returned by PROPFIND for the same href.
|
||||
type MultigetEvent struct {
|
||||
Href string
|
||||
ETag string
|
||||
CalendarData string
|
||||
}
|
||||
|
||||
// ReportMultiget runs a `REPORT calendar-multiget` (RFC 4791 §7.9)
|
||||
// against calendarPath and returns one MultigetEvent per requested href.
|
||||
// Hrefs missing from the response (404 inside the multistatus) are
|
||||
// omitted from the returned slice — callers should treat that as a
|
||||
// remote deletion. Hrefs are auto-chunked at multigetMaxHrefs.
|
||||
func (c *calDAVClient) ReportMultiget(ctx context.Context, calendarPath string, hrefs []string) ([]MultigetEvent, error) {
|
||||
if len(hrefs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := []MultigetEvent{}
|
||||
for start := 0; start < len(hrefs); start += multigetMaxHrefs {
|
||||
end := min(start+multigetMaxHrefs, len(hrefs))
|
||||
chunk, err := c.reportMultigetChunk(ctx, calendarPath, hrefs[start:end])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, chunk...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *calDAVClient) reportMultigetChunk(ctx context.Context, calendarPath string, hrefs []string) ([]MultigetEvent, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
`)
|
||||
for _, h := range hrefs {
|
||||
b.WriteString(" <D:href>")
|
||||
_ = xml.EscapeText(&b, []byte(h))
|
||||
b.WriteString("</D:href>\n")
|
||||
}
|
||||
b.WriteString(`</C:calendar-multiget>`)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "REPORT", c.absURL(calendarPath), strings.NewReader(b.String()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
req.Header.Set("Depth", "1")
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("REPORT: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 207 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("REPORT %s: %d %s — %s", calendarPath, resp.StatusCode, resp.Status, string(raw))
|
||||
}
|
||||
return parseMultigetResponse(resp.Body)
|
||||
}
|
||||
|
||||
// PropfindRoot performs a Depth:0 PROPFIND on the calendar URL — used by
|
||||
// the "Test connection" button to verify auth + URL without storing creds.
|
||||
func (c *calDAVClient) PropfindRoot(ctx context.Context, path string) error {
|
||||
@@ -198,6 +282,338 @@ func (c *calDAVClient) PropfindRoot(ctx context.Context, path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscoveredCalendar is one calendar collection enumerated by
|
||||
// DiscoverCalendars. supportedComponents lists the iCal component types
|
||||
// the server advertises (VEVENT, VTODO, …); the picker filters to ones
|
||||
// supporting VEVENT.
|
||||
type DiscoveredCalendar struct {
|
||||
Href string
|
||||
DisplayName string
|
||||
SupportedComponents []string
|
||||
}
|
||||
|
||||
// DiscoverCalendars walks the CalDAV discovery chain (RFC 6764 §6 /
|
||||
// RFC 6638 §10): server root → current-user-principal → calendar-home-set
|
||||
// → enumeration of child calendar collections.
|
||||
//
|
||||
// Returns the discovered calendars + the calendar-home-set URL so the
|
||||
// caller can issue MKCALENDAR against it in Slice 2c. Hrefs are
|
||||
// returned as-is (absolute or path-rooted) per server response; the
|
||||
// client's absURL handles both at PUT time.
|
||||
func (c *calDAVClient) DiscoverCalendars(ctx context.Context, serverURL string) ([]DiscoveredCalendar, string, error) {
|
||||
principal, err := c.findCurrentUserPrincipal(ctx, serverURL)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("current-user-principal: %w", err)
|
||||
}
|
||||
home, err := c.findCalendarHomeSet(ctx, principal)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("calendar-home-set: %w", err)
|
||||
}
|
||||
calendars, err := c.listCalendars(ctx, home)
|
||||
if err != nil {
|
||||
return nil, home, fmt.Errorf("list calendars: %w", err)
|
||||
}
|
||||
return calendars, home, nil
|
||||
}
|
||||
|
||||
func (c *calDAVClient) findCurrentUserPrincipal(ctx context.Context, urlPath string) (string, error) {
|
||||
body := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop><d:current-user-principal/></d:prop>
|
||||
</d:propfind>`
|
||||
hrefs, err := c.propfindHrefs(ctx, urlPath, "0", body, "current-user-principal")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(hrefs) == 0 {
|
||||
return "", fmt.Errorf("server returned no current-user-principal")
|
||||
}
|
||||
return hrefs[0], nil
|
||||
}
|
||||
|
||||
func (c *calDAVClient) findCalendarHomeSet(ctx context.Context, principalPath string) (string, error) {
|
||||
body := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop><c:calendar-home-set/></d:prop>
|
||||
</d:propfind>`
|
||||
hrefs, err := c.propfindHrefs(ctx, principalPath, "0", body, "calendar-home-set")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(hrefs) == 0 {
|
||||
return "", fmt.Errorf("server returned no calendar-home-set")
|
||||
}
|
||||
return hrefs[0], nil
|
||||
}
|
||||
|
||||
func (c *calDAVClient) listCalendars(ctx context.Context, homePath string) ([]DiscoveredCalendar, error) {
|
||||
body := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:resourcetype/>
|
||||
<d:displayname/>
|
||||
<c:supported-calendar-component-set/>
|
||||
</d:prop>
|
||||
</d:propfind>`
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(homePath), strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
req.Header.Set("Depth", "1")
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PROPFIND: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 207 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("PROPFIND %s: %d %s — %s", homePath, resp.StatusCode, resp.Status, string(raw))
|
||||
}
|
||||
var ms calendarHomeMultiStatus
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
|
||||
return nil, fmt.Errorf("decode home-set multistatus: %w", err)
|
||||
}
|
||||
out := []DiscoveredCalendar{}
|
||||
for _, r := range ms.Responses {
|
||||
var displayname string
|
||||
isCalendar := false
|
||||
comps := []string{}
|
||||
for _, ps := range r.Propstat {
|
||||
if !strings.Contains(ps.Status, "200") {
|
||||
continue
|
||||
}
|
||||
if ps.Prop.ResourceType.Calendar != nil {
|
||||
isCalendar = true
|
||||
}
|
||||
if ps.Prop.DisplayName != "" {
|
||||
displayname = ps.Prop.DisplayName
|
||||
}
|
||||
for _, comp := range ps.Prop.SupportedCalendarComponentSet.Comp {
|
||||
if comp.Name != "" {
|
||||
comps = append(comps, comp.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !isCalendar {
|
||||
continue
|
||||
}
|
||||
// Filter to calendars that advertise VEVENT support — task / address
|
||||
// books slip into the home-set on Apple iCloud and we don't want
|
||||
// those in the picker.
|
||||
if len(comps) > 0 && !slices.Contains(comps, "VEVENT") {
|
||||
continue
|
||||
}
|
||||
out = append(out, DiscoveredCalendar{
|
||||
Href: r.Href,
|
||||
DisplayName: displayname,
|
||||
SupportedComponents: comps,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// propfindHrefs runs a PROPFIND and returns the hrefs nested under the
|
||||
// named property's value. Used for current-user-principal +
|
||||
// calendar-home-set extraction where the property body is a single href.
|
||||
func (c *calDAVClient) propfindHrefs(ctx context.Context, urlPath, depth, body, propName string) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(urlPath), strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
req.Header.Set("Depth", depth)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PROPFIND: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 207 && resp.StatusCode != 200 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("PROPFIND %s: %d %s — %s", urlPath, resp.StatusCode, resp.Status, string(raw))
|
||||
}
|
||||
var ms propHrefMultiStatus
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
|
||||
return nil, fmt.Errorf("decode multistatus for %s: %w", propName, err)
|
||||
}
|
||||
out := []string{}
|
||||
for _, r := range ms.Responses {
|
||||
for _, ps := range r.Propstat {
|
||||
if !strings.Contains(ps.Status, "200") {
|
||||
continue
|
||||
}
|
||||
for _, h := range ps.Prop.CurrentUserPrincipal.Hrefs {
|
||||
out = append(out, h)
|
||||
}
|
||||
for _, h := range ps.Prop.CalendarHomeSet.Hrefs {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- MKCALENDAR capability probe + provisioning (Slice 2c) ---
|
||||
|
||||
// ProbeMKCalendar reports whether the CalDAV server accepts MKCALENDAR
|
||||
// against the calendar-home-set. Two-step per design §4.2:
|
||||
//
|
||||
// 1. OPTIONS on the home URL — if the server returns `Allow:` listing
|
||||
// MKCALENDAR, we're done.
|
||||
// 2. Synthetic probe — issue MKCALENDAR against a random
|
||||
// `.paliad-probe-<short>/` path and DELETE it. Catches legacy SOGo
|
||||
// and misconfigured Radicales that don't list MKCALENDAR in Allow
|
||||
// but still accept it. Servers that 405/501 the synthetic probe
|
||||
// are recorded as no-MKCALENDAR; further attempts skip the probe.
|
||||
//
|
||||
// The probe never persists state — that's the service-layer's job via
|
||||
// CalDAVService.MakeCalendar.
|
||||
func (c *calDAVClient) ProbeMKCalendar(ctx context.Context, homePath string) (bool, error) {
|
||||
if allows, err := c.optionsAllows(ctx, homePath); err == nil {
|
||||
if slices.Contains(allows, "MKCALENDAR") {
|
||||
return true, nil
|
||||
}
|
||||
// OPTIONS responded but doesn't list MKCALENDAR — fall through to
|
||||
// synthetic probe; some servers omit MKCALENDAR from Allow even
|
||||
// when they accept it. OPTIONS-returns-no-MKCALENDAR is not a
|
||||
// hard negative.
|
||||
}
|
||||
// Synthetic probe — a single MKCALENDAR against a randomised name
|
||||
// that the server is overwhelmingly unlikely to already have.
|
||||
probePath := joinPath(homePath, ".paliad-probe-"+randomToken(6)+"/")
|
||||
mkBody := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:set><D:prop><D:displayname>paliad-probe</D:displayname></D:prop></D:set>
|
||||
</C:mkcalendar>`
|
||||
req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(probePath), strings.NewReader(mkBody))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("MKCALENDAR probe: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated, http.StatusOK:
|
||||
// Server accepted the probe. Tear down the probe collection so
|
||||
// we don't leak a junk calendar; if the DELETE fails we shrug
|
||||
// (best effort — the user's calendar list will have one
|
||||
// .paliad-probe-* entry; not the end of the world).
|
||||
_ = c.deleteCollection(ctx, probePath)
|
||||
return true, nil
|
||||
case http.StatusMethodNotAllowed, http.StatusNotImplemented, http.StatusForbidden:
|
||||
return false, nil
|
||||
default:
|
||||
// Unknown — treat as no-MKCALENDAR to be safe; the user can
|
||||
// still bind by URL.
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// MakeCalendar issues MKCALENDAR against home/<calendarName>/ and
|
||||
// returns the absolute path that was created. The caller is
|
||||
// responsible for picking a free slug; 405 from the server means
|
||||
// "name already taken — pick another".
|
||||
func (c *calDAVClient) MakeCalendar(ctx context.Context, homePath, calendarName, displayName string) (string, error) {
|
||||
path := joinPath(homePath, calendarName+"/")
|
||||
body := mkcalendarBody(displayName)
|
||||
req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(path), strings.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("MKCALENDAR: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated, http.StatusOK:
|
||||
return path, nil
|
||||
case http.StatusMethodNotAllowed:
|
||||
return "", ErrCalendarNameTaken
|
||||
case http.StatusForbidden, http.StatusNotImplemented:
|
||||
return "", ErrMKCalendarUnsupported
|
||||
default:
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("MKCALENDAR %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func mkcalendarBody(displayName string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>`)
|
||||
_ = xml.EscapeText(&b, []byte(displayName))
|
||||
b.WriteString(`</D:displayname>
|
||||
<C:supported-calendar-component-set>
|
||||
<C:comp name="VEVENT"/>
|
||||
</C:supported-calendar-component-set>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</C:mkcalendar>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// optionsAllows returns the methods listed in the Allow header of an
|
||||
// OPTIONS response. Caseless match per RFC 7231 §7.4.1.
|
||||
func (c *calDAVClient) optionsAllows(ctx context.Context, path string) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "OPTIONS", c.absURL(path), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OPTIONS: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("OPTIONS %s: %d", path, resp.StatusCode)
|
||||
}
|
||||
out := []string{}
|
||||
for _, h := range resp.Header.Values("Allow") {
|
||||
for _, m := range strings.Split(h, ",") {
|
||||
out = append(out, strings.ToUpper(strings.TrimSpace(m)))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// deleteCollection sends a DELETE that doesn't care about 404.
|
||||
func (c *calDAVClient) deleteCollection(ctx context.Context, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", c.absURL(path), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// randomToken returns a short hex string of `n` bytes. Used for the
|
||||
// synthetic MKCALENDAR probe path; doesn't need to be cryptographically
|
||||
// strong (the worst-case is a collision with an existing calendar of
|
||||
// the same name, which we catch as ErrCalendarNameTaken upstream).
|
||||
func randomToken(n int) string {
|
||||
buf := make([]byte, n)
|
||||
_, _ = rand.Read(buf)
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// joinPath cleans up double slashes between calendar path and uid.
|
||||
func joinPath(base, name string) string {
|
||||
base = strings.TrimRight(base, "/")
|
||||
@@ -221,6 +637,7 @@ type propStat struct {
|
||||
Status string `xml:"DAV: status"`
|
||||
Prop struct {
|
||||
ETag string `xml:"DAV: getetag"`
|
||||
CalendarData string `xml:"urn:ietf:params:xml:ns:caldav calendar-data"`
|
||||
ResourceType struct {
|
||||
Collection *struct{} `xml:"DAV: collection"`
|
||||
} `xml:"DAV: resourcetype"`
|
||||
@@ -232,6 +649,92 @@ type multiStatus struct {
|
||||
Responses []msResponse `xml:"DAV: response"`
|
||||
}
|
||||
|
||||
// propHrefMultiStatus is used to extract <DAV:href> children out of the
|
||||
// <D:current-user-principal/> and <C:calendar-home-set/> properties.
|
||||
// Both render as: <prop><name><href>…</href></name></prop>.
|
||||
type propHrefMultiStatus struct {
|
||||
XMLName xml.Name `xml:"DAV: multistatus"`
|
||||
Responses []propHrefResponse `xml:"DAV: response"`
|
||||
}
|
||||
|
||||
type propHrefResponse struct {
|
||||
XMLName xml.Name `xml:"DAV: response"`
|
||||
Href string `xml:"DAV: href"`
|
||||
Propstat []propHrefPropstat `xml:"DAV: propstat"`
|
||||
}
|
||||
|
||||
type propHrefPropstat struct {
|
||||
XMLName xml.Name `xml:"DAV: propstat"`
|
||||
Status string `xml:"DAV: status"`
|
||||
Prop struct {
|
||||
CurrentUserPrincipal struct {
|
||||
Hrefs []string `xml:"DAV: href"`
|
||||
} `xml:"DAV: current-user-principal"`
|
||||
CalendarHomeSet struct {
|
||||
Hrefs []string `xml:"DAV: href"`
|
||||
} `xml:"urn:ietf:params:xml:ns:caldav calendar-home-set"`
|
||||
} `xml:"DAV: prop"`
|
||||
}
|
||||
|
||||
// calendarHomeMultiStatus parses the response to a Depth:1 PROPFIND on
|
||||
// calendar-home-set asking for resourcetype + displayname +
|
||||
// supported-calendar-component-set.
|
||||
type calendarHomeMultiStatus struct {
|
||||
XMLName xml.Name `xml:"DAV: multistatus"`
|
||||
Responses []calendarHomeResponse `xml:"DAV: response"`
|
||||
}
|
||||
|
||||
type calendarHomeResponse struct {
|
||||
XMLName xml.Name `xml:"DAV: response"`
|
||||
Href string `xml:"DAV: href"`
|
||||
Propstat []calendarHomePropstat `xml:"DAV: propstat"`
|
||||
}
|
||||
|
||||
type calendarHomePropstat struct {
|
||||
XMLName xml.Name `xml:"DAV: propstat"`
|
||||
Status string `xml:"DAV: status"`
|
||||
Prop struct {
|
||||
DisplayName string `xml:"DAV: displayname"`
|
||||
ResourceType struct {
|
||||
Calendar *struct{} `xml:"urn:ietf:params:xml:ns:caldav calendar"`
|
||||
} `xml:"DAV: resourcetype"`
|
||||
SupportedCalendarComponentSet struct {
|
||||
Comp []struct {
|
||||
Name string `xml:"name,attr"`
|
||||
} `xml:"urn:ietf:params:xml:ns:caldav comp"`
|
||||
} `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-component-set"`
|
||||
} `xml:"DAV: prop"`
|
||||
}
|
||||
|
||||
func parseMultigetResponse(r io.Reader) ([]MultigetEvent, error) {
|
||||
var ms multiStatus
|
||||
dec := xml.NewDecoder(r)
|
||||
if err := dec.Decode(&ms); err != nil {
|
||||
return nil, fmt.Errorf("decode multistatus: %w", err)
|
||||
}
|
||||
out := []MultigetEvent{}
|
||||
for _, resp := range ms.Responses {
|
||||
var etag, data string
|
||||
ok := false
|
||||
for _, ps := range resp.Propstat {
|
||||
if !strings.Contains(ps.Status, "200") {
|
||||
continue
|
||||
}
|
||||
etag = strings.Trim(ps.Prop.ETag, `"`)
|
||||
data = ps.Prop.CalendarData
|
||||
if data != "" {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
// 404 / 403 on this specific href — treat as missing, skip.
|
||||
continue
|
||||
}
|
||||
out = append(out, MultigetEvent{Href: resp.Href, ETag: etag, CalendarData: data})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseMultiStatus(r io.Reader) ([]CalDAVEntry, error) {
|
||||
var ms multiStatus
|
||||
dec := xml.NewDecoder(r)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
157
internal/services/dashboard_layout_service.go
Normal file
157
internal/services/dashboard_layout_service.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package services
|
||||
|
||||
// DashboardLayoutService is the CRUD layer for paliad.user_dashboard_layouts —
|
||||
// per-user configurable dashboard layout for /dashboard.
|
||||
//
|
||||
// Design: docs/design-dashboard-configurable-2026-05-20.md §5.4.
|
||||
//
|
||||
// Visibility: every read and write is scoped to the calling user via the
|
||||
// RLS policy `user_dashboard_layouts_owner_all` on auth.uid() = user_id.
|
||||
// The service also AND-joins user_id in SQL for defense-in-depth.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// DashboardLayoutService manages paliad.user_dashboard_layouts.
|
||||
type DashboardLayoutService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewDashboardLayoutService wires the service.
|
||||
func NewDashboardLayoutService(db *sqlx.DB) *DashboardLayoutService {
|
||||
return &DashboardLayoutService{db: db}
|
||||
}
|
||||
|
||||
// GetOrSeed returns the caller's saved layout. On first call for a user
|
||||
// (no row), it inserts and returns the factory default. The seed is
|
||||
// idempotent — concurrent first-loads converge to the same row via the
|
||||
// ON CONFLICT DO NOTHING clause.
|
||||
//
|
||||
// The returned spec has SanitizeForRead applied; if any entries were
|
||||
// dropped (catalog shrank) the cleaned spec is also persisted back so the
|
||||
// next write doesn't trip on stale entries.
|
||||
func (s *DashboardLayoutService) GetOrSeed(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
|
||||
spec, found, err := s.fetch(ctx, userID)
|
||||
if err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
if !found {
|
||||
return s.seedFactoryDefault(ctx, userID)
|
||||
}
|
||||
if spec.SanitizeForRead() {
|
||||
// Best-effort cleanup; on failure we still return the in-memory
|
||||
// sanitized spec — the user sees a clean dashboard either way.
|
||||
_ = s.upsert(ctx, userID, spec)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// Update validates the spec and UPSERTs it. Returns the persisted spec
|
||||
// (round-tripped through the DB to confirm storage).
|
||||
func (s *DashboardLayoutService) Update(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) (DashboardLayoutSpec, error) {
|
||||
if err := spec.Validate(); err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
if err := s.upsert(ctx, userID, spec); err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
out, found, err := s.fetch(ctx, userID)
|
||||
if err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
if !found {
|
||||
return DashboardLayoutSpec{}, fmt.Errorf("dashboard layout vanished after upsert for user %s", userID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResetToDefault overwrites the user's layout with the factory default.
|
||||
func (s *DashboardLayoutService) ResetToDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
|
||||
def := FactoryDefaultLayout()
|
||||
if err := s.upsert(ctx, userID, def); err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
|
||||
// fetch returns (spec, found, err). found=false means the user has no row
|
||||
// yet — the seed path takes over.
|
||||
func (s *DashboardLayoutService) fetch(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, bool, error) {
|
||||
var raw json.RawMessage
|
||||
err := s.db.GetContext(ctx, &raw, `
|
||||
SELECT layout_json
|
||||
FROM paliad.user_dashboard_layouts
|
||||
WHERE user_id = $1
|
||||
`, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return DashboardLayoutSpec{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return DashboardLayoutSpec{}, false, fmt.Errorf("fetch dashboard layout: %w", err)
|
||||
}
|
||||
var spec DashboardLayoutSpec
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
// Stored row is unparseable — treat as a missing row, the seed
|
||||
// path will overwrite it. Log via the returned error wrapper.
|
||||
return DashboardLayoutSpec{}, false, fmt.Errorf("dashboard layout JSON decode for user %s: %w", userID, err)
|
||||
}
|
||||
return spec, true, nil
|
||||
}
|
||||
|
||||
// seedFactoryDefault inserts the factory layout for a brand-new user.
|
||||
// ON CONFLICT DO NOTHING handles the race where two concurrent first
|
||||
// loads both miss the SELECT and both try to insert.
|
||||
func (s *DashboardLayoutService) seedFactoryDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
|
||||
def := FactoryDefaultLayout()
|
||||
bytes, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout marshal: %w", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
`, userID, json.RawMessage(bytes)); err != nil {
|
||||
return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout insert: %w", err)
|
||||
}
|
||||
// Re-fetch in case ON CONFLICT DO NOTHING let another writer's row win;
|
||||
// either way the user now has a row.
|
||||
out, found, err := s.fetch(ctx, userID)
|
||||
if err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
if !found {
|
||||
// Extremely unlikely — would mean the row vanished between
|
||||
// INSERT and SELECT. Return the factory default in-memory.
|
||||
return def, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// upsert overwrites the layout. updated_at gets bumped on conflict so
|
||||
// callers can observe write recency.
|
||||
func (s *DashboardLayoutService) upsert(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) error {
|
||||
bytes, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dashboard layout marshal: %w", err)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET layout_json = EXCLUDED.layout_json,
|
||||
updated_at = now()
|
||||
`, userID, json.RawMessage(bytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("dashboard layout upsert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
181
internal/services/dashboard_layout_service_test.go
Normal file
181
internal/services/dashboard_layout_service_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package services
|
||||
|
||||
// Live-DB tests for DashboardLayoutService. Skipped when TEST_DATABASE_URL
|
||||
// is unset.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
type dashboardLayoutTestEnv struct {
|
||||
t *testing.T
|
||||
pool *sqlx.DB
|
||||
svc *DashboardLayoutService
|
||||
userID uuid.UUID
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
func setupDashboardLayoutTest(t *testing.T) *dashboardLayoutTestEnv {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uuid.New()
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
|
||||
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
|
||||
t.Logf("skip auth.users seed: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
|
||||
VALUES ($1, $1::text || '@test.local', 'Dashboard Layout Test', 'munich', 'standard')
|
||||
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
c := context.Background()
|
||||
pool.ExecContext(c, `DELETE FROM paliad.user_dashboard_layouts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(c, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(c, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
pool.Close()
|
||||
}
|
||||
|
||||
return &dashboardLayoutTestEnv{
|
||||
t: t,
|
||||
pool: pool,
|
||||
svc: NewDashboardLayoutService(pool),
|
||||
userID: userID,
|
||||
cleanup: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutService_GetOrSeedAutoSeeds(t *testing.T) {
|
||||
env := setupDashboardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
spec, err := env.svc.GetOrSeed(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrSeed: %v", err)
|
||||
}
|
||||
if spec.Version != LayoutSpecVersion {
|
||||
t.Errorf("seeded version=%d; want %d", spec.Version, LayoutSpecVersion)
|
||||
}
|
||||
if len(spec.Widgets) != len(KnownWidgetKeys) {
|
||||
t.Errorf("seeded widget count=%d; want %d", len(spec.Widgets), len(KnownWidgetKeys))
|
||||
}
|
||||
|
||||
// Second call returns the same row, not a second seed.
|
||||
spec2, err := env.svc.GetOrSeed(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrSeed second: %v", err)
|
||||
}
|
||||
if len(spec2.Widgets) != len(spec.Widgets) {
|
||||
t.Errorf("second call widget count drifted: %d vs %d", len(spec2.Widgets), len(spec.Widgets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutService_UpdateRoundTrips(t *testing.T) {
|
||||
env := setupDashboardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Seed first so the row exists.
|
||||
if _, err := env.svc.GetOrSeed(ctx, env.userID); err != nil {
|
||||
t.Fatalf("GetOrSeed: %v", err)
|
||||
}
|
||||
|
||||
// Custom layout: hide matter-summary, reorder.
|
||||
custom := DashboardLayoutSpec{
|
||||
Version: LayoutSpecVersion,
|
||||
Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 5, "horizon_days": 14}`)},
|
||||
{Key: WidgetMatterSummary, Visible: false},
|
||||
{Key: WidgetDeadlineSummary, Visible: true},
|
||||
},
|
||||
}
|
||||
out, err := env.svc.Update(ctx, env.userID, custom)
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if len(out.Widgets) != 3 {
|
||||
t.Fatalf("Update returned %d widgets; want 3", len(out.Widgets))
|
||||
}
|
||||
if out.Widgets[0].Key != WidgetUpcomingDeadlines {
|
||||
t.Errorf("Update returned widgets[0]=%q; want %q", out.Widgets[0].Key, WidgetUpcomingDeadlines)
|
||||
}
|
||||
if out.Widgets[1].Visible {
|
||||
t.Errorf("Update returned widgets[1].Visible=true; want false")
|
||||
}
|
||||
|
||||
// Re-read confirms persistence.
|
||||
got, err := env.svc.GetOrSeed(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrSeed after update: %v", err)
|
||||
}
|
||||
if len(got.Widgets) != 3 {
|
||||
t.Errorf("GetOrSeed after update: %d widgets; want 3", len(got.Widgets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutService_UpdateRejectsInvalid(t *testing.T) {
|
||||
env := setupDashboardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
bad := DashboardLayoutSpec{
|
||||
Version: LayoutSpecVersion,
|
||||
Widgets: []DashboardWidgetRef{
|
||||
{Key: "fake-widget-key", Visible: true},
|
||||
},
|
||||
}
|
||||
if _, err := env.svc.Update(ctx, env.userID, bad); err == nil {
|
||||
t.Fatalf("Update accepted invalid layout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutService_ResetToDefault(t *testing.T) {
|
||||
env := setupDashboardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Custom layout first.
|
||||
custom := DashboardLayoutSpec{
|
||||
Version: LayoutSpecVersion,
|
||||
Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetDeadlineSummary, Visible: true},
|
||||
},
|
||||
}
|
||||
if _, err := env.svc.Update(ctx, env.userID, custom); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
// Reset.
|
||||
reset, err := env.svc.ResetToDefault(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetToDefault: %v", err)
|
||||
}
|
||||
if len(reset.Widgets) != len(KnownWidgetKeys) {
|
||||
t.Errorf("reset widget count=%d; want %d", len(reset.Widgets), len(KnownWidgetKeys))
|
||||
}
|
||||
}
|
||||
176
internal/services/dashboard_layout_spec.go
Normal file
176
internal/services/dashboard_layout_spec.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package services
|
||||
|
||||
// DashboardLayoutSpec — JSON shape for paliad.user_dashboard_layouts.layout_json.
|
||||
//
|
||||
// Design: docs/design-dashboard-configurable-2026-05-20.md §5.2.
|
||||
//
|
||||
// Validation surface:
|
||||
// - version must be 1 (v0 / unknown versions seed the factory default at
|
||||
// read time; the validator only ever sees writes from a current client).
|
||||
// - widgets is at most 32 entries (sanity cap; catalog can grow but a
|
||||
// single user's layout shouldn't).
|
||||
// - each widget.key must be in KnownWidgetKeys on WRITE.
|
||||
// - no duplicate keys.
|
||||
// - each widget.settings (if present) is validated against its catalog
|
||||
// entry's WidgetSettingsSchema.
|
||||
//
|
||||
// On READ, unknown keys are dropped silently — see SanitizeForRead.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// LayoutSpecVersion is the only supported version for v1.
|
||||
const LayoutSpecVersion = 1
|
||||
|
||||
// LayoutWidgetCap is the sanity cap on widgets per layout. The v1 catalog
|
||||
// has 7 entries; 32 leaves room for catalog growth without unbounded JSON
|
||||
// blobs.
|
||||
const LayoutWidgetCap = 32
|
||||
|
||||
// DashboardWidgetRef is a single widget entry in the ordered widgets[] array.
|
||||
// Visible=false entries are kept in the array so the picker can show them as
|
||||
// "hidden" and re-adding restores their position.
|
||||
type DashboardWidgetRef struct {
|
||||
Key WidgetKey `json:"key"`
|
||||
Visible bool `json:"visible"`
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// DashboardLayoutSpec is the persisted layout shape.
|
||||
type DashboardLayoutSpec struct {
|
||||
Version int `json:"v"`
|
||||
Widgets []DashboardWidgetRef `json:"widgets"`
|
||||
}
|
||||
|
||||
// FactoryDefaultLayout returns the Slice A1 baseline layout — every
|
||||
// widget in KnownWidgetKeys, visible, in canonical order, with per-widget
|
||||
// default settings drawn from the catalog. A user with no row sees this
|
||||
// on first load and is byte-identical to today's dashboard plus the new
|
||||
// inbox-approvals widget.
|
||||
func FactoryDefaultLayout() DashboardLayoutSpec {
|
||||
catalog := WidgetCatalog()
|
||||
byKey := make(map[WidgetKey]WidgetDef, len(catalog))
|
||||
for _, def := range catalog {
|
||||
byKey[def.Key] = def
|
||||
}
|
||||
|
||||
widgets := make([]DashboardWidgetRef, 0, len(KnownWidgetKeys))
|
||||
for _, k := range KnownWidgetKeys {
|
||||
def, ok := byKey[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ref := DashboardWidgetRef{Key: k, Visible: def.DefaultVisible}
|
||||
if settings := defaultSettingsJSON(def); settings != nil {
|
||||
ref.Settings = settings
|
||||
}
|
||||
widgets = append(widgets, ref)
|
||||
}
|
||||
|
||||
return DashboardLayoutSpec{
|
||||
Version: LayoutSpecVersion,
|
||||
Widgets: widgets,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultSettingsJSON encodes the per-widget defaults declared on the
|
||||
// catalog entry. Returns nil when the widget has no settings.
|
||||
func defaultSettingsJSON(def WidgetDef) json.RawMessage {
|
||||
if def.DefaultCount == nil && def.DefaultHorizon == nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]int{}
|
||||
if def.DefaultCount != nil {
|
||||
out["count"] = *def.DefaultCount
|
||||
}
|
||||
if def.DefaultHorizon != nil {
|
||||
out["horizon_days"] = *def.DefaultHorizon
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Validate enforces the structural invariants on write. Returns
|
||||
// ErrInvalidInput wrapped with a precise message on the first violation.
|
||||
func (s DashboardLayoutSpec) Validate() error {
|
||||
if s.Version != LayoutSpecVersion {
|
||||
return fmt.Errorf("%w: layout version %d not supported (want %d)",
|
||||
ErrInvalidInput, s.Version, LayoutSpecVersion)
|
||||
}
|
||||
if len(s.Widgets) > LayoutWidgetCap {
|
||||
return fmt.Errorf("%w: layout has %d widgets (cap %d)",
|
||||
ErrInvalidInput, len(s.Widgets), LayoutWidgetCap)
|
||||
}
|
||||
|
||||
seen := make(map[WidgetKey]bool, len(s.Widgets))
|
||||
for i, w := range s.Widgets {
|
||||
if !slices.Contains(KnownWidgetKeys, w.Key) {
|
||||
return fmt.Errorf("%w: widgets[%d].key %q is not a known widget",
|
||||
ErrInvalidInput, i, w.Key)
|
||||
}
|
||||
if seen[w.Key] {
|
||||
return fmt.Errorf("%w: widgets has duplicate key %q",
|
||||
ErrInvalidInput, w.Key)
|
||||
}
|
||||
seen[w.Key] = true
|
||||
|
||||
def, ok := LookupWidgetDef(w.Key)
|
||||
if !ok {
|
||||
// Defense in depth — KnownWidgetKeys was checked above.
|
||||
return fmt.Errorf("%w: widgets[%d].key %q has no catalog entry",
|
||||
ErrInvalidInput, i, w.Key)
|
||||
}
|
||||
if err := def.Settings.Validate(w.Settings); err != nil {
|
||||
return fmt.Errorf("widgets[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanitizeForRead applies the forgiving read-path rules: drop entries whose
|
||||
// keys are not in the catalog (catalog has shrunk) and bump the version to
|
||||
// the current one if missing. Settings on surviving entries pass through
|
||||
// unchanged — invalid settings on read are not worth aborting over and the
|
||||
// next write will reject them anyway.
|
||||
//
|
||||
// Returns true if anything was changed; callers can use that to decide
|
||||
// whether to PUT the cleaned spec back.
|
||||
func (s *DashboardLayoutSpec) SanitizeForRead() bool {
|
||||
changed := false
|
||||
if s.Version != LayoutSpecVersion {
|
||||
s.Version = LayoutSpecVersion
|
||||
changed = true
|
||||
}
|
||||
if len(s.Widgets) == 0 {
|
||||
return changed
|
||||
}
|
||||
out := make([]DashboardWidgetRef, 0, len(s.Widgets))
|
||||
for _, w := range s.Widgets {
|
||||
if _, ok := LookupWidgetDef(w.Key); !ok {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
s.Widgets = out
|
||||
return changed
|
||||
}
|
||||
|
||||
// ParseDashboardLayoutSpec decodes JSON bytes and validates. Used by the
|
||||
// HTTP handler on incoming request bodies.
|
||||
func ParseDashboardLayoutSpec(b []byte) (DashboardLayoutSpec, error) {
|
||||
var s DashboardLayoutSpec
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return DashboardLayoutSpec{}, fmt.Errorf("%w: layout JSON decode: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if err := s.Validate(); err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
241
internal/services/dashboard_layout_spec_test.go
Normal file
241
internal/services/dashboard_layout_spec_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for DashboardLayoutSpec + WidgetCatalog.
|
||||
// No DB; safe to run in any environment.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFactoryDefaultLayout_AllKnownWidgetsPresent(t *testing.T) {
|
||||
def := FactoryDefaultLayout()
|
||||
if def.Version != LayoutSpecVersion {
|
||||
t.Errorf("FactoryDefaultLayout version=%d; want %d", def.Version, LayoutSpecVersion)
|
||||
}
|
||||
if len(def.Widgets) != len(KnownWidgetKeys) {
|
||||
t.Fatalf("FactoryDefaultLayout has %d widgets; want %d", len(def.Widgets), len(KnownWidgetKeys))
|
||||
}
|
||||
for i, k := range KnownWidgetKeys {
|
||||
if def.Widgets[i].Key != k {
|
||||
t.Errorf("widgets[%d].Key = %q; want %q", i, def.Widgets[i].Key, k)
|
||||
}
|
||||
if !def.Widgets[i].Visible {
|
||||
t.Errorf("widgets[%d].Visible = false; factory default should be all-visible", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryDefaultLayout_SettingsDefaultsPresent(t *testing.T) {
|
||||
def := FactoryDefaultLayout()
|
||||
for _, w := range def.Widgets {
|
||||
catalogDef, ok := LookupWidgetDef(w.Key)
|
||||
if !ok {
|
||||
t.Errorf("factory widget %q is not in catalog", w.Key)
|
||||
continue
|
||||
}
|
||||
hasDefaults := catalogDef.DefaultCount != nil || catalogDef.DefaultHorizon != nil
|
||||
if hasDefaults && len(w.Settings) == 0 {
|
||||
t.Errorf("widget %q has catalog defaults but factory layout has empty settings", w.Key)
|
||||
}
|
||||
if !hasDefaults && len(w.Settings) > 0 {
|
||||
t.Errorf("widget %q has no catalog defaults but factory layout has settings %s", w.Key, string(w.Settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryDefaultLayout_PassesValidation(t *testing.T) {
|
||||
def := FactoryDefaultLayout()
|
||||
if err := def.Validate(); err != nil {
|
||||
t.Fatalf("factory default failed Validate(): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_WrongVersion(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 99, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "version") {
|
||||
t.Errorf("error %q should mention 'version'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_TooManyWidgets(t *testing.T) {
|
||||
widgets := make([]DashboardWidgetRef, LayoutWidgetCap+1)
|
||||
for i := range widgets {
|
||||
widgets[i] = DashboardWidgetRef{Key: WidgetDeadlineSummary, Visible: true}
|
||||
}
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: widgets}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_UnknownKey(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: "not-a-real-widget", Visible: true},
|
||||
}}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_DuplicateKey(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetDeadlineSummary, Visible: true},
|
||||
{Key: WidgetDeadlineSummary, Visible: false},
|
||||
}}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("error %q should mention 'duplicate'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_BadSettings(t *testing.T) {
|
||||
// count not in CountOptions for upcoming-deadlines (legal: 1,3,5,10,20)
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 7}`)},
|
||||
}}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_AcceptsValidSettings(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 5, "horizon_days": 14}`)},
|
||||
{Key: WidgetInlineAgenda, Visible: true, Settings: json.RawMessage(`{"horizon_days": 60}`)},
|
||||
{Key: WidgetRecentActivity, Visible: false},
|
||||
}}
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("Validate returned %v; want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_SettingsOnNoSettingsWidget(t *testing.T) {
|
||||
// deadline-summary has no Settings schema.
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetDeadlineSummary, Visible: true, Settings: json.RawMessage(`{"count": 5}`)},
|
||||
}}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_DropsUnknownKeys(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetDeadlineSummary, Visible: true},
|
||||
{Key: "deprecated-widget", Visible: true},
|
||||
{Key: WidgetInlineAgenda, Visible: true},
|
||||
}}
|
||||
changed := s.SanitizeForRead()
|
||||
if !changed {
|
||||
t.Errorf("SanitizeForRead returned false; expected true (one entry dropped)")
|
||||
}
|
||||
if len(s.Widgets) != 2 {
|
||||
t.Errorf("after sanitize: %d widgets; want 2", len(s.Widgets))
|
||||
}
|
||||
if s.Widgets[0].Key != WidgetDeadlineSummary || s.Widgets[1].Key != WidgetInlineAgenda {
|
||||
t.Errorf("after sanitize: keys = %v %v; want %v %v",
|
||||
s.Widgets[0].Key, s.Widgets[1].Key, WidgetDeadlineSummary, WidgetInlineAgenda)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_NoopOnClean(t *testing.T) {
|
||||
s := FactoryDefaultLayout()
|
||||
if s.SanitizeForRead() {
|
||||
t.Errorf("SanitizeForRead on factory default returned true; want false (already clean)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_BumpsVersion(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 0, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
|
||||
if !s.SanitizeForRead() {
|
||||
t.Errorf("SanitizeForRead returned false; expected version bump")
|
||||
}
|
||||
if s.Version != LayoutSpecVersion {
|
||||
t.Errorf("after sanitize: Version=%d; want %d", s.Version, LayoutSpecVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDashboardLayoutSpec_RoundTrip(t *testing.T) {
|
||||
def := FactoryDefaultLayout()
|
||||
bytes, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
parsed, err := ParseDashboardLayoutSpec(bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if parsed.Version != def.Version {
|
||||
t.Errorf("version mismatch: %d vs %d", parsed.Version, def.Version)
|
||||
}
|
||||
if len(parsed.Widgets) != len(def.Widgets) {
|
||||
t.Errorf("widget count mismatch: %d vs %d", len(parsed.Widgets), len(def.Widgets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDashboardLayoutSpec_InvalidJSON(t *testing.T) {
|
||||
_, err := ParseDashboardLayoutSpec([]byte(`{not-json}`))
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("ParseDashboardLayoutSpec returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetCatalog_AllKnownKeysHaveDef(t *testing.T) {
|
||||
for _, k := range KnownWidgetKeys {
|
||||
def, ok := LookupWidgetDef(k)
|
||||
if !ok {
|
||||
t.Errorf("KnownWidgetKeys entry %q has no WidgetDef", k)
|
||||
continue
|
||||
}
|
||||
if def.TitleDE == "" || def.TitleEN == "" {
|
||||
t.Errorf("widget %q missing title (de=%q en=%q)", k, def.TitleDE, def.TitleEN)
|
||||
}
|
||||
if def.DescriptionDE == "" || def.DescriptionEN == "" {
|
||||
t.Errorf("widget %q missing description", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetCatalog_NoOrphanDefs(t *testing.T) {
|
||||
known := make(map[WidgetKey]bool, len(KnownWidgetKeys))
|
||||
for _, k := range KnownWidgetKeys {
|
||||
known[k] = true
|
||||
}
|
||||
for _, def := range WidgetCatalog() {
|
||||
if !known[def.Key] {
|
||||
// Orphans are allowed (forward-compat: pinned-projects const
|
||||
// exists in widget_catalog.go before its widget module ships).
|
||||
// But verify the catalog entry is internally coherent.
|
||||
if def.TitleDE == "" || def.TitleEN == "" {
|
||||
t.Errorf("orphan catalog entry %q must still have titles", def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetSettingsSchema_NilRejectsNonEmpty(t *testing.T) {
|
||||
var sch *WidgetSettingsSchema
|
||||
if err := sch.Validate(json.RawMessage(`{"count": 5}`)); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("nil schema accepted settings; got %v", err)
|
||||
}
|
||||
if err := sch.Validate(nil); err != nil {
|
||||
t.Errorf("nil schema rejected empty settings: %v", err)
|
||||
}
|
||||
if err := sch.Validate(json.RawMessage(`null`)); err != nil {
|
||||
t.Errorf("nil schema rejected 'null' settings: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -21,14 +21,24 @@ import (
|
||||
// DashboardService reads paliad.projects/deadlines/appointments/project_events for
|
||||
// the Dashboard page.
|
||||
type DashboardService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
approvals *ApprovalService
|
||||
}
|
||||
|
||||
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
|
||||
return &DashboardService{db: db, users: users}
|
||||
}
|
||||
|
||||
// SetApprovalService wires the inbox-approvals widget data source. Called
|
||||
// post-construction so that DashboardService and ApprovalService can be
|
||||
// stitched together at boot without a circular constructor dependency.
|
||||
// Safe to leave nil — InboxSummary will then carry pending_count=0 and an
|
||||
// empty entries list, and the widget renders its empty state.
|
||||
func (s *DashboardService) SetApprovalService(a *ApprovalService) {
|
||||
s.approvals = a
|
||||
}
|
||||
|
||||
// DashboardData is the full payload returned to the frontend.
|
||||
type DashboardData struct {
|
||||
User *DashboardUser `json:"user"`
|
||||
@@ -38,8 +48,42 @@ type DashboardData struct {
|
||||
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
|
||||
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
|
||||
RecentActivity []ActivityEntry `json:"recent_activity"`
|
||||
InboxSummary InboxSummary `json:"inbox_summary"`
|
||||
}
|
||||
|
||||
// InboxSummary feeds the inbox-approvals widget on the configurable
|
||||
// dashboard (t-paliad-219). PendingCount is the precise number of
|
||||
// approval requests that await this user's approval; Top is a small
|
||||
// preview list (up to InboxTopCap entries) ordered oldest-pending-first
|
||||
// so the most urgent appears first.
|
||||
//
|
||||
// When the ApprovalService dependency is unwired (knowledge-platform-only
|
||||
// deployments, tests), PendingCount=0 and Top=[] so the widget renders
|
||||
// its empty state. The data path is read-only — no writes go through
|
||||
// the dashboard payload.
|
||||
type InboxSummary struct {
|
||||
PendingCount int `json:"pending_count"`
|
||||
Top []InboxEntry `json:"top"`
|
||||
}
|
||||
|
||||
// InboxEntry is a single row in InboxSummary.Top — the minimum needed
|
||||
// to render a clickable preview ("Frist X auf Akte Y, vorgeschlagen am Z").
|
||||
type InboxEntry struct {
|
||||
RequestID uuid.UUID `json:"id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityTitle *string `json:"entity_title,omitempty"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProjectTitle string `json:"project_title"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
RequesterID uuid.UUID `json:"requester_id"`
|
||||
RequesterName string `json:"requester_name"`
|
||||
}
|
||||
|
||||
// InboxTopCap caps the preview list. The widget's count setting tops out
|
||||
// at 10 (see WidgetCatalog inboxCounts); we fetch the cap once and let
|
||||
// the client trim further per the user's setting.
|
||||
const InboxTopCap = 10
|
||||
|
||||
type DashboardUser struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
@@ -146,7 +190,12 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
|
||||
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
endOfWindow := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||
// t-paliad-219 §18 Note B: widen the upcoming windows from 7d → 60d
|
||||
// so the per-widget horizon dropdown (7/14/30/60) can filter client-
|
||||
// side without re-querying. LIMIT bumps from 10 to 40 for the same
|
||||
// reason — the widget's count setting tops out at 20 plus headroom
|
||||
// for the agenda widget which can read from the same payload.
|
||||
endOfWindow := now.AddDate(0, 0, 60).Format("2006-01-02")
|
||||
bounds := computeDeadlineBucketBounds(now.UTC())
|
||||
|
||||
if err := s.loadSummary(ctx, data, user, bounds); err != nil {
|
||||
@@ -161,6 +210,9 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
|
||||
if err := s.loadRecentActivity(ctx, data, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.loadInboxSummary(ctx, data, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
annotateUrgency(data.UpcomingDeadlines, now)
|
||||
return data, nil
|
||||
@@ -261,7 +313,7 @@ SELECT f.id,
|
||||
AND f.due_date <= $3::date
|
||||
AND ` + visibilityPredicatePositional("p", 1) + `
|
||||
ORDER BY f.due_date ASC
|
||||
LIMIT 10`
|
||||
LIMIT 40`
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
|
||||
user.ID, today, endOfWeek); err != nil {
|
||||
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
|
||||
@@ -269,6 +321,45 @@ SELECT f.id,
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadInboxSummary populates DashboardData.InboxSummary — the open-
|
||||
// approval count + top InboxTopCap entries for the inbox-approvals
|
||||
// widget (t-paliad-219). When ApprovalService is unwired (knowledge-
|
||||
// platform-only deployments, tests), the function is a no-op and the
|
||||
// widget renders its empty state.
|
||||
func (s *DashboardService) loadInboxSummary(ctx context.Context, data *DashboardData, user *models.User) error {
|
||||
data.InboxSummary = InboxSummary{Top: []InboxEntry{}}
|
||||
if s.approvals == nil {
|
||||
return nil
|
||||
}
|
||||
cnt, err := s.approvals.PendingCountForUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dashboard inbox count: %w", err)
|
||||
}
|
||||
data.InboxSummary.PendingCount = cnt
|
||||
if cnt == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.approvals.ListPendingForApprover(ctx, user.ID, InboxFilter{Limit: InboxTopCap})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dashboard inbox top: %w", err)
|
||||
}
|
||||
top := make([]InboxEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
top = append(top, InboxEntry{
|
||||
RequestID: r.ID,
|
||||
EntityType: r.EntityType,
|
||||
EntityTitle: r.EntityTitle,
|
||||
ProjectID: r.ProjectID,
|
||||
ProjectTitle: r.ProjectTitle,
|
||||
RequestedAt: r.RequestedAt,
|
||||
RequesterID: r.RequestedBy,
|
||||
RequesterName: r.RequesterName,
|
||||
})
|
||||
}
|
||||
data.InboxSummary.Top = top
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
|
||||
query := `
|
||||
SELECT t.id,
|
||||
@@ -282,13 +373,13 @@ SELECT t.id,
|
||||
FROM paliad.appointments t
|
||||
LEFT JOIN paliad.projects p ON p.id = t.project_id
|
||||
WHERE t.start_at >= $2
|
||||
AND t.start_at < ($2 + interval '7 days')
|
||||
AND t.start_at < ($2 + interval '60 days')
|
||||
AND (
|
||||
(t.project_id IS NULL AND t.created_by = $1)
|
||||
OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 1) + `)
|
||||
)
|
||||
ORDER BY t.start_at ASC
|
||||
LIMIT 10`
|
||||
LIMIT 40`
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
|
||||
user.ID, now); err != nil {
|
||||
return fmt.Errorf("dashboard upcoming appointments: %w", err)
|
||||
|
||||
51
internal/services/dashboard_service_test.go
Normal file
51
internal/services/dashboard_service_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for DashboardService extensions in Slice A3.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func TestDashboardService_InboxSummary_NilApprovalsIsNoop(t *testing.T) {
|
||||
s := &DashboardService{} // approvals nil
|
||||
data := &DashboardData{}
|
||||
user := &models.User{ID: uuid.New()}
|
||||
if err := s.loadInboxSummary(context.Background(), data, user); err != nil {
|
||||
t.Fatalf("loadInboxSummary with nil approvals returned %v; want nil", err)
|
||||
}
|
||||
if data.InboxSummary.PendingCount != 0 {
|
||||
t.Errorf("PendingCount=%d; want 0", data.InboxSummary.PendingCount)
|
||||
}
|
||||
if data.InboxSummary.Top == nil {
|
||||
t.Errorf("Top is nil; want empty slice")
|
||||
}
|
||||
if len(data.InboxSummary.Top) != 0 {
|
||||
t.Errorf("Top has %d entries; want 0", len(data.InboxSummary.Top))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardService_SetApprovalService_WiringWorks(t *testing.T) {
|
||||
s := &DashboardService{}
|
||||
if s.approvals != nil {
|
||||
t.Fatalf("freshly-constructed DashboardService has non-nil approvals")
|
||||
}
|
||||
a := &ApprovalService{} // empty shell; we only check the pointer wiring
|
||||
s.SetApprovalService(a)
|
||||
if s.approvals != a {
|
||||
t.Errorf("SetApprovalService did not wire the pointer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxTopCap_NonZero(t *testing.T) {
|
||||
// Sanity guard: if someone zeros this const, the inbox-approvals
|
||||
// widget falls back to an empty top-N silently. Pin it ≥ the
|
||||
// largest catalog count option for the inbox widget (10).
|
||||
if InboxTopCap < 10 {
|
||||
t.Errorf("InboxTopCap=%d; must be ≥ 10 to satisfy widget catalog max count", InboxTopCap)
|
||||
}
|
||||
}
|
||||
266
internal/services/dump_export_test.go
Normal file
266
internal/services/dump_export_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package services
|
||||
|
||||
// Regression tests for the xlsx-generator pitfalls reported by m on
|
||||
// 2026-05-19:
|
||||
//
|
||||
// 1. Excel showed a "Repairs required" prompt on opening the .xlsx.
|
||||
// Root cause: SetPanes call passed only Freeze + YSplit; the
|
||||
// resulting <pane> XML missed topLeftCell + activePane, which
|
||||
// Excel rejects. Fix in buildXLSX: complete the Panes struct
|
||||
// (TopLeftCell="A2", ActivePane="bottomLeft", Selection on
|
||||
// bottomLeft).
|
||||
//
|
||||
// 2. Windows Explorer / Excel's File→Info showed Modified=2006-09-16
|
||||
// ("xuri" — excelize's first-commit defaults). Root cause:
|
||||
// SetDocProps was never called, so the canned default leaked
|
||||
// through. Fix in buildXLSX: SetDocProps({Created, Modified} =
|
||||
// meta.GeneratedAt; Creator = "Paliad (<firm>)").
|
||||
//
|
||||
// The tests are always-on (no env var gate) so a future writer
|
||||
// regression shows up loudly in `go test`. Developer-convenience hatch
|
||||
// at the bottom: set DUMP_EXPORT=1 to additionally write the bundle +
|
||||
// xlsx to /tmp for opening in real Excel.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// fixturePersonalExport builds a tiny in-memory bundle + the raw xlsx
|
||||
// for the regression assertions and the optional /tmp dump.
|
||||
func fixturePersonalExport(t *testing.T) (bundle []byte, xlsxBytes []byte, meta ExportMeta) {
|
||||
t.Helper()
|
||||
meta = ExportMeta{
|
||||
SchemaVersion: 1,
|
||||
FirmName: "HLC",
|
||||
Scope: ExportScopePersonal,
|
||||
GeneratedAt: time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC),
|
||||
GeneratedByID: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
|
||||
GeneratedByEml: "m@hlc.de",
|
||||
GeneratedByLbl: "m",
|
||||
RowCounts: map[string]int{"projects": 1, "deadlines": 0},
|
||||
}
|
||||
sheets := []collectedSheet{
|
||||
{name: "projects", columns: []string{"id", "title", "umlauts"}, rows: [][]string{{"u1", "Acme", "Müller"}}},
|
||||
{name: "deadlines", columns: []string{"id", "due_date"}, rows: nil},
|
||||
}
|
||||
bundle = assembleBundleForTest(t, sheets, meta)
|
||||
var err error
|
||||
xlsxBytes, err = buildXLSX(sheets, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("buildXLSX: %v", err)
|
||||
}
|
||||
return bundle, xlsxBytes, meta
|
||||
}
|
||||
|
||||
// TestXLSX_DocProps_NotExcelizeDefault pins fix #2.
|
||||
//
|
||||
// Before the fix: core.xml had Created=Modified="2006-09-16T00:00:00Z"
|
||||
// (xuri's first commit). Now we expect both to equal meta.GeneratedAt
|
||||
// in RFC 3339 UTC, and Creator to be "Paliad (<firm>)".
|
||||
func TestXLSX_DocProps_NotExcelizeDefault(t *testing.T) {
|
||||
_, xlsxBytes, meta := fixturePersonalExport(t)
|
||||
fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("excelize.OpenReader: %v", err)
|
||||
}
|
||||
defer fl.Close()
|
||||
|
||||
props, err := fl.GetDocProps()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocProps: %v", err)
|
||||
}
|
||||
wantTS := meta.GeneratedAt.UTC().Format(time.RFC3339)
|
||||
if props.Created != wantTS {
|
||||
t.Errorf("Created = %q, want %q (excelize-default leak)", props.Created, wantTS)
|
||||
}
|
||||
if props.Modified != wantTS {
|
||||
t.Errorf("Modified = %q, want %q (excelize-default leak)", props.Modified, wantTS)
|
||||
}
|
||||
if props.Creator == "xuri" || props.Creator == "" {
|
||||
t.Errorf("Creator = %q, want non-empty non-xuri (e.g. \"Paliad (HLC)\")", props.Creator)
|
||||
}
|
||||
if !strings.Contains(props.Creator, "Paliad") {
|
||||
t.Errorf("Creator = %q, expected to contain \"Paliad\"", props.Creator)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXLSX_DocProps_TracksGeneratedAt pins that docProps stays bound to
|
||||
// meta.GeneratedAt across different timestamps — belt-and-braces vs
|
||||
// the fixed-fixture timestamp in the previous test.
|
||||
func TestXLSX_DocProps_TracksGeneratedAt(t *testing.T) {
|
||||
for _, ts := range []time.Time{
|
||||
time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2027, 12, 31, 23, 59, 59, 0, time.UTC),
|
||||
time.Now().UTC().Truncate(time.Second),
|
||||
} {
|
||||
meta := ExportMeta{
|
||||
SchemaVersion: 1,
|
||||
FirmName: "HLC",
|
||||
Scope: ExportScopePersonal,
|
||||
GeneratedAt: ts,
|
||||
RowCounts: map[string]int{"projects": 0},
|
||||
}
|
||||
xlsxBytes, err := buildXLSX([]collectedSheet{
|
||||
{name: "projects", columns: []string{"id"}, rows: nil},
|
||||
}, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("buildXLSX: %v", err)
|
||||
}
|
||||
fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReader: %v", err)
|
||||
}
|
||||
props, err := fl.GetDocProps()
|
||||
_ = fl.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocProps: %v", err)
|
||||
}
|
||||
want := ts.Format(time.RFC3339)
|
||||
if props.Modified != want {
|
||||
t.Errorf("Modified = %q, want %q", props.Modified, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXLSX_PaneXML_IsCompleteAndValid pins fix #1.
|
||||
//
|
||||
// excelize accepts the half-broken <pane state="frozen" ySplit="1"/>
|
||||
// shape on re-read (its parser is permissive), but Excel rejects it
|
||||
// with "Repairs required". To detect the regression without spinning
|
||||
// up Office, we read the raw worksheet XML out of the in-memory xlsx
|
||||
// zip and assert that the pane element has both topLeftCell + activePane.
|
||||
func TestXLSX_PaneXML_IsCompleteAndValid(t *testing.T) {
|
||||
_, xlsxBytes, _ := fixturePersonalExport(t)
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(xlsxBytes), int64(len(xlsxBytes)))
|
||||
if err != nil {
|
||||
t.Fatalf("xlsx is not a valid zip: %v", err)
|
||||
}
|
||||
|
||||
// sheet1 = __meta (no pane). sheet2 = projects, sheet3 = deadlines —
|
||||
// both have the frozen header.
|
||||
for _, target := range []string{"xl/worksheets/sheet2.xml", "xl/worksheets/sheet3.xml"} {
|
||||
var body []byte
|
||||
for _, f := range zr.File {
|
||||
if f.Name == target {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", target, err)
|
||||
}
|
||||
body, _ = io.ReadAll(rc)
|
||||
rc.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatalf("missing %s in xlsx zip", target)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, `topLeftCell="A2"`) {
|
||||
t.Errorf("%s pane missing topLeftCell — Excel will prompt 'repairs required'.\nXML: %s",
|
||||
target, s)
|
||||
}
|
||||
if !strings.Contains(s, `activePane="bottomLeft"`) {
|
||||
t.Errorf("%s pane missing activePane — Excel will prompt 'repairs required'.\nXML: %s",
|
||||
target, s)
|
||||
}
|
||||
if !strings.Contains(s, `state="frozen"`) {
|
||||
t.Errorf("%s pane missing state=frozen.\nXML: %s", target, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXLSX_NoExcelizeBuildDefaults guards against any future regression
|
||||
// where a code path writes the .xlsx without first overriding excelize's
|
||||
// canned defaults. Cheap byte-level assertions.
|
||||
func TestXLSX_NoExcelizeBuildDefaults(t *testing.T) {
|
||||
_, xlsxBytes, _ := fixturePersonalExport(t)
|
||||
if bytes.Contains(xlsxBytes, []byte("2006-09-16T00:00:00Z")) {
|
||||
t.Errorf("xlsx leaks excelize default Created/Modified=2006-09-16 — SetDocProps not called?")
|
||||
}
|
||||
if bytes.Contains(xlsxBytes, []byte(`<dc:creator>xuri</dc:creator>`)) {
|
||||
t.Errorf("xlsx leaks excelize default Creator=xuri — SetDocProps not called?")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXLSX_OpensCleanly is the catch-all: round-trip the file through
|
||||
// excelize and confirm sheet names, row counts, and GetDocProps work.
|
||||
func TestXLSX_OpensCleanly(t *testing.T) {
|
||||
_, xlsxBytes, _ := fixturePersonalExport(t)
|
||||
fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReader: %v", err)
|
||||
}
|
||||
defer fl.Close()
|
||||
|
||||
wantSheets := []string{"__meta", "projects", "deadlines"}
|
||||
got := fl.GetSheetList()
|
||||
if len(got) != len(wantSheets) {
|
||||
t.Fatalf("sheet list length = %d, want %d (%v vs %v)", len(got), len(wantSheets), got, wantSheets)
|
||||
}
|
||||
for i, want := range wantSheets {
|
||||
if got[i] != want {
|
||||
t.Errorf("sheet[%d] = %q, want %q", i, got[i], want)
|
||||
}
|
||||
}
|
||||
rows, err := fl.GetRows("projects")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRows(projects): %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("projects rows = %d, want 2 (header + 1)", len(rows))
|
||||
}
|
||||
if rows[0][0] != "id" || rows[1][0] != "u1" || rows[1][2] != "Müller" {
|
||||
t.Errorf("projects rows = %v, want header=[id title umlauts] row=[u1 Acme Müller]", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBundle_ZipEntryMTime_TracksGeneratedAt pins the outer-zip side of
|
||||
// fix #2. Pre-fix every entry was stamped 2000-01-01 (the deterministic
|
||||
// constant) so Windows showed extracted files with a stale Modified
|
||||
// column. Now they carry meta.GeneratedAt.
|
||||
func TestBundle_ZipEntryMTime_TracksGeneratedAt(t *testing.T) {
|
||||
bundle, _, meta := fixturePersonalExport(t)
|
||||
zr, err := zip.NewReader(bytes.NewReader(bundle), int64(len(bundle)))
|
||||
if err != nil {
|
||||
t.Fatalf("bundle not a valid zip: %v", err)
|
||||
}
|
||||
want := meta.GeneratedAt.UTC()
|
||||
for _, f := range zr.File {
|
||||
got := f.Modified.UTC()
|
||||
// Zip stores mtime at 2-second resolution; allow ≤2s drift.
|
||||
diff := got.Sub(want)
|
||||
if diff < -2*time.Second || diff > 2*time.Second {
|
||||
t.Errorf("zip entry %q Modified = %v, want ~%v", f.Name, got, want)
|
||||
}
|
||||
// Specifically catch the old 2000-01-01 stamp.
|
||||
if got.Year() == 2000 && got.Month() == 1 && got.Day() == 1 {
|
||||
t.Errorf("zip entry %q stamped 2000-01-01 — old deterministic-constant regression", f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDumpExport is the developer-convenience hatch. Skipped by default;
|
||||
// set DUMP_EXPORT=1 to write artifacts to /tmp for opening in real Excel.
|
||||
func TestDumpExport(t *testing.T) {
|
||||
if os.Getenv("DUMP_EXPORT") == "" {
|
||||
t.Skip("set DUMP_EXPORT=1 to dump artifacts to /tmp/paliad-export-debug.{zip,xlsx}")
|
||||
}
|
||||
bundle, xlsxBytes, _ := fixturePersonalExport(t)
|
||||
if err := os.WriteFile("/tmp/paliad-export-debug.zip", bundle, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile("/tmp/paliad-export-debug.xlsx", xlsxBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("wrote /tmp/paliad-export-debug.zip (%d bytes) + .xlsx (%d bytes)", len(bundle), len(xlsxBytes))
|
||||
}
|
||||
@@ -12,6 +12,8 @@ func EmailTemplateSampleData(key, lang, slot string) map[string]any {
|
||||
switch key {
|
||||
case EmailTemplateKeyInvitation:
|
||||
return invitationSample(lang)
|
||||
case EmailTemplateKeyAddUserWelcome:
|
||||
return addUserWelcomeSample(lang)
|
||||
case EmailTemplateKeyDeadlineDigest:
|
||||
return deadlineDigestSample(lang, slot)
|
||||
case EmailTemplateKeyBase:
|
||||
@@ -98,6 +100,30 @@ func deadlineDigestSample(lang, slot string) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-223 Slice B (#49) — sample data for the Add-User welcome mail.
|
||||
// The variable contract mirrors what UserService.AdminCreateUserFull
|
||||
// passes to MailService.SendTemplate at runtime.
|
||||
func addUserWelcomeSample(lang string) map[string]any {
|
||||
if lang == "en" {
|
||||
return map[string]any{
|
||||
"InviterName": "Maria Schmidt",
|
||||
"InviterEmail": "maria.schmidt@hlc.com",
|
||||
"ToEmail": "new.colleague@hlc.com",
|
||||
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
|
||||
"BaseURL": "https://paliad.de",
|
||||
"Firm": "HLC",
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"InviterName": "Maria Schmidt",
|
||||
"InviterEmail": "maria.schmidt@hlc.com",
|
||||
"ToEmail": "neu.kollege@hlc.de",
|
||||
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
|
||||
"BaseURL": "https://paliad.de",
|
||||
"Firm": "HLC",
|
||||
}
|
||||
}
|
||||
|
||||
func baseSample(lang string) map[string]any {
|
||||
subj := "Beispielbetreff"
|
||||
if lang == "en" {
|
||||
|
||||
@@ -41,11 +41,17 @@ const (
|
||||
EmailTemplateKeyInvitation = "invitation"
|
||||
EmailTemplateKeyDeadlineDigest = "deadline_digest"
|
||||
EmailTemplateKeyBase = "base"
|
||||
// EmailTemplateKeyAddUserWelcome — t-paliad-223 Slice B (#49). Sent when
|
||||
// a global_admin directly creates a paliad.users + auth.users pair from
|
||||
// /admin/team's "Konto direkt anlegen" form. Carries a Supabase
|
||||
// recovery-link so the new colleague can set their own password.
|
||||
EmailTemplateKeyAddUserWelcome = "add_user_welcome"
|
||||
)
|
||||
|
||||
// CanonicalEmailTemplateKeys is the closed set in canonical display order.
|
||||
var CanonicalEmailTemplateKeys = []string{
|
||||
EmailTemplateKeyInvitation,
|
||||
EmailTemplateKeyAddUserWelcome,
|
||||
EmailTemplateKeyDeadlineDigest,
|
||||
EmailTemplateKeyBase,
|
||||
}
|
||||
@@ -420,6 +426,10 @@ var defaultSubjects = map[string]map[string]string{
|
||||
"de": `[Paliad] {{.InviterName}} lädt Sie zu Paliad ein`,
|
||||
"en": `[Paliad] {{.InviterName}} invites you to Paliad`,
|
||||
},
|
||||
EmailTemplateKeyAddUserWelcome: {
|
||||
"de": `[Paliad] Ihr Paliad-Konto ist bereit`,
|
||||
"en": `[Paliad] Your Paliad account is ready`,
|
||||
},
|
||||
EmailTemplateKeyDeadlineDigest: {
|
||||
"de": digestSubjectDE,
|
||||
"en": digestSubjectEN,
|
||||
|
||||
@@ -21,6 +21,8 @@ func EmailTemplateVariables(key string) []EmailTemplateVariable {
|
||||
switch key {
|
||||
case EmailTemplateKeyInvitation:
|
||||
return invitationVariables
|
||||
case EmailTemplateKeyAddUserWelcome:
|
||||
return addUserWelcomeVariables
|
||||
case EmailTemplateKeyDeadlineDigest:
|
||||
return deadlineDigestVariables
|
||||
case EmailTemplateKeyBase:
|
||||
@@ -51,6 +53,30 @@ var invitationVariables = []EmailTemplateVariable{
|
||||
SampleDE: "HLC", SampleEN: "HLC"},
|
||||
}
|
||||
|
||||
// t-paliad-223 Slice B (#49) — variables consumed by the Add-User welcome
|
||||
// mail. UserService.AdminCreateUserFull populates these at send time.
|
||||
var addUserWelcomeVariables = []EmailTemplateVariable{
|
||||
{Name: ".InviterName", Type: "string",
|
||||
Description: "Anzeigename der/des global_admin, die das Konto angelegt hat.",
|
||||
SampleDE: "Maria Schmidt", SampleEN: "Maria Schmidt"},
|
||||
{Name: ".InviterEmail", Type: "string",
|
||||
Description: "E-Mail-Adresse der/des global_admin.",
|
||||
SampleDE: "maria.schmidt@hlc.com", SampleEN: "maria.schmidt@hlc.com"},
|
||||
{Name: ".ToEmail", Type: "string",
|
||||
Description: "Empfänger:in (E-Mail der neuen Person).",
|
||||
SampleDE: "neu.kollege@hlc.de", SampleEN: "new.colleague@hlc.com"},
|
||||
{Name: ".MagicLink", Type: "string",
|
||||
Description: "Einmaliger Supabase-Recovery-Link zum Passwort-Setzen.",
|
||||
SampleDE: "https://supabase.paliad.de/auth/v1/verify?token=…",
|
||||
SampleEN: "https://supabase.paliad.de/auth/v1/verify?token=…"},
|
||||
{Name: ".BaseURL", Type: "string",
|
||||
Description: "Öffentliche Paliad-URL (PALIAD_BASE_URL).",
|
||||
SampleDE: "https://paliad.de", SampleEN: "https://paliad.de"},
|
||||
{Name: ".Firm", Type: "string",
|
||||
Description: "Firmenname (FIRM_NAME).",
|
||||
SampleDE: "HLC", SampleEN: "HLC"},
|
||||
}
|
||||
|
||||
var deadlineDigestVariables = []EmailTemplateVariable{
|
||||
{Name: ".Slot", Type: "string",
|
||||
Description: "Trigger-Slot: \"morning\" oder \"evening\". Body verwendet typischerweise .IsEvening.",
|
||||
|
||||
@@ -279,7 +279,12 @@ func shouldExcludeAppointmentsForStatus(status DeadlineStatusFilter) bool {
|
||||
// matches a bucket-style deadline status — used to filter the
|
||||
// appointment side when the user clicks a card on the unified events
|
||||
// page. Returns (nil, nil) for non-bucket statuses (pending / all /
|
||||
// upcoming / "" / overdue / completed — those are handled separately).
|
||||
// "" / overdue / completed — those are handled separately).
|
||||
//
|
||||
// DeadlineFilterUpcoming maps to "start_at >= today" so legacy
|
||||
// `?status=upcoming` URLs hide past appointments instead of falling
|
||||
// through to the unfiltered query (m/paliad#54 — the UI option that
|
||||
// surfaced this status has been removed, but bookmarks may persist).
|
||||
func bucketAppointmentWindow(status DeadlineStatusFilter, b deadlineBucketBounds) (*time.Time, *time.Time) {
|
||||
switch status {
|
||||
case DeadlineFilterToday:
|
||||
@@ -293,6 +298,8 @@ func bucketAppointmentWindow(status DeadlineStatusFilter, b deadlineBucketBounds
|
||||
return &b.nextMonday, &t
|
||||
case DeadlineFilterLater:
|
||||
return &b.weekAfter, nil
|
||||
case DeadlineFilterUpcoming:
|
||||
return &b.today, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user