Compare commits
26 Commits
mai/archim
...
mai/darwin
| Author | SHA1 | Date | |
|---|---|---|---|
| aa435e5435 | |||
| 3966394a39 | |||
| 5dacc97a6b | |||
| 15bcba5d7c | |||
| 48f78a713b | |||
| a421bff856 | |||
| 0aa81139a3 | |||
| fbd087e0cd | |||
| 8bac1b4f88 | |||
| 1fcfab7791 | |||
| 12ed8bb8da | |||
| 7654ce6833 | |||
| f3b947e3ad | |||
| f0b08e9d06 | |||
| 760a0de931 | |||
| bc8dc9d048 | |||
| 694c7a53ad | |||
| 81cb89f68e | |||
| a6b2979a94 | |||
| 8f1f88b517 | |||
| d5c80febb1 | |||
| 1765d5e55f | |||
| c85c382b1b | |||
| 7a359989a9 | |||
| 1a8eee2a10 | |||
| 4472faf224 |
@@ -117,7 +117,9 @@ func main() {
|
||||
}
|
||||
|
||||
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
|
||||
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
|
||||
bindingSvc := services.NewCalendarBindingService(pool)
|
||||
targetSvc := services.NewAppointmentTargetService(pool)
|
||||
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc, bindingSvc, targetSvc)
|
||||
// Wire the push hook so user-driven mutations sync to the external
|
||||
// calendar without waiting for the next 60-second tick.
|
||||
appointmentSvc.SetCalDAVPusher(caldavSvc)
|
||||
@@ -143,6 +145,7 @@ func main() {
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
CalDAVBindings: bindingSvc,
|
||||
Rules: rules,
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
@@ -175,14 +178,23 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
# Slice 2 — project-subtree sync export (t-paliad-214)
|
||||
|
||||
Design: archimedes (inventor), 2026-05-20.
|
||||
Task: **t-paliad-214 Slice 2**.
|
||||
Branch: `mai/archimedes/inventor-excel-data` (continuation from Slice 1).
|
||||
Status: READY FOR REVIEW — no code yet, awaiting m go/no-go on §10 open decisions.
|
||||
|
||||
Builds on `docs/design-paliad-data-export-2026-05-19.md` (Slice 1 design + §12 m's decisions) which is now merged + shipped. **This doc covers only what changes for Slice 2.** Cross-reference §2.2 of the Slice 1 doc for the original project-scope sketch — this Slice 2 doc refines it with live-state verification + explicit picks on the questions Slice 1 left open.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premise check (live state, 2026-05-20)
|
||||
|
||||
Verified directly against the youpc Postgres `paliad` schema + branch state.
|
||||
|
||||
**Slice 1 status:** merged at `bf31935` (Slice 1 main) + `f758537` (xlsx fix). System-audit-log table + `ExportService.WritePersonal` + `GET /api/me/export` + Datenexport tab on /settings are live on paliad.de.
|
||||
|
||||
**ExportService is scope-agnostic.** The Slice 1 implementation deliberately threaded the scope-aware predicate through `personalSheetQueries(actorID)`. Slice 2 adds a parallel `projectSheetQueries(actorID, rootProjectID)` and a new handler — the writer + zip-assembly + audit-row plumbing are all reused as-is. No refactor needed before adding scope #2.
|
||||
|
||||
**Subtree size at firm-scale today.** The largest single project subtree in the org (Siemens AG, the only one with a meaningful tree) carries:
|
||||
|
||||
| entity | rows (subtree) |
|
||||
|-------------------|---------------:|
|
||||
| deadlines | 29 |
|
||||
| appointments | 4 |
|
||||
| notes | 1 |
|
||||
| project_events | 80 |
|
||||
|
||||
Smallest non-trivial subtree (Mandant vs Gegner) is 1 + 1 + 1 + 26. **At firm-scale today every project subtree fits comfortably in a sub-megabyte synchronous response.** A "big firm with 1000 active projects each with 50 deadlines" would generate workbooks under 20MB — still synchronously serveable with a 30s watchdog.
|
||||
|
||||
**Migration tracker** at `106_add_madrid_office`; next free = `107`. Slice 2 does not need a new migration (system_audit_log already covers project scope via `scope='project' + scope_root=<root_id>`).
|
||||
|
||||
**Project responsibility enum** (`internal/services/approval_levels.go:29-32`) is the locked set: `lead` / `member` / `observer` / `external`. m's Slice 1 Q2 decision was "any team member with responsibility ∈ {lead, member}" — observers + externals see but don't extract.
|
||||
|
||||
**Visibility predicate.** `visibilityPredicatePositional(alias, $1)` is the canonical RLS-mirror used by every list endpoint. `projectDescendantPredicate(alias)` is the ltree subtree filter for sqlx-named queries. Slice 2 needs both: visibility gates the *caller's right to extract*; descendant filter gates *which rows belong in the export*.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why Slice 2
|
||||
|
||||
Use cases that came up in Slice 1's design pass but couldn't be served by personal scope alone:
|
||||
|
||||
1. **Archival handover.** A matter closes; the partner wants a single artifact representing the entire project tree (Client → Litigation → Patent → Case) to drop into NetDocuments / Highvail.
|
||||
2. **Due-diligence package.** Outside counsel asks for "everything paliad knows about Acme v. Beta". The partner runs the project export, attaches the .zip to an email, done.
|
||||
3. **Per-matter audit response.** Compliance asks "what did paliad record about this proceeding between dates X and Y?" The export carries the audit trail (`project_events` + relevant `system_audit_log` rows) for the subtree, untouched.
|
||||
4. **Inter-firm handover** when a matter migrates to a different firm — the no-lock-in promise from Slice 1's framing.
|
||||
|
||||
Personal scope is *user-centric* ("everything I can see"). Project scope is *matter-centric* ("everything about this matter"). They are complementary, not redundant.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope definition (precise)
|
||||
|
||||
**Root:** the project whose UUID is passed in the URL path (`/api/projects/{id}/export`).
|
||||
|
||||
**Subtree:** root + all descendants via ltree path (`paliad.projects.path @> root.path` or, in the application-layer mirror, `projectDescendantPredicate("p")` bound to `:project_id = root_id`).
|
||||
|
||||
**Caller filter:** Visibility predicate is implicit because the caller must already pass `can_see_project(root_id)` to use the endpoint at all — but we additionally narrow the user-disclosure sheets (see "Restricted users sheet" below).
|
||||
|
||||
Per-sheet inclusion:
|
||||
|
||||
| sheet name | source table(s) | filter |
|
||||
|-----------------------|----------------------------------------------------------|--------|
|
||||
| `projects` | `paliad.projects` | `path @> root.path` (root + descendants) |
|
||||
| `project_teams` | `paliad.project_teams` | `project_id IN subtree` |
|
||||
| `project_partner_units` | `paliad.project_partner_units` | `project_id IN subtree` |
|
||||
| `deadlines` | `paliad.deadlines` | `project_id IN subtree` |
|
||||
| `appointments` | `paliad.appointments` | `project_id IN subtree` |
|
||||
| `parties` | `paliad.parties` | `project_id IN subtree` |
|
||||
| `notes` | `paliad.notes` (4-way polymorphic, resolved to project) | the note's effective project ∈ subtree |
|
||||
| `documents` | `paliad.documents` (metadata only — `ai_extracted` jsonb dropped) | `project_id IN subtree` |
|
||||
| `project_events` | `paliad.project_events` (audit) | `project_id IN subtree` |
|
||||
| `approval_requests` | `paliad.approval_requests` | `project_id IN subtree`, including completed + rejected |
|
||||
| `approval_policies` | `paliad.approval_policies` | union of: (project rows for subtree) + (ancestor rows of root) + (partner-unit defaults attached to any subtree project), with a `source` attribution column |
|
||||
| `checklist_instances` | `paliad.checklist_instances` | `project_id IN subtree` |
|
||||
| `partner_units` | `paliad.partner_units` | only units attached to any subtree project (via `project_partner_units`) |
|
||||
| `partner_unit_members`| `paliad.partner_unit_members` | only members of the attached units |
|
||||
| `users_referenced` | restricted id/email/display_name/office/profession | only users referenced as FK anywhere in the export |
|
||||
| `system_audit_log_subset` | `paliad.system_audit_log` | rows with `scope_root IN subtree` — captures who has exported this subtree (and when) historically |
|
||||
|
||||
**`__meta` sheet** + `__meta.json` + `README.txt`: identical shape to Slice 1, with `scope=project` + `scope_root_id=<root>` + `scope_root_label=<root.title>` added.
|
||||
|
||||
**Reference sheets (`ref__*`).** Same set as Slice 1: `proceeding_types`, `event_types`, `event_categories`, `deadline_rules`, `deadline_concepts`, `courts`, `countries`, `holidays`. Identical bytes across all exports of the same `__meta.generated_at` (reference tables don't change per-project).
|
||||
|
||||
**Explicit exclusions:**
|
||||
|
||||
- `users` (full user roster) — replaced by `users_referenced` (restricted).
|
||||
- `partner_units` (org-wide list) — replaced by attached-only subset.
|
||||
- Personal sidecars (`user_views`, `user_caldav_config`, `user_pinned_projects`, `user_card_layouts`, `paliadin_turns`) — these are per-user, not per-project. Calling user's caldav config + views do NOT belong in a project handover.
|
||||
- `invitations` — org-wide invite pipeline, not project-data.
|
||||
- `auth.*` schema — not paliad's.
|
||||
- Migration shadow tables (`*_pre_NNN`) — Slice 1 same.
|
||||
- Credential-shaped columns — same PII deny-regex as Slice 1.
|
||||
|
||||
---
|
||||
|
||||
## 3. Endpoint shape
|
||||
|
||||
```
|
||||
GET /api/projects/{id}/export
|
||||
```
|
||||
|
||||
**Auth:** existing protected mux middleware (`auth.Middleware` + `auth.WithUserID`).
|
||||
|
||||
**Path param:** `{id}` is the root project's UUID. Service errors → handler maps to 404 (`ErrNotVisible`) / 400 (`ErrInvalidInput`) per the existing `writeServiceError` pattern.
|
||||
|
||||
**Query params:**
|
||||
|
||||
| param | default | values | meaning |
|
||||
|---------------|---------|--------|---------|
|
||||
| `direct_only` | `false` | `0`/`1` | When `1`, narrow the export to the root project only (no descendants). Mirrors the existing `?direct_only=` on `/api/projects/{id}/events`. Default = subtree-inclusive. |
|
||||
| `format` | `zip` | `zip` only (v1) | Reserved for future `xlsx-only` / `json-only` flags. Documented in README only. |
|
||||
|
||||
**Response:**
|
||||
|
||||
- `200 OK`, `Content-Type: application/zip`, `Content-Disposition: attachment; filename="paliad-export-project-<slug>-<ts>.zip"`, `Content-Length: <size>`, `X-Paliad-Export-Audit-Id: <uuid>`.
|
||||
- `403 Forbidden` with `{code, message}` when caller fails the §4 profession + responsibility gate.
|
||||
- `404 Not Found` when `can_see_project(root_id)` returns false.
|
||||
- `500` on internal error (audit row marked `data_export_failed`).
|
||||
- `503` if DB / ExportService is unavailable (same `requireDB` pattern as every other handler).
|
||||
|
||||
**Filename:**
|
||||
|
||||
```
|
||||
paliad-export-project-<slug>-<short-uuid>-<timestamp>.zip
|
||||
slug = slugifyFilename(root.title), capped 40 chars
|
||||
short-uuid = last 8 hex chars of root.id (disambiguator for similar titles)
|
||||
timestamp = YYYY-MM-DDTHHMMZ UTC
|
||||
```
|
||||
|
||||
Example: `paliad-export-project-Siemens-AG-69e2cacb-2026-05-20T1042Z.zip`.
|
||||
|
||||
The short-uuid is new compared to Slice 1's `paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip`. **Reasoning:** two projects can have identical titles (a partner running a long-lived "Standard NDA" project per client would produce filename collisions when archived together). 8 hex chars give 4 billion-class disambiguation space — overkill, but cheap.
|
||||
|
||||
---
|
||||
|
||||
## 4. Permission gate
|
||||
|
||||
Per Slice 1's Q2 lock-in (m's call 2026-05-19), the gate is **purely responsibility-based**, no profession floor:
|
||||
|
||||
```
|
||||
caller MUST satisfy ALL of:
|
||||
(a) auth.UserIDFromContext(r.Context()) — i.e. authenticated
|
||||
(b) can_see_project(root_id) — RLS visibility
|
||||
(c) EXISTS (paliad.project_teams pt
|
||||
WHERE pt.user_id = caller
|
||||
AND pt.project_id = root_id
|
||||
AND pt.responsibility IN ('lead', 'member'))
|
||||
OR caller is global_admin
|
||||
```
|
||||
|
||||
**Why a `project_teams` direct-membership check (and not effective-role via derivation)?** Derivation grants visibility (you can SEE the project) but not extraction authority. A PA member of an attached Partner Unit who is *derived* into the project via `project_partner_units.derive_grants_authority=true` can approve writes, but extracting the matter file is a different sovereignty axis — partner & lead/member explicitly committed to the matter own the data, derived-only viewers shouldn't be able to walk away with the bundle.
|
||||
|
||||
If m wants to loosen this to "anyone who can write is allowed to extract" (i.e. include derived-authority users), it's a one-line change on the SQL. Flagged as Q1 in §10.
|
||||
|
||||
**Observers + Externals:** read-only, no extraction. They can still see the project at runtime; they cannot walk away with the workbook.
|
||||
|
||||
**Global admins:** can extract anything anywhere — same as `/admin/*`. The audit row records this.
|
||||
|
||||
**Edge case — caller is on the root's team but not on a descendant's team.** Still allowed — the gate is at the *root*, not per-descendant. This mirrors how `can_see_project` extends visibility down the tree once you're on any ancestor. Pulling-the-tree from the root is the whole point.
|
||||
|
||||
---
|
||||
|
||||
## 5. Reused vs new code
|
||||
|
||||
What gets reused from Slice 1 (zero changes):
|
||||
|
||||
- `ExportService.writeBundle(ctx, w, sheets, &meta)` — scope-agnostic.
|
||||
- `buildXLSX`, `buildJSON`, `buildCSV`, `buildREADME`, `metaToKeyValueRows`, `byteBuf`.
|
||||
- `formatCellValue` — value coercion.
|
||||
- `piiColumnDenyRegex` + per-sheet `DropColumns` mechanism.
|
||||
- `WriteAuditRow` / `PatchAuditRowSuccess` / `PatchAuditRowFailure` — audit-chain.
|
||||
- `ExportFilename` — adds project-scope-specific behavior (already a switch on scope).
|
||||
- The `__meta` sheet + `__meta.json` shape.
|
||||
- The 30s context watchdog from Slice 1's handler.
|
||||
|
||||
What's new:
|
||||
|
||||
1. **`projectSheetQueries(actorID, rootID uuid.UUID, directOnly bool) []sheetQuery`** in `export_service.go` — returns the project-scope sheet registry. ~250 LoC of SQL recipes.
|
||||
2. **`ExportService.WriteProject(ctx, w, spec ExportSpec, directOnly bool) (ExportMeta, error)`** — mirror of `WritePersonal`, calls `writeBundle` with the new sheet set.
|
||||
3. **`handleProjectExport(w, r *http.Request)`** in `internal/handlers/export.go` — handler with the §4 gate. ~80 LoC of route plumbing + auth checks.
|
||||
4. **Route registration** in `handlers.go`:
|
||||
```go
|
||||
protected.HandleFunc("GET /api/projects/{id}/export", handleProjectExport)
|
||||
```
|
||||
5. **UI affordance** on `/projects/{id}` — a "Daten dieses Projekts exportieren" entry in the project's settings menu (the cog icon, or whatever menu the project-detail page already has). Triggers the same transient-`<a download>` pattern as Slice 1.
|
||||
6. **`ExportFilename` extension** — accept the short-uuid + slug. One-line change.
|
||||
|
||||
Estimated total: **~600 LoC backend + ~50 LoC frontend + ~10 i18n keys DE+EN**.
|
||||
|
||||
No new migration (system_audit_log already supports `scope='project'`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge cases
|
||||
|
||||
### 6.1 Cross-project references
|
||||
|
||||
`paliad.projects.counterclaim_of` is a self-FK that can point at a project *outside* the subtree (a counterclaim under one matter referencing the parent matter elsewhere). Two policy options:
|
||||
|
||||
- **Inventor pick: keep the FK column with the foreign UUID; add a warning row in `__meta.warnings` listing every cross-subtree FK so the consumer knows.**
|
||||
Reasoning: silently severing references is the *opposite* of the no-lock-in promise. Importers can choose to keep the reference (resolving via UUID join) or strip it.
|
||||
- Alternative: NULL the column out. Simpler but lossier.
|
||||
|
||||
Same policy applies to any future self-FK column on `projects` or polymorphic FKs that escape the subtree.
|
||||
|
||||
### 6.2 Notes' 4-way polymorphism
|
||||
|
||||
`paliad.notes` has `project_id`, `deadline_id`, `appointment_id`, `project_event_id` — exactly one is non-NULL. To filter, resolve each to its effective `project_id` and intersect with the subtree:
|
||||
|
||||
```sql
|
||||
SELECT * FROM paliad.notes
|
||||
WHERE COALESCE(
|
||||
project_id,
|
||||
(SELECT d.project_id FROM paliad.deadlines d WHERE d.id = notes.deadline_id),
|
||||
(SELECT a.project_id FROM paliad.appointments a WHERE a.id = notes.appointment_id),
|
||||
(SELECT pe.project_id FROM paliad.project_events pe WHERE pe.id = notes.project_event_id)
|
||||
) IN <subtree>
|
||||
```
|
||||
|
||||
Same pattern as Slice 1's personal-scope notes query. No new code.
|
||||
|
||||
### 6.3 Partner-unit data
|
||||
|
||||
`partner_units` is org-wide (11 rows today). `project_partner_units` attaches specific units to specific projects, optionally with `derive_grants_authority=true` to extend approval power. For project export:
|
||||
|
||||
- `project_partner_units` rows for subtree projects → included.
|
||||
- `partner_units` → only the units referenced by those attachments.
|
||||
- `partner_unit_members` → only members of those units.
|
||||
- `partner_unit_events` (audit) → excluded (it's org-meta, not project-data; the user export from Slice 1 already gates this to admin-only).
|
||||
|
||||
This lets a recipient reconstruct "who could approve writes on this matter at the time of export" without dumping the full org chart.
|
||||
|
||||
### 6.4 Approval policies — full chain with attribution
|
||||
|
||||
A project's effective approval policy can come from three sources (per t-paliad-154 design):
|
||||
|
||||
1. Project-row policy on this project.
|
||||
2. Project-row policy on an ancestor.
|
||||
3. Partner-unit-default policy attached to this project.
|
||||
|
||||
For the export, we ship **all three sources** as separate rows in the `approval_policies` sheet, each tagged with a `source` column (`'project'` / `'ancestor'` / `'partner_unit_default'`). The recipient can reconstruct the effective policy by applying the same MAX-of-sources logic the live app uses.
|
||||
|
||||
Without all three sources, an importer asks "why is this approval required?" and has no answer.
|
||||
|
||||
### 6.5 paliadin_turns
|
||||
|
||||
Excluded from project scope. They are user-AI conversations, person-specific, not project-data. (Same hard-exclude as m's Q5 decision for org scope.)
|
||||
|
||||
### 6.6 Caller's `direct_only=true` semantics
|
||||
|
||||
When `?direct_only=1`:
|
||||
|
||||
- `projects` sheet contains exactly one row (the root).
|
||||
- All entity sheets filter by `project_id = root.id` (no IN-subquery).
|
||||
- `project_partner_units` + `partner_units` filter to those attached directly to the root.
|
||||
- Cross-project warnings for descendants don't apply (since descendants aren't in scope).
|
||||
- Filename slug stays unchanged (still derived from root.title).
|
||||
|
||||
Use case: an associate wants just this case's data, not the parent client or sibling matters. Useful for handover of one specific proceeding.
|
||||
|
||||
### 6.7 Concurrent edits during export
|
||||
|
||||
The export runs in a single Postgres transaction (default read-committed isolation). Inserts that land mid-export may or may not appear depending on the snapshot. We don't ship REPEATABLE READ or SERIALIZABLE — at sub-megabyte scope it doesn't matter, and adding transaction-level juggling for a corner case isn't worth the complexity. The `__meta.generated_at` is the snapshot anchor.
|
||||
|
||||
---
|
||||
|
||||
## 7. Audit row shape
|
||||
|
||||
Existing `paliad.system_audit_log` table from Slice 1's mig 102. The Slice 2 handler writes:
|
||||
|
||||
```
|
||||
event_type = 'data_export'
|
||||
actor_id = caller's uuid
|
||||
actor_email = caller's email captured at write time
|
||||
scope = 'project'
|
||||
scope_root = root project's uuid
|
||||
metadata = { "requested_at": "<rfc3339>",
|
||||
"direct_only": false,
|
||||
"root_label": "Siemens AG",
|
||||
"root_path": "00000000_..._.61e3fb9e_..." // ltree path for posterity
|
||||
}
|
||||
```
|
||||
|
||||
On success, `PatchAuditRowSuccess` adds:
|
||||
|
||||
```
|
||||
metadata.row_counts = { "projects": 1, "deadlines": 29, ... }
|
||||
metadata.file_size_bytes = <int>
|
||||
metadata.warnings = [ "subtree references project <uuid> via counterclaim_of",
|
||||
"sheet=foo column=token dropped (PII deny-list)", ... ]
|
||||
metadata.completed_at = "<rfc3339>"
|
||||
```
|
||||
|
||||
On failure, `event_type` flips to `data_export_failed`, `metadata.error = "<stringified error>"`.
|
||||
|
||||
The `system_audit_log` already surfaces on `/admin/audit-log` (6th union branch added in Slice 1). Project leads will see the export rows for *their* projects (because `scope_root` is forwarded as `project_id` in the union projection). Global admins see everything.
|
||||
|
||||
---
|
||||
|
||||
## 8. Trade-offs flagged
|
||||
|
||||
1. **Synchronous-only for now.** A pathological 1M-row subtree would block a request goroutine for >30s; the watchdog kicks in and the user gets a 503. We could lift to async (Slice 3 territory) when this actually happens. Not now.
|
||||
2. **Reference data ships with every project export.** ~1000 rows of `deadline_rules` + `event_types` + … = ~70KB compressed in every workbook. Acceptable cost for self-interpretability. A later optimization could split reference into a separate `paliad-reference-snapshot.zip` and have the project export `README` link to it.
|
||||
3. **Cross-subtree FK retention adds a warning surface.** Recipients of an export with cross-subtree counterclaim_of refs see warnings in `__meta` but no resolution path. That's correct behavior — but future "diff two exports" tooling will need to handle FK-to-non-present-row gracefully. Slice 6+ concern, not blocking.
|
||||
4. **The §4 gate is stricter than visibility.** A derived-only user can `GET /api/projects/{id}` but not `GET /api/projects/{id}/export`. They'll see a 403. Worth surfacing in the UI as a tooltip: "Datenexport ist nur Team-Mitgliedern (Lead / Member) vorbehalten." Otherwise users hit the 403 and don't know why.
|
||||
5. **`direct_only` is a power-user knob.** No UI for it in v1 — only accessible via query param. Documented in `README.txt` only. Avoids a confusing toggle on the export menu when 90% of exports want the subtree.
|
||||
6. **No streaming.** We buffer the whole bundle in memory before sending headers (so audit-row patch + `Content-Length` can be set before flush). At firm-scale today this is sub-megabyte. At firm-scale-100x this would still fit; at firm-scale-10000x we'd need to switch to chunked + skip the precise `Content-Length`.
|
||||
7. **`approval_policies` triple-source carries some redundancy.** A project with no own policy + an ancestor policy will show one row tagged `source='ancestor'`. A project with both will show two rows (one per source) with separate `required_role` values. Slightly more rows but it makes the workbook honest about provenance.
|
||||
|
||||
---
|
||||
|
||||
## 9. Slice scope vs deferred
|
||||
|
||||
**v1 (this slice ships):**
|
||||
|
||||
- `GET /api/projects/{id}/export` with `?direct_only=` query param.
|
||||
- UI affordance on `/projects/{id}` cog menu.
|
||||
- Subtree-inclusive xlsx + JSON + CSV bundle.
|
||||
- All §2 sheets including reference + restricted users + partner-unit subset.
|
||||
- Audit row in `system_audit_log` with row_counts + warnings.
|
||||
|
||||
**Deferred to Slice 3 (org export, async):**
|
||||
|
||||
- Async with job-tracking + on-disk artifact.
|
||||
- Cleanup goroutine + retention env.
|
||||
- Scope=`org` sheet registry (full schema dump).
|
||||
|
||||
**Deferred to later slices (no change from Slice 1's plan):**
|
||||
|
||||
- Slice 4 — scheduled exports.
|
||||
- Slice 5 — API ergonomics (PATs).
|
||||
- Slice 6 — DSR helper UI.
|
||||
- Slice 7 — document binary inclusion.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open decisions for m
|
||||
|
||||
Per the head's instruction (2026-05-20 brief): **NO AskUserQuestion this round.** Head batches m's picks across 4 inventors today. These are listed for m to ratify in one combined session.
|
||||
|
||||
Each item: inventor pick first, alternative(s) after, with reasoning.
|
||||
|
||||
### Q1 — Authority gate: responsibility-only (lead/member) or include derived-authority users?
|
||||
|
||||
**Inventor pick: responsibility ∈ {lead, member} only.** A direct team commitment is the sovereignty axis for extraction. Derived-via-partner-unit users have approval authority but aren't matter owners.
|
||||
|
||||
Alternative: union `(responsibility ∈ {lead, member})` with `(EffectiveProjectRole returns DerivedPeer)`. Slightly broader; lets a PA on the Munich Lit unit extract every Munich Lit matter they're derived into.
|
||||
|
||||
This is the question Slice 1's Q2 locked at the surface level ("any team member with responsibility ∈ {lead, member}") but didn't address the derivation interaction. Confirming here.
|
||||
|
||||
### Q2 — `direct_only` query param: ship in v1 or defer?
|
||||
|
||||
**Inventor pick: ship in v1 as a query-only knob, no UI.** It's a one-line code path (predicate switch); deferring forces a follow-up slice for a power-user need.
|
||||
|
||||
Alternative: defer; v1 is subtree-always. Marginal UI simplicity gain (no `?direct_only=` mention in `README.txt`). Costs: future support tickets ("how do I export just this one case?").
|
||||
|
||||
### Q3 — Cross-subtree FK handling: keep with warning or NULL out?
|
||||
|
||||
**Inventor pick: keep the FK column, add a warning row in `__meta`.** Preserves the no-lock-in promise (an importer can choose to keep or strip the reference). NULL-ing is silent data loss.
|
||||
|
||||
Alternative: NULL the column on export. Simpler workbook; rejects "keep references for integrity" use case.
|
||||
|
||||
### Q4 — `approval_policies` sheet: include all 3 source-attributed rows, or just project rows?
|
||||
|
||||
**Inventor pick: all 3 sources, each tagged with `source` column.** A recipient needs to know "why is this approval required" without re-running paliad's MAX-resolver. Slice 1's design §2.2 already proposed this; Slice 2 lands it.
|
||||
|
||||
Alternative: project-row policies only. Recipient sees `required_role=NULL` and has no recourse to discover the ancestor / partner-unit-default policy that actually applies.
|
||||
|
||||
### Q5 — Filename short-uuid disambiguator: include 8-hex-suffix or just slug?
|
||||
|
||||
**Inventor pick: include short-uuid suffix.** Two projects with identical titles (common: "Standard NDA" per client) would otherwise produce filename collisions when archived together. 4 billion-class disambiguation is cheap.
|
||||
|
||||
Alternative: just the title slug. Cleaner-looking filename; collision-risk per long-lived firm.
|
||||
|
||||
### Q6 — System audit row: include the project's ltree path in metadata?
|
||||
|
||||
**Inventor pick: yes, include `metadata.root_path`.** The audit row outlives the project deletion; preserving the path lets a future audit query reconstruct ancestry even after the matter is closed.
|
||||
|
||||
Alternative: just `scope_root` (the UUID). Tighter audit row; ancestry recoverable only while the project still exists.
|
||||
|
||||
### Q7 — 403 messaging: bilingual or English only?
|
||||
|
||||
**Inventor pick: bilingual.** Paliad is German-first; the gate copy needs both languages. The pattern matches `mapApprovalError` (handlers/projects.go:96-101) which already emits bilingual error text.
|
||||
|
||||
Alternative: English. Smaller code; misaligned with paliad's product language.
|
||||
|
||||
---
|
||||
|
||||
## 11. Recommended implementer
|
||||
|
||||
Continuity matters here. Slice 1's writer abstraction is mine; Slice 2 generalises it. Same hands.
|
||||
|
||||
- archimedes (this worker) for the backend + UI + tests.
|
||||
- Fresh Sonnet coder is OK but would re-discover the writer-abstraction seams.
|
||||
|
||||
**NOT cronus** per memory directive 2026-05-06 (retired from paliad).
|
||||
|
||||
---
|
||||
|
||||
## 12. Adjacent work
|
||||
|
||||
- **Slice 1** is shipped + live on paliad.de (`/api/me/export`).
|
||||
- **Slice 3** (org async) — designed in Slice 1's §7; remains deferred until Slice 2 ships.
|
||||
- **t-paliad-215** (submission generator) — separate workstream; no overlap.
|
||||
- **t-paliad-216** (suggest-changes) — Slice C merged to main; no overlap.
|
||||
- The new `paliad.system_audit_log` table from Slice 1 is the audit substrate; Slice 2 reuses it untouched.
|
||||
|
||||
---
|
||||
|
||||
## 13. References
|
||||
|
||||
- `docs/design-paliad-data-export-2026-05-19.md` — Slice 1 design + §12 m's decisions.
|
||||
- `internal/services/export_service.go` — current ExportService impl (scope-agnostic).
|
||||
- `internal/services/visibility.go` — `visibilityPredicatePositional` + `projectDescendantPredicate`.
|
||||
- `internal/services/approval_levels.go:29-32` — responsibility enum.
|
||||
- `internal/services/team_service.go:47-95` — `AddMember` + `legacyRoleFromResponsibility`.
|
||||
- `internal/handlers/handlers.go` — protected-mux route registration.
|
||||
- `internal/db/migrations/102_system_audit_log.up.sql` — audit table.
|
||||
|
||||
---
|
||||
|
||||
**END OF DESIGN. Status: READY FOR REVIEW.**
|
||||
|
||||
Inventor parks until m's batched picks come back. No code touches the tree from this branch in this shift.
|
||||
@@ -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})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
// t-paliad-216 Slice B — modal for the "Suggest changes" approval action.
|
||||
// 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 of the date-allowlist
|
||||
// fields (per entity_type) AND/OR leaves a free-text note. On submit the
|
||||
// caller POSTs to /api/approval-requests/{id}/suggest-changes, which closes
|
||||
// the OLD row as `changes_requested` and spawns a NEW pending row authored
|
||||
// by the approver carrying counter_payload as its payload.
|
||||
// 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 (v1):
|
||||
// - update-lifecycle only — the suggest_changes button is hidden for
|
||||
// create / complete / delete lifecycles in shape-list.ts, so the modal
|
||||
// never opens on them. If callers somehow trigger it on an unsupported
|
||||
// lifecycle, openApprovalEditModal() resolves with null (cancel) after
|
||||
// surfacing the unsupported-lifecycle copy.
|
||||
// - Hard-coded fields per entity_type. We deliberately don't build a
|
||||
// generic field-editor framework — only two entity_types exist and
|
||||
// both have small fixed allowlists.
|
||||
// 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 original proposed values
|
||||
// preImage: {...}, // pre-mutation values (for diff display)
|
||||
// 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
|
||||
@@ -30,12 +38,25 @@
|
||||
// }
|
||||
|
||||
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 {
|
||||
@@ -43,213 +64,342 @@ export interface ApprovalEditModalResult {
|
||||
note: string;
|
||||
}
|
||||
|
||||
// Per-entity-type editable field allowlist. Matches buildRevertSetClauses
|
||||
// in internal/services/approval_service.go — the server side rejects any
|
||||
// key outside this set anyway. Keeping the UI list in sync is a
|
||||
// safety-vs-confusion trade-off: a stray key here would be silently
|
||||
// dropped server-side, so it's harmless but misleading.
|
||||
const DEADLINE_FIELDS: ReadonlyArray<{ key: string; type: "date" }> = [
|
||||
{ key: "due_date", type: "date" },
|
||||
{ key: "original_due_date", type: "date" },
|
||||
{ key: "warning_date", type: "date" },
|
||||
// FieldSpec — one editable input row. The type determines the <input>
|
||||
// (or <textarea>) shape; getValue / setValue normalise the form-element
|
||||
// value to the server-friendly counter_payload shape.
|
||||
interface FieldSpec {
|
||||
key: string;
|
||||
labelKey: string; // i18n key
|
||||
inputType: "text" | "date" | "datetime-local" | "textarea";
|
||||
// Required = title (NOT NULL on the column). Other fields are nullable;
|
||||
// empty string clears (server's addText helper handles this).
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const DEADLINE_FIELDS: ReadonlyArray<FieldSpec> = [
|
||||
{ key: "title", labelKey: "deadlines.field.title", inputType: "text", required: true },
|
||||
{ key: "due_date", labelKey: "deadlines.field.due", inputType: "date" },
|
||||
{ key: "original_due_date", labelKey: "approvals.suggest.field.original_due_date", inputType: "date" },
|
||||
{ key: "warning_date", labelKey: "approvals.suggest.field.warning_date", inputType: "date" },
|
||||
{ key: "rule_code", labelKey: "approvals.suggest.field.rule_code", inputType: "text" },
|
||||
{ key: "description", labelKey: "approvals.suggest.field.description", inputType: "textarea" },
|
||||
{ key: "notes", labelKey: "deadlines.field.notes", inputType: "textarea" },
|
||||
];
|
||||
|
||||
const APPOINTMENT_FIELDS: ReadonlyArray<{ key: string; type: "datetime-local" }> = [
|
||||
{ key: "start_at", type: "datetime-local" },
|
||||
{ key: "end_at", type: "datetime-local" },
|
||||
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 function openApprovalEditModal(
|
||||
export async function openApprovalEditModal(
|
||||
args: ApprovalEditModalArgs,
|
||||
): Promise<ApprovalEditModalResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (args.lifecycleEvent !== "update") {
|
||||
// Defence-in-depth: shape-list.ts hides the button for non-update
|
||||
// lifecycles, but if some caller bypasses that gate, fail cleanly.
|
||||
window.alert(t("approvals.suggest.unsupported_lifecycle"));
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
if (args.lifecycleEvent !== "update") {
|
||||
window.alert(t("approvals.suggest.unsupported_lifecycle"));
|
||||
return null;
|
||||
}
|
||||
|
||||
document.getElementById("approval-edit-modal")?.remove();
|
||||
const fields = args.entityType === "deadline" ? DEADLINE_FIELDS : APPOINTMENT_FIELDS;
|
||||
const original = (args.payload ?? {}) as Record<string, unknown>;
|
||||
const preImage = (args.preImage ?? {}) as Record<string, unknown>;
|
||||
|
||||
const 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";
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "approval-edit-modal";
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = renderShell(args, fields, original, preImage);
|
||||
document.body.appendChild(overlay);
|
||||
body.appendChild(renderIntro());
|
||||
body.appendChild(renderFieldsSection(fields, original, preImage));
|
||||
|
||||
const close = (result: ApprovalEditModalResult | null) => {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", onKey);
|
||||
resolve(result);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close(null);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
overlay.querySelectorAll("[data-suggest-cancel]").forEach((el) =>
|
||||
el.addEventListener("click", () => close(null)),
|
||||
);
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) close(null);
|
||||
});
|
||||
|
||||
const submitBtn = overlay.querySelector<HTMLButtonElement>("[data-suggest-submit]");
|
||||
const noteEl = overlay.querySelector<HTMLTextAreaElement>("[data-suggest-note]");
|
||||
const inputs = Array.from(
|
||||
overlay.querySelectorAll<HTMLInputElement>("[data-suggest-field]"),
|
||||
);
|
||||
|
||||
const refreshSubmit = () => {
|
||||
if (!submitBtn) return;
|
||||
const dirty = inputs.some((el) => {
|
||||
const orig = formatFieldForInput(original[el.dataset.suggestField || ""]);
|
||||
return el.value !== orig;
|
||||
});
|
||||
const hasNote = !!(noteEl && noteEl.value.trim());
|
||||
submitBtn.disabled = !(dirty || hasNote);
|
||||
submitBtn.title = submitBtn.disabled
|
||||
? t("approvals.suggest.submit_disabled_hint")
|
||||
: "";
|
||||
};
|
||||
inputs.forEach((el) => el.addEventListener("input", refreshSubmit));
|
||||
noteEl?.addEventListener("input", refreshSubmit);
|
||||
refreshSubmit();
|
||||
|
||||
const form = overlay.querySelector<HTMLFormElement>("[data-suggest-form]");
|
||||
form?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
if (submitBtn?.disabled) return;
|
||||
// Build counter_payload from inputs that differ from original.
|
||||
// Fields unchanged stay out of the payload — the server's
|
||||
// buildRevertSetClauses only writes the keys it sees, so we don't
|
||||
// need to send untouched fields.
|
||||
const counterPayload: Record<string, unknown> = {};
|
||||
for (const el of inputs) {
|
||||
const key = el.dataset.suggestField || "";
|
||||
const orig = formatFieldForInput(original[key]);
|
||||
if (el.value !== orig) {
|
||||
counterPayload[key] = formatFieldForServer(el.value, el.type);
|
||||
}
|
||||
// event_type_ids picker (deadline-only) — async because the picker
|
||||
// needs to fetch the firm's event-type catalogue. We attach a host
|
||||
// element synchronously and populate it once the fetch returns.
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let eventTypePickerLoaded = false;
|
||||
if (args.entityType === "deadline") {
|
||||
const pickerSection = renderEventTypePickerSection();
|
||||
body.appendChild(pickerSection.section);
|
||||
void (async () => {
|
||||
try {
|
||||
await fetchEventTypes();
|
||||
eventTypePicker = attachEventTypePicker(pickerSection.host, {
|
||||
initialIDs: (original.event_type_ids as string[] | undefined) ?? [],
|
||||
});
|
||||
eventTypePickerLoaded = true;
|
||||
} catch (_e) {
|
||||
// Fail-soft: leave the section empty; counter still works
|
||||
// without event_type_ids in the payload.
|
||||
pickerSection.host.textContent = t("approvals.suggest.event_type_picker_unavailable");
|
||||
}
|
||||
close({
|
||||
counterPayload,
|
||||
note: (noteEl?.value ?? "").trim(),
|
||||
});
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
// Focus first input (or note if no fields).
|
||||
(inputs[0] ?? noteEl)?.focus();
|
||||
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 renderShell(
|
||||
args: ApprovalEditModalArgs,
|
||||
fields: ReadonlyArray<{ key: string; type: string }>,
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): string {
|
||||
const entityLabel = esc(t(("approvals.entity." + args.entityType) as never));
|
||||
const fieldRows = fields
|
||||
.map((f) => {
|
||||
const label = fieldLabel(args.entityType, f.key);
|
||||
const value = esc(formatFieldForInput(original[f.key]));
|
||||
const preVal = formatFieldForInput(preImage[f.key]);
|
||||
const preHint = preVal
|
||||
? `<span class="suggest-field-prehint">${esc(t("approvals.diff.before"))}: ${esc(preVal)}</span>`
|
||||
: "";
|
||||
return `
|
||||
<label class="suggest-field">
|
||||
<span class="suggest-field-label">${esc(label)}</span>
|
||||
<input type="${esc(f.type)}" data-suggest-field="${esc(f.key)}" value="${value}" />
|
||||
${preHint}
|
||||
</label>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="modal modal-approval-suggest" role="dialog" aria-modal="true" aria-labelledby="approval-suggest-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="approval-suggest-title">${esc(t("approvals.suggest.modal_title"))} — ${entityLabel}</h2>
|
||||
<button type="button" class="modal-close" data-suggest-cancel aria-label="${esc(t("approvals.suggest.cancel"))}">×</button>
|
||||
</header>
|
||||
<form data-suggest-form>
|
||||
<div class="modal-body">
|
||||
<p class="suggest-intro muted">${esc(t("approvals.suggest.intro"))}</p>
|
||||
<div class="suggest-fields">${fieldRows}</div>
|
||||
<label class="suggest-note">
|
||||
<span class="suggest-field-label">${esc(t("approvals.suggest.note_label"))}</span>
|
||||
<textarea data-suggest-note rows="3" placeholder="${esc(t("approvals.suggest.note_placeholder"))}"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-suggest-cancel>${esc(t("approvals.suggest.cancel"))}</button>
|
||||
<button type="submit" class="btn btn-primary" data-suggest-submit disabled>${esc(t("approvals.suggest.submit"))}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
function renderIntro(): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
p.className = "approval-suggest-intro muted";
|
||||
p.textContent = t("approvals.suggest.intro");
|
||||
return p;
|
||||
}
|
||||
|
||||
// fieldLabel — pick the user-facing label for a given (entity_type, key)
|
||||
// tuple. Reuses existing entity-field i18n where it exists so the same
|
||||
// label that's used on the deadline / appointment edit forms also shows
|
||||
// in this modal.
|
||||
function fieldLabel(entityType: string, key: string): string {
|
||||
const lookups: Record<string, string> = {
|
||||
"deadline.due_date": t("deadlines.field.due" as never) || "Fälligkeitsdatum",
|
||||
"deadline.original_due_date": "Ursprüngliches Fälligkeitsdatum",
|
||||
"deadline.warning_date": "Warndatum",
|
||||
"appointment.start_at": t("appointments.field.start" as never) || "Beginn",
|
||||
"appointment.end_at": t("appointments.field.end" as never) || "Ende",
|
||||
function renderFieldsSection(
|
||||
fields: ReadonlyArray<FieldSpec>,
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--editable";
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("approvals.suggest.section.editable");
|
||||
section.appendChild(h);
|
||||
|
||||
for (const f of fields) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-field approval-suggest-field";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = t(f.labelKey as never);
|
||||
wrap.appendChild(label);
|
||||
|
||||
const value = formatFieldForInput(original[f.key], f.inputType);
|
||||
|
||||
let input: HTMLInputElement | HTMLTextAreaElement;
|
||||
if (f.inputType === "textarea") {
|
||||
input = document.createElement("textarea");
|
||||
input.rows = 3;
|
||||
(input as HTMLTextAreaElement).value = value;
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
(input as HTMLInputElement).type = f.inputType;
|
||||
(input as HTMLInputElement).value = value;
|
||||
}
|
||||
input.dataset.suggestField = f.key;
|
||||
input.dataset.suggestOriginal = value;
|
||||
input.dataset.suggestInputType = f.inputType;
|
||||
if (f.required) input.required = true;
|
||||
|
||||
// Wire the <label> to focus the <input> on click.
|
||||
const inputID = `suggest-field-${f.key}`;
|
||||
input.id = inputID;
|
||||
label.setAttribute("for", inputID);
|
||||
|
||||
wrap.appendChild(input);
|
||||
|
||||
// "Vorher" hint when pre_image carries a distinct value for this field.
|
||||
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
|
||||
if (preVal && preVal !== value) {
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "approval-suggest-prehint";
|
||||
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
|
||||
wrap.appendChild(hint);
|
||||
}
|
||||
|
||||
section.appendChild(wrap);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderEventTypePickerSection(): { section: HTMLElement; host: HTMLElement } {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--editable";
|
||||
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("deadlines.field.event_type");
|
||||
section.appendChild(h);
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.className = "approval-suggest-event-type-picker";
|
||||
section.appendChild(host);
|
||||
|
||||
return { section, host };
|
||||
}
|
||||
|
||||
function renderContextSection(
|
||||
args: ApprovalEditModalArgs,
|
||||
original: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--context";
|
||||
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("approvals.suggest.section.context");
|
||||
section.appendChild(h);
|
||||
|
||||
const rows: Array<[string, string]> = [];
|
||||
if (args.projectTitle) {
|
||||
rows.push([t("approvals.suggest.context.project"), args.projectTitle]);
|
||||
}
|
||||
if (args.requesterName) {
|
||||
rows.push([t("approvals.suggest.context.requester"), args.requesterName]);
|
||||
}
|
||||
if (args.requestedAt) {
|
||||
rows.push([t("approvals.suggest.context.requested_at"), formatDateForDisplay(args.requestedAt)]);
|
||||
}
|
||||
// Approval status — entity row's current approval_status (typically
|
||||
// "pending" while the modal is open, but display the requester's
|
||||
// perspective for completeness).
|
||||
const approvalStatus = original.approval_status as string | undefined;
|
||||
if (approvalStatus) {
|
||||
rows.push([
|
||||
t("approvals.suggest.context.approval_status"),
|
||||
t(("approvals.status." + approvalStatus) as never) || approvalStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
section.style.display = "none";
|
||||
return section;
|
||||
}
|
||||
|
||||
const dl = document.createElement("dl");
|
||||
dl.className = "approval-suggest-context-grid";
|
||||
for (const [label, value] of rows) {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = label;
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = value;
|
||||
dl.appendChild(dt);
|
||||
dl.appendChild(dd);
|
||||
}
|
||||
section.appendChild(dl);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderNoteSection(): { section: HTMLElement; textarea: HTMLTextAreaElement } {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--note";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-field approval-suggest-note";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = t("approvals.suggest.note_label");
|
||||
label.setAttribute("for", "suggest-note");
|
||||
wrap.appendChild(label);
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.id = "suggest-note";
|
||||
textarea.rows = 3;
|
||||
textarea.placeholder = t("approvals.suggest.note_placeholder");
|
||||
textarea.dataset.suggestNote = "true";
|
||||
wrap.appendChild(textarea);
|
||||
|
||||
section.appendChild(wrap);
|
||||
return { section, textarea };
|
||||
}
|
||||
|
||||
interface BuildResult {
|
||||
counterPayload: Record<string, unknown>;
|
||||
note: string;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
fieldInputs: ReadonlyArray<HTMLInputElement | HTMLTextAreaElement>,
|
||||
noteEl: HTMLTextAreaElement,
|
||||
original: Record<string, unknown>,
|
||||
eventTypePicker: PickerHandle | null,
|
||||
eventTypePickerLoaded: boolean,
|
||||
): BuildResult {
|
||||
const counterPayload: Record<string, unknown> = {};
|
||||
let dirty = false;
|
||||
|
||||
for (const el of fieldInputs) {
|
||||
const key = el.dataset.suggestField || "";
|
||||
const orig = el.dataset.suggestOriginal || "";
|
||||
const inputType = el.dataset.suggestInputType || "text";
|
||||
if (el.value === orig) continue;
|
||||
counterPayload[key] = formatFieldForServer(el.value, inputType);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (eventTypePicker && eventTypePickerLoaded) {
|
||||
const currentIDs = eventTypePicker.getIDs().slice().sort();
|
||||
const originalIDs = ((original.event_type_ids as string[] | undefined) ?? []).slice().sort();
|
||||
if (currentIDs.length !== originalIDs.length
|
||||
|| currentIDs.some((id, i) => id !== originalIDs[i])) {
|
||||
counterPayload.event_type_ids = currentIDs;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
counterPayload,
|
||||
note: noteEl.value.trim(),
|
||||
dirty,
|
||||
};
|
||||
return lookups[`${entityType}.${key}`] || key;
|
||||
}
|
||||
|
||||
// formatFieldForInput — convert a server-side payload value to the format
|
||||
// the <input> wants. Dates round-trip cleanly as YYYY-MM-DD; datetime-local
|
||||
// wants YYYY-MM-DDTHH:MM. Server returns ISO 8601 / RFC 3339 timestamps,
|
||||
// we trim to the local-input shape.
|
||||
function formatFieldForInput(v: unknown): string {
|
||||
// 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);
|
||||
// Pure date: keep first 10 chars.
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||
// ISO timestamp: keep YYYY-MM-DDTHH:MM (drop seconds + tz).
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/);
|
||||
if (m) return `${m[1]}T${m[2]}`;
|
||||
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 the input element's string value back to
|
||||
// a server-friendly shape. Date inputs send YYYY-MM-DD; datetime-local
|
||||
// sends YYYY-MM-DDTHH:MM (we let the server interpret as local time, same
|
||||
// as the existing entity-edit forms — there's no tz-shift specific to
|
||||
// suggest-changes).
|
||||
// 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 (!value) return null;
|
||||
if (inputType === "date") return value; // YYYY-MM-DD
|
||||
if (inputType === "datetime-local") return value; // YYYY-MM-DDTHH:MM
|
||||
if (inputType === "date" || inputType === "datetime-local") {
|
||||
return value || null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// HTML-escape helper. Local to this module so the modal doesn't bring in a
|
||||
// utility from elsewhere.
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[&<>"]/g, (c) => {
|
||||
switch (c) {
|
||||
case "&": return "&";
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
case '"': return """;
|
||||
default: return c;
|
||||
}
|
||||
});
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
@@ -430,54 +431,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 = "";
|
||||
@@ -648,21 +616,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);
|
||||
|
||||
@@ -911,6 +911,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",
|
||||
@@ -1685,6 +1691,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",
|
||||
@@ -2115,6 +2160,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",
|
||||
@@ -2246,6 +2292,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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.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",
|
||||
@@ -3538,6 +3595,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",
|
||||
@@ -4300,6 +4362,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",
|
||||
@@ -4730,6 +4831,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",
|
||||
@@ -4861,6 +4963,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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.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",
|
||||
|
||||
@@ -184,6 +184,9 @@ async function handleSuggestChanges(
|
||||
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) {
|
||||
@@ -192,11 +195,17 @@ async function handleSuggestChanges(
|
||||
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
|
||||
@@ -208,6 +217,9 @@ async function handleSuggestChanges(
|
||||
lifecycleEvent,
|
||||
payload,
|
||||
preImage,
|
||||
projectTitle,
|
||||
requesterName,
|
||||
requestedAt,
|
||||
});
|
||||
if (!result) return; // cancel
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -17,11 +17,21 @@ import {
|
||||
populateCourtPicker,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
// user's chosen date. Cleared whenever the trigger changes (proceeding,
|
||||
// trigger date, flag toggle) so a fresh calc starts unanchored — same
|
||||
// semantic as /tools/fristenrechner.
|
||||
const anchorOverrides = new Map<string, string>();
|
||||
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
@@ -125,10 +135,14 @@ async function doCalc() {
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
flags: readFlags(),
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
@@ -180,8 +194,8 @@ function renderResults(data: DeadlineResponse) {
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { showNotes })
|
||||
: renderTimelineBody(data, { showParty: true, showNotes });
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
@@ -229,7 +243,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.
|
||||
@@ -312,6 +331,21 @@ 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;
|
||||
|
||||
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=");
|
||||
});
|
||||
});
|
||||
@@ -299,6 +299,87 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
${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 {
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -642,11 +642,22 @@ export type I18nKey =
|
||||
| "approvals.status.superseded"
|
||||
| "approvals.subtitle"
|
||||
| "approvals.suggest.cancel"
|
||||
| "approvals.suggest.context.approval_status"
|
||||
| "approvals.suggest.context.project"
|
||||
| "approvals.suggest.context.requested_at"
|
||||
| "approvals.suggest.context.requester"
|
||||
| "approvals.suggest.event_type_picker_unavailable"
|
||||
| "approvals.suggest.field.description"
|
||||
| "approvals.suggest.field.original_due_date"
|
||||
| "approvals.suggest.field.rule_code"
|
||||
| "approvals.suggest.field.warning_date"
|
||||
| "approvals.suggest.intro"
|
||||
| "approvals.suggest.modal_title"
|
||||
| "approvals.suggest.next_request_link"
|
||||
| "approvals.suggest.note_label"
|
||||
| "approvals.suggest.note_placeholder"
|
||||
| "approvals.suggest.section.context"
|
||||
| "approvals.suggest.section.editable"
|
||||
| "approvals.suggest.submit"
|
||||
| "approvals.suggest.submit_disabled_hint"
|
||||
| "approvals.suggest.unsupported_lifecycle"
|
||||
@@ -698,6 +709,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"
|
||||
@@ -879,6 +927,11 @@ export type I18nKey =
|
||||
| "dashboard.deadlines.empty"
|
||||
| "dashboard.deadlines.heading"
|
||||
| "dashboard.greeting.prefix"
|
||||
| "dashboard.inbox.empty"
|
||||
| "dashboard.inbox.entity.appointment"
|
||||
| "dashboard.inbox.entity.deadline"
|
||||
| "dashboard.inbox.full_link"
|
||||
| "dashboard.inbox.heading"
|
||||
| "dashboard.matters.active"
|
||||
| "dashboard.matters.archived"
|
||||
| "dashboard.matters.heading"
|
||||
@@ -1670,6 +1723,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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3882,7 +3882,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;
|
||||
@@ -12202,37 +12372,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;
|
||||
|
||||
5
go.mod
5
go.mod
@@ -4,18 +4,19 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/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
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -57,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
|
||||
@@ -83,9 +84,10 @@ 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
|
||||
@@ -129,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,
|
||||
@@ -155,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +314,11 @@ 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)
|
||||
@@ -354,6 +363,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)
|
||||
|
||||
@@ -24,6 +24,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 +52,7 @@ type dbServices struct {
|
||||
broadcast *services.BroadcastService
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
dashboardLayout *services.DashboardLayoutService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
}
|
||||
|
||||
@@ -425,28 +425,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -436,17 +436,18 @@ func (s *ApprovalService) SuggestChanges(ctx context.Context, requestID, callerI
|
||||
return nil, fmt.Errorf("marshal counter_payload: %w", err)
|
||||
}
|
||||
|
||||
// Validate counter has at least one allowlisted field for the entity
|
||||
// type — otherwise the entity-update below would be a no-op and the
|
||||
// new row would just resubmit the SAME values, which is a degenerate
|
||||
// case we should reject cleanly. Only run this check when the
|
||||
// payload "differs" (i.e. caller actually provided something).
|
||||
// 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 := buildRevertSetClauses(old.EntityType, counterPayload); err != nil {
|
||||
// ErrUnknownEntityType wraps "empty pre_image for X" when no
|
||||
// allowlisted key is present. Rebrand as suggestion-input
|
||||
// failure for the handler's 400 mapping.
|
||||
return nil, fmt.Errorf("%w: %v", ErrSuggestionRequiresChange, err)
|
||||
if _, _, err := buildCounterSetClauses(old.EntityType, counterPayload); err != nil {
|
||||
// buildCounterSetClauses already wraps ErrSuggestionRequiresChange
|
||||
// for the "no allowlisted fields" + empty-title cases. Propagate.
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,31 +574,84 @@ func (s *ApprovalService) SuggestChanges(ctx context.Context, requestID, callerI
|
||||
return &newID, nil
|
||||
}
|
||||
|
||||
// applyEntityUpdate writes the allowlisted fields from payload onto the
|
||||
// entity row. Mirrors the write side of write-then-approve (which lives in
|
||||
// DeadlineService / AppointmentService for the user-driven path) — used
|
||||
// by SuggestChanges to apply an approver's counter-proposal back onto the
|
||||
// entity inside the same tx. Reuses buildRevertSetClauses for the
|
||||
// jsonb-key-to-SQL-SET translation so the allowlist is one source of
|
||||
// truth.
|
||||
// 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)
|
||||
}
|
||||
setClauses, args, err := buildRevertSetClauses(entityType, payload)
|
||||
|
||||
// 1. Column-level updates via the counter-allowlist.
|
||||
setClauses, args, err := buildCounterSetClauses(entityType, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setClauses = append(setClauses, "updated_at = now()")
|
||||
args = append(args, entityID)
|
||||
q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
|
||||
entityTableName(entityType), strings.Join(setClauses, ", "), len(args))
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return fmt.Errorf("apply counter payload to entity: %w", err)
|
||||
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
|
||||
@@ -893,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
|
||||
@@ -947,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) {
|
||||
|
||||
@@ -1336,3 +1336,80 @@ func TestApprovalService_SuggestChanges_CounterApproverCannotSelfApprove(t *test
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -352,3 +352,38 @@ func TestTemplateRegistry_Tiers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatentNumberUPC covers the kind-code parenthesisation that UPC
|
||||
// briefs use (t-paliad-215 Slice 2, design §22 Q-S2-4).
|
||||
func TestPatentNumberUPC(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
// EP variants — the common case.
|
||||
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
|
||||
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
|
||||
// DE national number with kind code.
|
||||
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
|
||||
// No kind code → pass-through unchanged.
|
||||
{"EP 1 234 567", "EP 1 234 567"},
|
||||
// Leading + trailing whitespace trimmed.
|
||||
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
|
||||
// Empty input.
|
||||
{"", ""},
|
||||
// Slash-separated forms (WO publication numbers) don't match
|
||||
// the kind-code shape → pass through.
|
||||
{"WO/2023/123456", "WO/2023/123456"},
|
||||
// Two-digit kind code (e.g. B12) doesn't match the single-digit
|
||||
// pattern; pass through. This is intentional — real EP kind
|
||||
// codes are single-letter + single-digit.
|
||||
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := patentNumberUPC(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -264,6 +265,12 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
|
||||
bag["project.case_number"] = derefString(p.CaseNumber)
|
||||
bag["project.court"] = derefString(p.Court)
|
||||
bag["project.patent_number"] = derefString(p.PatentNumber)
|
||||
// project.patent_number_upc is the UPC-brief convention — kind code
|
||||
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
|
||||
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
|
||||
// kind code is present so the lawyer's draft never sees a worse
|
||||
// number than the source value.
|
||||
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
|
||||
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
|
||||
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
|
||||
bag["project.our_side"] = derefString(p.OurSide)
|
||||
@@ -482,3 +489,47 @@ func legalSourcePretty(src, lang string) string {
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// patentNumberKindCodeRegex matches a trailing kind code on a patent
|
||||
// number: a whitespace-separated single uppercase letter followed by
|
||||
// a single digit (B1, A1, A2, B2, B9, C1, T2, U1, …). Capturing
|
||||
// groups split the base from the kind code so the formatter can
|
||||
// parenthesise the kind without touching the rest of the number.
|
||||
var patentNumberKindCodeRegex = regexp.MustCompile(`^(.*?)\s+([A-Z]\d)$`)
|
||||
|
||||
// patentNumberUPC reformats a patent number from the DE convention
|
||||
// ("EP 1 234 567 B1") to the UPC-brief convention
|
||||
// ("EP 1 234 567 (B1)"). The kind code is parenthesised; everything
|
||||
// else is preserved verbatim. Numbers without a recognised trailing
|
||||
// kind code pass through unchanged so a lawyer's draft never sees a
|
||||
// number worse than the source value.
|
||||
//
|
||||
// Recognised inputs:
|
||||
//
|
||||
// "EP 1 234 567 B1" → "EP 1 234 567 (B1)"
|
||||
// "EP 4 056 049 A1" → "EP 4 056 049 (A1)"
|
||||
// "DE 10 2020 123 456 A1" → "DE 10 2020 123 456 (A1)"
|
||||
// " EP 1 234 567 B1 " → "EP 1 234 567 (B1)" (trimmed)
|
||||
//
|
||||
// Pass-through:
|
||||
//
|
||||
// "EP 1 234 567" → "EP 1 234 567"
|
||||
// "WO/2023/123456" → "WO/2023/123456" (no kind code shape)
|
||||
// "" → ""
|
||||
//
|
||||
// Pure function; unit-tested in submission_vars_test.go.
|
||||
func patentNumberUPC(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if m := patentNumberKindCodeRegex.FindStringSubmatch(s); m != nil {
|
||||
base := strings.TrimSpace(m[1])
|
||||
kind := m[2]
|
||||
if base == "" {
|
||||
return s
|
||||
}
|
||||
return base + " (" + kind + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
126
internal/services/target_service.go
Normal file
126
internal/services/target_service.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// AppointmentTargetService — CRUD on paliad.appointment_caldav_targets.
|
||||
//
|
||||
// Each row is the per-(appointment, binding) push state: the caldav_uid
|
||||
// PUT into that binding's calendar (canonical per Appointment) plus the
|
||||
// ETag returned by the server on the last successful PUT. Replaces the
|
||||
// scalar paliad.appointments.caldav_uid / caldav_etag columns for the
|
||||
// post-Slice-2a sync engine; those scalars stay populated for back-compat
|
||||
// through Slice 4.
|
||||
type AppointmentTargetService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewAppointmentTargetService(db *sqlx.DB) *AppointmentTargetService {
|
||||
return &AppointmentTargetService{db: db}
|
||||
}
|
||||
|
||||
const targetColumns = `appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at`
|
||||
|
||||
// UpsertAfterPush records the result of a successful PUT to the binding's
|
||||
// calendar. Called by CalDAVService.pushAll after each PUT.
|
||||
func (s *AppointmentTargetService) UpsertAfterPush(ctx context.Context, appointmentID, bindingID uuid.UUID, uid, etag string) error {
|
||||
var etagPtr *string
|
||||
if etag != "" {
|
||||
etagPtr = &etag
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO paliad.appointment_caldav_targets
|
||||
(appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (appointment_id, binding_id)
|
||||
DO UPDATE SET caldav_uid = EXCLUDED.caldav_uid,
|
||||
caldav_etag = EXCLUDED.caldav_etag,
|
||||
last_pushed_at = NOW()`,
|
||||
appointmentID, bindingID, uid, etagPtr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert caldav target: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindByUIDAndBinding returns the target row matching this (uid, binding)
|
||||
// pair, or nil when no such row exists.
|
||||
func (s *AppointmentTargetService) FindByUIDAndBinding(ctx context.Context, uid string, bindingID uuid.UUID) (*models.AppointmentCalDAVTarget, error) {
|
||||
var t models.AppointmentCalDAVTarget
|
||||
err := s.db.GetContext(ctx, &t,
|
||||
`SELECT `+targetColumns+`
|
||||
FROM paliad.appointment_caldav_targets
|
||||
WHERE caldav_uid = $1 AND binding_id = $2`, uid, bindingID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find target by uid+binding: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// ListForBinding returns every target row attached to this binding.
|
||||
// Used by the pull-reconciliation pass to detect remote deletions.
|
||||
func (s *AppointmentTargetService) ListForBinding(ctx context.Context, bindingID uuid.UUID) ([]models.AppointmentCalDAVTarget, error) {
|
||||
rows := []models.AppointmentCalDAVTarget{}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+targetColumns+`
|
||||
FROM paliad.appointment_caldav_targets
|
||||
WHERE binding_id = $1`, bindingID); err != nil {
|
||||
return nil, fmt.Errorf("list targets for binding: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DeleteByAppointmentAndBinding removes one specific target row.
|
||||
// Used after a successful remote DELETE.
|
||||
func (s *AppointmentTargetService) DeleteByAppointmentAndBinding(ctx context.Context, appointmentID, bindingID uuid.UUID) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.appointment_caldav_targets
|
||||
WHERE appointment_id = $1 AND binding_id = $2`, appointmentID, bindingID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete target: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StaleForBinding returns target rows whose appointment_id is no longer
|
||||
// in the in-scope set. Used by the post-pull cleanup pass to delete
|
||||
// appointments that left the binding's scope (e.g. project unshared,
|
||||
// scope_kind PATCHed). currentAppointmentIDs may be empty — in that
|
||||
// case every target row is considered stale.
|
||||
func (s *AppointmentTargetService) StaleForBinding(ctx context.Context, bindingID uuid.UUID, currentAppointmentIDs []uuid.UUID) ([]models.AppointmentCalDAVTarget, error) {
|
||||
rows := []models.AppointmentCalDAVTarget{}
|
||||
if len(currentAppointmentIDs) == 0 {
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+targetColumns+`
|
||||
FROM paliad.appointment_caldav_targets
|
||||
WHERE binding_id = $1`, bindingID); err != nil {
|
||||
return nil, fmt.Errorf("stale-targets all: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT `+targetColumns+`
|
||||
FROM paliad.appointment_caldav_targets
|
||||
WHERE binding_id = ?
|
||||
AND appointment_id NOT IN (?)`, bindingID, currentAppointmentIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stale-targets prepare: %w", err)
|
||||
}
|
||||
query = s.db.Rebind(query)
|
||||
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("stale-targets exec: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
219
internal/services/widget_catalog.go
Normal file
219
internal/services/widget_catalog.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package services
|
||||
|
||||
// Widget catalog for the configurable dashboard (t-paliad-219).
|
||||
//
|
||||
// Design: docs/design-dashboard-configurable-2026-05-20.md §4 (catalog) and
|
||||
// §18 Note B (settings schema).
|
||||
//
|
||||
// The catalog is the source of truth for which widgets a user can pick.
|
||||
// Adding a new widget = add a WidgetKey const + append a WidgetDef in
|
||||
// WidgetCatalog. Frontend has its own mirror in
|
||||
// frontend/src/client/widgets/registry.ts; the two must stay in sync.
|
||||
//
|
||||
// Versioning rule (design §10): unknown keys in a user's saved layout are
|
||||
// dropped silently at read time; write paths validate against the catalog.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// WidgetKey is the catalog identifier for a single widget.
|
||||
type WidgetKey string
|
||||
|
||||
const (
|
||||
WidgetDeadlineSummary WidgetKey = "deadline-summary"
|
||||
WidgetMatterSummary WidgetKey = "matter-summary"
|
||||
WidgetUpcomingDeadlines WidgetKey = "upcoming-deadlines"
|
||||
WidgetUpcomingAppointments WidgetKey = "upcoming-appointments"
|
||||
WidgetInlineAgenda WidgetKey = "inline-agenda"
|
||||
WidgetRecentActivity WidgetKey = "recent-activity"
|
||||
WidgetInboxApprovals WidgetKey = "inbox-approvals"
|
||||
WidgetPinnedProjects WidgetKey = "pinned-projects"
|
||||
)
|
||||
|
||||
// KnownWidgetKeys is the canonical order used when seeding the factory
|
||||
// default layout. New entries land at the bottom by default.
|
||||
var KnownWidgetKeys = []WidgetKey{
|
||||
WidgetDeadlineSummary,
|
||||
WidgetMatterSummary,
|
||||
WidgetUpcomingDeadlines,
|
||||
WidgetUpcomingAppointments,
|
||||
WidgetInlineAgenda,
|
||||
WidgetRecentActivity,
|
||||
WidgetInboxApprovals,
|
||||
// WidgetPinnedProjects ships in Slice C (catalog expansion) — not in
|
||||
// the Slice A1 baseline. Keep the const above for forward-compat;
|
||||
// omit from KnownWidgetKeys until the widget module lands.
|
||||
}
|
||||
|
||||
// WidgetSettingsSchema declares which knobs a widget exposes. nil = no
|
||||
// per-widget settings (the gear icon is hidden in edit mode).
|
||||
type WidgetSettingsSchema struct {
|
||||
// CountOptions lists permitted "count" values. Empty = no count knob.
|
||||
CountOptions []int
|
||||
// HorizonOptions lists permitted "horizon_days" values. Empty = no
|
||||
// horizon knob.
|
||||
HorizonOptions []int
|
||||
// CountAllowsAll is true when "all" is a legal value for count
|
||||
// (rendered as the literal -1 in the JSON). pinned-projects uses this.
|
||||
CountAllowsAll bool
|
||||
}
|
||||
|
||||
// Validate enforces the schema against a raw settings blob. nil schema
|
||||
// rejects any non-empty settings; empty settings always pass.
|
||||
func (sch *WidgetSettingsSchema) Validate(raw json.RawMessage) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
if sch == nil {
|
||||
return fmt.Errorf("%w: widget has no settings; got %s", ErrInvalidInput, string(raw))
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Count *int `json:"count,omitempty"`
|
||||
HorizonDays *int `json:"horizon_days,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return fmt.Errorf("%w: widget settings decode: %v", ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
if parsed.Count != nil {
|
||||
if len(sch.CountOptions) == 0 {
|
||||
return fmt.Errorf("%w: widget has no count knob", ErrInvalidInput)
|
||||
}
|
||||
if !(sch.CountAllowsAll && *parsed.Count == -1) && !slices.Contains(sch.CountOptions, *parsed.Count) {
|
||||
return fmt.Errorf("%w: count %d not in %v", ErrInvalidInput, *parsed.Count, sch.CountOptions)
|
||||
}
|
||||
}
|
||||
if parsed.HorizonDays != nil {
|
||||
if len(sch.HorizonOptions) == 0 {
|
||||
return fmt.Errorf("%w: widget has no horizon knob", ErrInvalidInput)
|
||||
}
|
||||
if !slices.Contains(sch.HorizonOptions, *parsed.HorizonDays) {
|
||||
return fmt.Errorf("%w: horizon_days %d not in %v", ErrInvalidInput, *parsed.HorizonDays, sch.HorizonOptions)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WidgetDef is one entry in the catalog. Title/description fields are the
|
||||
// translation-key seeds; frontend resolves them via the i18n registry.
|
||||
type WidgetDef struct {
|
||||
Key WidgetKey `json:"key"`
|
||||
TitleDE string `json:"title_de"`
|
||||
TitleEN string `json:"title_en"`
|
||||
DescriptionDE string `json:"description_de"`
|
||||
DescriptionEN string `json:"description_en"`
|
||||
DefaultVisible bool `json:"default_visible"`
|
||||
DefaultCount *int `json:"default_count,omitempty"`
|
||||
DefaultHorizon *int `json:"default_horizon_days,omitempty"`
|
||||
Settings *WidgetSettingsSchema `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// WidgetCatalog returns the v1 catalog. Returned by value (small struct
|
||||
// slice) so callers can freely append i18n overrides for the wire format.
|
||||
func WidgetCatalog() []WidgetDef {
|
||||
listCounts := []int{1, 3, 5, 10, 20}
|
||||
listHorizon := []int{7, 14, 30, 60}
|
||||
inboxCounts := []int{1, 3, 5, 10}
|
||||
agendaHorizon := []int{14, 30, 60}
|
||||
|
||||
tenDefault := 10
|
||||
threeDefault := 3
|
||||
thirtyDefault := 30
|
||||
|
||||
return []WidgetDef{
|
||||
{
|
||||
Key: WidgetDeadlineSummary,
|
||||
TitleDE: "Fristen auf einen Blick",
|
||||
TitleEN: "Deadlines at a glance",
|
||||
DescriptionDE: "Ampel-Karten für überfällige, heutige und kommende Fristen.",
|
||||
DescriptionEN: "Traffic-light cards for overdue, today, and upcoming deadlines.",
|
||||
DefaultVisible: true,
|
||||
},
|
||||
{
|
||||
Key: WidgetMatterSummary,
|
||||
TitleDE: "Meine Akten",
|
||||
TitleEN: "My Matters",
|
||||
DescriptionDE: "Aktiv-, archiviert- und Gesamtzahl deiner sichtbaren Akten.",
|
||||
DescriptionEN: "Active, archived and total counts of your visible matters.",
|
||||
DefaultVisible: true,
|
||||
},
|
||||
{
|
||||
Key: WidgetUpcomingDeadlines,
|
||||
TitleDE: "Kommende Fristen",
|
||||
TitleEN: "Upcoming deadlines",
|
||||
DescriptionDE: "Liste der nächsten Fristen — Anzahl und Zeitraum konfigurierbar.",
|
||||
DescriptionEN: "List of upcoming deadlines — count and horizon configurable.",
|
||||
DefaultVisible: true,
|
||||
DefaultCount: &tenDefault,
|
||||
DefaultHorizon: &thirtyDefault,
|
||||
Settings: &WidgetSettingsSchema{
|
||||
CountOptions: listCounts,
|
||||
HorizonOptions: listHorizon,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: WidgetUpcomingAppointments,
|
||||
TitleDE: "Kommende Termine",
|
||||
TitleEN: "Upcoming appointments",
|
||||
DescriptionDE: "Liste der nächsten Termine — Anzahl und Zeitraum konfigurierbar.",
|
||||
DescriptionEN: "List of upcoming appointments — count and horizon configurable.",
|
||||
DefaultVisible: true,
|
||||
DefaultCount: &tenDefault,
|
||||
DefaultHorizon: &thirtyDefault,
|
||||
Settings: &WidgetSettingsSchema{
|
||||
CountOptions: listCounts,
|
||||
HorizonOptions: listHorizon,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: WidgetInlineAgenda,
|
||||
TitleDE: "Agenda",
|
||||
TitleEN: "Agenda",
|
||||
DescriptionDE: "30-Tage-Agenda mit Fristen und Terminen kombiniert.",
|
||||
DescriptionEN: "30-day agenda combining deadlines and appointments.",
|
||||
DefaultVisible: true,
|
||||
DefaultHorizon: &thirtyDefault,
|
||||
Settings: &WidgetSettingsSchema{
|
||||
HorizonOptions: agendaHorizon,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: WidgetRecentActivity,
|
||||
TitleDE: "Letzte Aktivität",
|
||||
TitleEN: "Recent activity",
|
||||
DescriptionDE: "Verlauf der letzten Ereignisse in deinen sichtbaren Akten.",
|
||||
DescriptionEN: "Recent events across your visible matters.",
|
||||
DefaultVisible: true,
|
||||
DefaultCount: &tenDefault,
|
||||
Settings: &WidgetSettingsSchema{
|
||||
CountOptions: listCounts,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: WidgetInboxApprovals,
|
||||
TitleDE: "Offene Freigaben",
|
||||
TitleEN: "Open approvals",
|
||||
DescriptionDE: "Deine offenen Freigaben mit Anzahl und einer kurzen Liste.",
|
||||
DescriptionEN: "Your open approval requests with count and a short list.",
|
||||
DefaultVisible: true,
|
||||
DefaultCount: &threeDefault,
|
||||
Settings: &WidgetSettingsSchema{
|
||||
CountOptions: inboxCounts,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LookupWidgetDef returns the catalog entry for a key, or false if unknown.
|
||||
func LookupWidgetDef(key WidgetKey) (WidgetDef, bool) {
|
||||
for _, def := range WidgetCatalog() {
|
||||
if def.Key == key {
|
||||
return def, true
|
||||
}
|
||||
}
|
||||
return WidgetDef{}, false
|
||||
}
|
||||
Reference in New Issue
Block a user