docs(audit): product audit + improvement roadmap (t-paliad-015)
Post-Phase-A–J full-product audit: UX, code, content, architecture,
ops. 5 Critical findings (JWT signature bypass, dashboard Termine
leak, parteien/termin delete policy gap, @hoganlovells-only email
gate, CALDAV_ENCRYPTION_KEY missing from compose), 8 Important, 10
Polish, 11 Feature ideas, 14 tech-debt items. Each item has a file
reference and a concrete fix.
Top-two exploit-paths (detailed in §1):
1. internal/auth/auth.go:178 — middleware decodes JWT exp but never
verifies the signature; sub-claim is trusted downstream by every
service. Any authenticated cookie → impersonate any user.
2. internal/services/dashboard_service.go:245 — personal Termine
leaked cross-user on the /dashboard "Kommende Termine" list
(missing created_by filter on the akte_id IS NULL branch).
This commit is contained in:
649
docs/improvement-audit.md
Normal file
649
docs/improvement-audit.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# Paliad — Product Audit & Improvement Roadmap
|
||||
|
||||
Prepared by `cronus` (inventor) on 2026-04-18 for task **t-paliad-015**.
|
||||
Scope: complete code, UX, content, architecture, and ops audit after the
|
||||
17 000-line KanzlAI → Paliad integration (Phases A–J, April 2026).
|
||||
|
||||
Audit posture: first-real-user (HLC patent lawyer, Munich) **and**
|
||||
long-term architect. Items are prioritised and actionable — each has a
|
||||
file reference and a suggested fix. No hour estimates; effort is
|
||||
expressed as **S / M / L** (small / medium / large).
|
||||
|
||||
- **S** = <1 day of focused work; single file or localised change
|
||||
- **M** = multi-file change, migration, or non-trivial design
|
||||
- **L** = new subsystem, cross-cutting rework, or external dep
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the two things to fix today
|
||||
|
||||
1. **The session middleware does not verify JWT signatures** and the Go
|
||||
backend extracts `auth.uid()` from that unverified token to gate every
|
||||
database read and write. A forged cookie with any `sub` and a future
|
||||
`exp` gives an attacker access to any user's Akten, including admin.
|
||||
See **C-1** below. `internal/auth/auth.go:178` +
|
||||
`internal/auth/user.go:60`.
|
||||
2. **The dashboard leaks every user's personal Termine to every other
|
||||
logged-in user** for the next 7 days (title, location, description,
|
||||
start/end). `internal/services/dashboard_service.go:245`. See **C-2**.
|
||||
|
||||
Both are one-file fixes and must ship before this audit reaches a wider
|
||||
pilot group.
|
||||
|
||||
---
|
||||
|
||||
## 1 — Critical (fix immediately)
|
||||
|
||||
### C-1. Session JWTs are not signature-verified
|
||||
|
||||
**File:** `internal/auth/auth.go:178`, `internal/auth/user.go:60`
|
||||
**Severity:** Critical (authZ bypass)
|
||||
**Effort:** M
|
||||
|
||||
`Client.Middleware` accepts the session cookie, parses `exp` via
|
||||
`DecodeJWTExpiry`, and calls `next` if the token is unexpired. The
|
||||
signature is never checked. `WithUserID` then base64-decodes the `sub`
|
||||
claim straight into the request context. `AkteService.GetByID` and every
|
||||
other service trusts that UUID as the authenticated user.
|
||||
|
||||
Exploit: craft any JWT with `exp` in the future and `sub =
|
||||
<admin-user-uuid>`. Set it as the `patholo_session` cookie. The Go
|
||||
backend uses a service-role Postgres connection, so RLS policies do not
|
||||
run and the app-level visibility check sees the forged UUID as a real
|
||||
admin. Effectively: anyone with a Paliad cookie can impersonate anyone.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Fetch the Supabase project's JWT signing key (JWKS endpoint:
|
||||
`${SUPABASE_URL}/auth/v1/.well-known/jwks.json`) or the shared
|
||||
`SUPABASE_JWT_SECRET` and verify the token in `Middleware` before
|
||||
trusting any claim.
|
||||
- Cache the JWKS for 1 hour; rotate on kid mismatch.
|
||||
- `WithUserID` should read the *verified* claims, not re-decode the raw
|
||||
cookie.
|
||||
- Consider `github.com/golang-jwt/jwt/v5` — already idiomatic for Go.
|
||||
|
||||
**Related:** `internal/services/akte_service.go:18-24` explicitly
|
||||
documents that RLS "does not kick in because the backend does not
|
||||
provide a JWT-backed auth.uid()" — so the app-layer predicate is the
|
||||
*only* gate. The JWT must therefore be trusted.
|
||||
|
||||
---
|
||||
|
||||
### C-2. Dashboard leaks every user's personal Termine cross-user
|
||||
|
||||
**File:** `internal/services/dashboard_service.go:232-256`
|
||||
**Severity:** Critical (privacy / confidentiality)
|
||||
**Effort:** S
|
||||
|
||||
```sql
|
||||
WHERE t.start_at >= $4
|
||||
AND t.start_at < ($4 + interval '7 days')
|
||||
AND (t.akte_id IS NULL -- ← ANY personal Termin, any user
|
||||
OR a.firm_wide_visible = true
|
||||
OR a.owning_office = $1
|
||||
OR $2::uuid = ANY (a.collaborators)
|
||||
OR $3 = 'admin')
|
||||
```
|
||||
|
||||
Personal Termine (`akte_id IS NULL`) are creator-only by contract
|
||||
(`TerminService.canSee` enforces `created_by = userID`). The dashboard
|
||||
forgets this filter and returns up to 10 such rows for *any* user —
|
||||
title, location, description, times included.
|
||||
|
||||
In practice an associate's "Bewerbungsgespräch bei Kanzlei X" is visible
|
||||
to partners and vice-versa.
|
||||
|
||||
**Fix:** change the personal-Termin branch to
|
||||
`(t.akte_id IS NULL AND t.created_by = $2::uuid)` — mirrors the already-
|
||||
correct rule in `termin_service.go:103`.
|
||||
|
||||
---
|
||||
|
||||
### C-3. Any user with visibility can delete Akte children (Parteien, Termine)
|
||||
|
||||
**File:**
|
||||
- `internal/services/parteien_service.go:93-111` — Delete has no role gate.
|
||||
- `internal/services/termin_service.go:357-398` — Akte-linked Termine: only
|
||||
personal Termine check creator; Akte-linked pass through with just
|
||||
visibility.
|
||||
|
||||
**Severity:** Critical (data loss; inconsistent with Fristen policy)
|
||||
**Effort:** S
|
||||
|
||||
An associate in London, viewing a Munich firm-wide Akte, can
|
||||
`DELETE /api/parteien/{id}` and erase the Klägerin-record, or delete any
|
||||
hearing from the Akte's calendar. `FristService.Delete` already scopes
|
||||
to `partner|admin` only — the other child services should follow suit.
|
||||
|
||||
**Fix:** require `user.Role in {partner, admin}` (or `created_by =
|
||||
userID`) for delete on `ParteienService` and `TerminService`. Apply the
|
||||
same rule to `Update` on both.
|
||||
|
||||
---
|
||||
|
||||
### C-4. Email gate still pins to `@hoganlovells.com`
|
||||
|
||||
**File:** `internal/handlers/auth.go:113-116`
|
||||
**Severity:** Critical (post-merger lockout)
|
||||
**Effort:** S
|
||||
|
||||
HLC email domains (`@hlc.com`, `@hlc.de`) cannot register or log in.
|
||||
Login-form placeholders still say `name@hoganlovells.com`.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Replace `isHoganLovellsEmail` with an env-configurable whitelist,
|
||||
default `hoganlovells.com,hlc.com,hlc.de` (design §2 already
|
||||
requested this).
|
||||
- Update placeholders in `frontend/src/login.tsx:26,34` + the
|
||||
`login.hint` i18n key (de + en).
|
||||
- Error strings (`"Zugang nur für @hoganlovells.com …"`) should become
|
||||
"… für autorisierte HLC-E-Mail-Adressen."
|
||||
|
||||
---
|
||||
|
||||
### C-5. `CALDAV_ENCRYPTION_KEY` not wired into production compose
|
||||
|
||||
**File:** `docker-compose.yml:4-12`
|
||||
**Severity:** Critical in effect (feature silently disabled)
|
||||
**Effort:** S
|
||||
|
||||
The compose file declares `SUPABASE_URL`, `SUPABASE_ANON_KEY`,
|
||||
`GITEA_TOKEN`, `DATABASE_URL`. It does **not** pass
|
||||
`CALDAV_ENCRYPTION_KEY`. On the Dokploy compose (`Zx147ycurfYagKRl_Zzyo`)
|
||||
this means `cmd/server/main.go:66` logs
|
||||
"CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501" and
|
||||
the entire Termine-sync feature is dead on paliad.de. Users who save
|
||||
their CalDAV settings see a 501 from `/api/caldav-config` and conclude
|
||||
the product is broken.
|
||||
|
||||
**Fix:** add `- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}` (and
|
||||
while we're at it, a placeholder `ANTHROPIC_API_KEY` commented out so
|
||||
Phase H reactivation is one uncomment). Set the actual key on Dokploy.
|
||||
|
||||
---
|
||||
|
||||
## 2 — Important (fix this week)
|
||||
|
||||
### I-1. Dokumente tab on Akten detail is a dead placeholder
|
||||
|
||||
**File:** `frontend/src/akten-detail.tsx:215-222`,
|
||||
`frontend/src/client/i18n.ts:468,1241`
|
||||
**Severity:** Important (visible UI dead-end; leaks internal phase names)
|
||||
**Effort:** S
|
||||
|
||||
The tab is rendered, clickable, and shows the text "Dokumenten-Upload
|
||||
folgt in Phase H." — a strong UX signal that the product is unfinished.
|
||||
Phase H is deferred indefinitely.
|
||||
|
||||
**Fix (pick one):**
|
||||
|
||||
- Hide the Dokumente tab entirely until there is real content — drop it
|
||||
from the VALID_TABS list in `akten-detail.ts:62` and the TSX tabs
|
||||
strip.
|
||||
- Or keep it visible but replace the copy with a neutral "Dokumenten-
|
||||
Upload in Planung" (no phase leak) and a discreet CTA to vote/express
|
||||
interest (write to `paliad_feature_interest`).
|
||||
|
||||
I'd hide it — dead tabs are worse than missing features.
|
||||
|
||||
### I-2. Office labels in German not translated to English
|
||||
|
||||
**File:** `frontend/src/index.tsx:122-128`
|
||||
**Severity:** Important (i18n regression visible on landing page)
|
||||
**Effort:** S
|
||||
|
||||
Only `index.munich` has a `data-i18n` key. Düsseldorf, Hamburg,
|
||||
Amsterdam, London, Paris, **Mailand** render raw German in EN mode.
|
||||
"Mailand" → "Milan" is the most obvious miss; Düsseldorf/London/Paris
|
||||
are correct in both languages but the fact that only one is i18n'd is a
|
||||
consistency bug waiting for the next new office.
|
||||
|
||||
**Fix:** add `index.office.munich|duesseldorf|hamburg|amsterdam|london|
|
||||
paris|milan` keys (de: "München / Düsseldorf / Hamburg / Amsterdam /
|
||||
London / Paris / Mailand"; en: "Munich / Düsseldorf / Hamburg /
|
||||
Amsterdam / London / Paris / Milan").
|
||||
|
||||
### I-3. Gerichtsverzeichnis UPC URLs redirect
|
||||
|
||||
**File:** `internal/handlers/gerichte.go` (45 occurrences of
|
||||
`unifiedpatentcourt.org`, no hyphens)
|
||||
**Severity:** Important (one redirect per link click; flagged as wrong by
|
||||
link-checkers)
|
||||
**Effort:** S
|
||||
|
||||
Canonical UPC website is `https://www.unified-patent-court.org` (with
|
||||
hyphens). The no-hyphen form returns HTTP 403 challenge pages from
|
||||
Cloudflare on naive `curl` but does resolve to the same destination.
|
||||
`internal/handlers/links.go` uses the canonical form — the two files
|
||||
are inconsistent. Pick one. Canonical (hyphenated) is safer.
|
||||
|
||||
**Fix:** sed-replace `unifiedpatentcourt.org` → `unified-patent-court.org`
|
||||
across `gerichte.go` (and re-verify the deep paths still resolve —
|
||||
some `/en/court/court-appeal` style URLs may have moved).
|
||||
|
||||
### I-4. `loadRecentActivity` omits personal Termine audit rows (not a bug yet, but will be)
|
||||
|
||||
**File:** `internal/services/dashboard_service.go:259-287`
|
||||
**Severity:** Medium-Important (correctness risk)
|
||||
**Effort:** S
|
||||
|
||||
`loadRecentActivity` joins `akten_events` → `akten`, so only
|
||||
Akte-attached events appear. That is correct today because personal
|
||||
Termine explicitly skip the audit (per Phase F memory). If a future
|
||||
contributor adds a personal-Termin event type without reading the
|
||||
design, this query will silently drop it — add a comment or switch to
|
||||
`LEFT JOIN` + `WHERE a.id IS NULL OR <visibility>`.
|
||||
|
||||
### I-5. `/api/akten/{id}/events` has no pagination or size limit
|
||||
|
||||
**File:** `internal/services/akte_service.go:368-383`
|
||||
**Severity:** Important (scalability; long-running Akten)
|
||||
**Effort:** M
|
||||
|
||||
`ListEvents` returns every row ever written to `akten_events` for an
|
||||
Akte. A three-year litigation with CalDAV sync and daily notizen edits
|
||||
could easily reach 5–10 k rows. The Verlauf tab will then ship 2 MB of
|
||||
JSON on each tab-click.
|
||||
|
||||
**Fix:** add `?before=<uuid>&limit=50` cursor pagination and render
|
||||
"Load more" in the Verlauf tab. Already noted in the Phase E followup
|
||||
list, never actioned.
|
||||
|
||||
### I-6. Glossar is missing FRAND / SEP / related commercial-patent terms
|
||||
|
||||
**File:** `internal/handlers/glossar.go:33-118` (~86 entries)
|
||||
**Severity:** Important (content gap for the target audience)
|
||||
**Effort:** S
|
||||
|
||||
HLC's patent practice is a heavy SEP/FRAND shop; the glossary has zero
|
||||
entries for: FRAND, SEP, Standard-essentielles Patent, Patentpool, Anti-
|
||||
Anti-Suit Injunction, Injunction gap, Orange-Book-Verfahren,
|
||||
Huawei/ZTE-Verhandlungsmuster, RAND, ETSI IPR Policy, Patent-Hold-up,
|
||||
Patent-Hold-out. These are table-stakes for the intended user.
|
||||
|
||||
**Fix:** add ~12 entries under a new `SEP/FRAND` category (or stretch
|
||||
`Litigation`). Pull definitions from the canonical CJEU/BGH case law.
|
||||
|
||||
### I-7. README is out of date
|
||||
|
||||
**File:** `README.md:30-43,107`
|
||||
**Severity:** Important (onboarding drag)
|
||||
**Effort:** S
|
||||
|
||||
- Migration list stops at 013; 014 (`checklist_instances`) is live.
|
||||
- Line 107 says "Phase I (Notizen) pending — service and UI aren't
|
||||
built yet"; it shipped on `mai/knuth/phase-i-notizen` (commit
|
||||
`5a9f8e5`, 2026-04-17) with full service + handlers + UI.
|
||||
|
||||
**Fix:** refresh the migrations block (`014_checklist_instances …`) and
|
||||
the status paragraph. Phase I ✅ Done, Phase J docs-only done, infra
|
||||
retirement pending.
|
||||
|
||||
### I-8. Legacy "patholo_" names everywhere
|
||||
|
||||
**File:** `internal/auth/auth.go:17-18`, `internal/handlers/links.go:315`,
|
||||
`frontend/src/client/i18n.ts:6` (`STORAGE_KEY = "patholo-lang"`),
|
||||
`frontend/src/client/*.ts` (other localStorage keys),
|
||||
`paliad_link_suggestions` / `paliad_link_feedback` table names (compare
|
||||
vs. the older `patholo_*` references in the code).
|
||||
**Severity:** Important (brand inconsistency; minor confusion)
|
||||
**Effort:** M
|
||||
|
||||
Cookie name `patholo_session`, storage key `patholo-lang`, Supabase
|
||||
tables `patholo_link_suggestions` / `patholo_link_feedback`. Memory
|
||||
notes a deliberate decision to *keep* the cookie name so users aren't
|
||||
logged out — that's fine — but storage keys and table names can migrate
|
||||
without user impact.
|
||||
|
||||
**Fix:** plan a single branch that:
|
||||
|
||||
- Renames `STORAGE_KEY` → `paliad-lang` with a one-shot migration from
|
||||
the old key (read old, write new, delete old) in `i18n.ts` init.
|
||||
- Renames tables to `paliad_link_suggestions` / `paliad_link_feedback`
|
||||
(migration + update callers in `handlers/links.go`).
|
||||
- Leaves the cookie name alone or migrates it with a dual-read grace
|
||||
period.
|
||||
|
||||
---
|
||||
|
||||
## 3 — Polish (nice to have)
|
||||
|
||||
### P-1. HL Intern links are stubs
|
||||
|
||||
`internal/handlers/links.go:206-219` — two entries with `URL: "#"`
|
||||
render as clickable cards that go nowhere.
|
||||
|
||||
**Fix:** either remove the entries until real URLs land, or add a
|
||||
"Coming soon — suggest a URL" flag + disabled styling.
|
||||
|
||||
### P-2. Dashboard says "Meine Mandate" but the nav and URL say "Akten"
|
||||
|
||||
`frontend/src/dashboard.tsx:82`, i18n key `dashboard.matters.heading`.
|
||||
The project's naming convention (CLAUDE.md) mandates **Akten**
|
||||
throughout — the term "Mandate" is explicitly historical. The dashboard
|
||||
heading should read "Meine Akten" / "My Matters".
|
||||
|
||||
**Fix:** change i18n text to "Meine Akten" (DE) and leave
|
||||
"My Matters" (EN). Rename i18n key if desired.
|
||||
|
||||
### P-3. Login page placeholder still says `name@hoganlovells.com`
|
||||
|
||||
Covered by **C-4**. Mentioned again here because the hint
|
||||
"Nur für @hoganlovells.com Adressen." is a polish concern even after
|
||||
the whitelist change — update to "Nur für autorisierte HLC-Adressen."
|
||||
|
||||
### P-4. Landing page footer reads "Hogan Lovells Patent Practice"
|
||||
|
||||
`frontend/src/components/Footer.tsx:7`. Brand reads as *old* on every
|
||||
page. Switch to "HLC Patent Practice" post-merger (or drop the firm
|
||||
name entirely since Paliad is supposed to survive renames).
|
||||
|
||||
### P-5. No explicit empty-state copy on Fristen-Kalender / Termine-Kalender
|
||||
|
||||
Check `frontend/src/fristen-kalender.tsx`, `termine-kalender.tsx`.
|
||||
Calendars with no events render an empty grid — add a subtle "Keine
|
||||
Fristen / Termine im ausgewählten Zeitraum." string.
|
||||
|
||||
### P-6. Office names in `AkteService.isValidOffice` diverge from UI labels
|
||||
|
||||
`akte_service.go:403-408` accepts keys `munich, duesseldorf, hamburg,
|
||||
amsterdam, london, paris, milan`. The `models.go` user has the same
|
||||
list. But the UI (landing page) writes "München", "Düsseldorf",
|
||||
"Mailand". Currently fine because admins edit office via internal role
|
||||
only, but any future user-facing office selector must map label →
|
||||
key — add a single source of truth (`internal/calc/offices.go` or
|
||||
similar: `{key: "munich", labelDE: "München", labelEN: "Munich"}`).
|
||||
|
||||
### P-7. `Glossar` CSVs hard-coded in one file (230 lines)
|
||||
|
||||
Not a bug, but as the list grows toward 150+ terms it wants to move out
|
||||
of Go source into `internal/handlers/glossar_data.go` or a JSON blob in
|
||||
`internal/data/glossar.json`. Easier for non-devs to edit.
|
||||
|
||||
### P-8. Dockerfile lacks `HEALTHCHECK` and runs as root
|
||||
|
||||
`Dockerfile:13-19`. No `HEALTHCHECK`, no `USER nonroot`. Both are easy
|
||||
wins for Dokploy's health surface and container hardening.
|
||||
|
||||
```dockerfile
|
||||
# before the CMD
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:8080/ || exit 1
|
||||
RUN addgroup -S paliad && adduser -S -G paliad paliad
|
||||
USER paliad
|
||||
```
|
||||
|
||||
Add a `GET /healthz` route that returns 200 without auth; current `/`
|
||||
redirects and wgets a 200 anyway, but an explicit probe is cleaner.
|
||||
|
||||
### P-9. Landing-page copy still frames Paliad as "Paliad — Patentwissen für Hogan Lovells"
|
||||
|
||||
`frontend/src/client/i18n.ts:38-48`. With Phase 0 shipped, Paliad is
|
||||
more than a knowledge hub — it's the Aktenverwaltung. The hero section
|
||||
should call that out. Proposed: "Paliad — alles für den Patentalltag:
|
||||
Akten, Fristen, Termine und Wissen."
|
||||
|
||||
### P-10. Kostenrechner PDF and Checklisten print — not verified by audit
|
||||
|
||||
The design says PDF export works. I did not test it; please verify
|
||||
after any CSS change that affects `.tool-page` / `.print\:hide`.
|
||||
Consider adding a visual-regression snapshot to the build.
|
||||
|
||||
---
|
||||
|
||||
## 4 — Features (prioritised backlog)
|
||||
|
||||
### F-1. Global search (Ctrl-K / Cmd-K)
|
||||
|
||||
**Impact:** Very High
|
||||
**Effort:** M
|
||||
|
||||
A single keystroke that searches across Akten, Fristen, Termine,
|
||||
Parteien, Glossar, Gerichte, Links. Patent lawyers hop between matters
|
||||
constantly; the current UX requires clicking through 2 sidebar entries
|
||||
to find anything. Index on the backend with `tsvector` + `pg_trgm`, or
|
||||
build a client-side Lunr index for the static content and a single
|
||||
`/api/search?q=…` that fans out for user data. Match by Aktenzeichen,
|
||||
party name, Frist title.
|
||||
|
||||
### F-2. Verfahrensleitfäden (procedure guides)
|
||||
|
||||
**Impact:** Very High (per the original roadmap, never shipped)
|
||||
**Effort:** L
|
||||
|
||||
Step-by-step UPC / BPatG / EPO workflows that stitch together
|
||||
checklists, deadline rules, and recommended templates. This is the
|
||||
biggest original-roadmap item still missing.
|
||||
|
||||
### F-3. Expand the Downloads registry
|
||||
|
||||
**Impact:** High (2/10 on the roadmap, partial)
|
||||
**Effort:** M
|
||||
|
||||
Only `HL Patents Style.dotm` is wired up. mWorkRepo has more
|
||||
templates. Wire `Vollmacht.dotm`, `Unterlassungserklärung.dotm`,
|
||||
`UPC-Klageschrift-Skeleton.dotm` — mirror the existing proxy pattern.
|
||||
|
||||
### F-4. Benachrichtigungen (notifications)
|
||||
|
||||
**Impact:** Medium-High
|
||||
**Effort:** L
|
||||
|
||||
Email digest of today's overdue/due Fristen + tomorrow's Termine, sent
|
||||
at 07:00 CET per user. Supabase pg_cron + Edge Functions or a Go
|
||||
scheduler in the Paliad binary. Opt-in per user.
|
||||
|
||||
### F-5. What's New / Changelog in-app
|
||||
|
||||
**Impact:** Medium
|
||||
**Effort:** S
|
||||
|
||||
`docs/changelog.md` rendered under `/whatsnew` with a red dot on the
|
||||
sidebar when there's an entry newer than the user's `last_seen_changelog`
|
||||
timestamp. Cheap way to communicate feature releases.
|
||||
|
||||
### F-6. Akten-Kurzinfo on hover (pop-over)
|
||||
|
||||
**Impact:** Medium
|
||||
**Effort:** S
|
||||
|
||||
Hovering an Aktenzeichen in any list (Dashboard activity, Fristen,
|
||||
Termine) shows a tooltip with matter title, court, next Frist. Reduces
|
||||
tab-switching.
|
||||
|
||||
### F-7. Parteien mit Kontaktkarten (Update endpoint)
|
||||
|
||||
**Impact:** Medium
|
||||
**Effort:** S
|
||||
|
||||
Currently `ParteienService` only supports Create + Delete. No Update.
|
||||
Typos in a party name force a delete + re-add and lose the ID. Add
|
||||
PATCH `/api/parteien/{id}`.
|
||||
|
||||
### F-8. Fristenrechner — "Als Frist speichern" mit Wiedervorlage
|
||||
|
||||
**Impact:** Medium
|
||||
**Effort:** S
|
||||
|
||||
When creating a Frist with a Rule, also derive a Wiedervorlage-Frist
|
||||
(warning-date = due - 14 d) and offer to create both in one click.
|
||||
|
||||
### F-9. Dark mode
|
||||
|
||||
**Impact:** Low (but free with CSS variables)
|
||||
**Effort:** S
|
||||
|
||||
`global.css` already uses CSS variables heavily. Add a
|
||||
`@media (prefers-color-scheme: dark)` block + a manual toggle in the
|
||||
sidebar.
|
||||
|
||||
### F-10. Document upload without AI (revisit Phase H)
|
||||
|
||||
**Impact:** Medium (still open per memory episode)
|
||||
**Effort:** M
|
||||
|
||||
The Supabase Storage upload path already works; only the AI-extraction
|
||||
part was rejected. A plain "upload PDF to Akte" with no auto-extraction
|
||||
is still useful and unblocks the Dokumente tab.
|
||||
|
||||
### F-11. Akten-Tags / Labels
|
||||
|
||||
**Impact:** Medium
|
||||
**Effort:** M
|
||||
|
||||
Free-form tags on Akten (e.g., `SEP`, `OLG-Düsseldorf`, `Q4-Priority`)
|
||||
with a filter UI. Cheaper than a full custom-fields system and used
|
||||
extensively at comparable firms.
|
||||
|
||||
---
|
||||
|
||||
## 5 — Technical Debt
|
||||
|
||||
### T-1. RLS policies exist but are never enforced
|
||||
|
||||
`internal/db/migrations/007_rls_policies.up.sql` defines full office-
|
||||
scoped RLS keyed off `auth.uid()`. The Go backend uses a service-role
|
||||
connection (`db.OpenPool` with `DATABASE_URL`), so those policies never
|
||||
evaluate — every query bypasses RLS by design.
|
||||
|
||||
This is correct today (service-role means app-level checks must be
|
||||
bulletproof, which they mostly are) but it means the RLS layer is dead
|
||||
weight that increases surface for a migration bug to produce a
|
||||
silently-wrong policy. Decide:
|
||||
|
||||
- **Option A:** accept the "belt-and-braces" position, keep RLS,
|
||||
document loudly that it's not active in prod.
|
||||
- **Option B:** drop the RLS migration and policies — less code, less
|
||||
false sense of security.
|
||||
- **Option C:** switch to PostgREST-style per-request JWT connections so
|
||||
RLS actually runs. This is the most defensive but a substantial
|
||||
rewrite.
|
||||
|
||||
My recommendation: Option A, with an `internal/db/RLS_NOTE.md` and a
|
||||
`SET row_security = off;` (or equivalent) in the service-role
|
||||
connection so the policies don't quietly cost perf.
|
||||
|
||||
### T-2. Go module path still `mgit.msbls.de/m/patholo`
|
||||
|
||||
`go.mod:1` + every `import` statement. Not breaking, but a daily
|
||||
reminder of the old name. Rename on the next big refactor, not now —
|
||||
every file in the repo would churn.
|
||||
|
||||
### T-3. `calDAVClient` hand-rolls iCal + WebDAV
|
||||
|
||||
Documented in the Phase F memory as a deliberate choice. Fine for now,
|
||||
but re-evaluate when any of these lands:
|
||||
- Importing foreign UIDs (currently skipped)
|
||||
- RRULE / VTIMEZONE / DTSTART-with-tzid
|
||||
- Multi-calendar support per user
|
||||
|
||||
At that point switch to `github.com/emersion/go-ical` +
|
||||
`github.com/emersion/go-webdav`.
|
||||
|
||||
### T-4. `_dev/mock_supabase_auth.sql` lives inside `migrations/`
|
||||
|
||||
`internal/db/migrations/_dev/`. Embed.FS includes all files under
|
||||
`migrations/` — the `_dev` directory is probably ignored by
|
||||
`iofs.New` (directories not matching the pattern `NNN_*.sql` are
|
||||
filtered) but it's one hop from a build bug. Move to
|
||||
`internal/db/devtools/` or similar, outside the embed root.
|
||||
|
||||
### T-5. Client-side duplicated helpers
|
||||
|
||||
Memory's Phase E followup notes: `urgency-color` + `date-format` JS
|
||||
helpers are duplicated across `fristen.ts`, `fristen-detail.ts`,
|
||||
`fristen-kalender.ts`, `akten-detail.ts`. Extract to
|
||||
`frontend/src/client/fristen-shared.ts`. Not urgent, but next time
|
||||
anyone touches the Fristen UI: do this first.
|
||||
|
||||
### T-6. `/api/fristen?status=all` returns everything client-side filters
|
||||
|
||||
Per Phase E memory: fine at current volumes, but a partner with 500
|
||||
completed Fristen will see the full 500 on every calendar load. Add
|
||||
server-side date-range filtering before that's a support ticket.
|
||||
|
||||
### T-7. No error monitoring
|
||||
|
||||
The server logs to stdout (which Dokploy captures), but there is no
|
||||
Sentry / log-based alert wiring. Nobody knows when something breaks in
|
||||
prod until a user complains. Options:
|
||||
|
||||
- Wire Supabase log drain (if Dokploy supports it).
|
||||
- Self-host a lightweight OpenTelemetry collector on mlake.
|
||||
- At minimum: `GET /metrics` (Prometheus text) with basic counters, and
|
||||
a cron that posts to Gotify when error-rate crosses a threshold.
|
||||
|
||||
Pick the cheapest now — we can evolve later.
|
||||
|
||||
### T-8. No structured logging
|
||||
|
||||
Mixed `log.Printf` + `slog.Info` across the codebase. Pick one, default
|
||||
to `slog` with a JSON handler in prod. Pre-populate user-id and
|
||||
request-id in the context for correlation.
|
||||
|
||||
### T-9. No backup verification for `paliad` schema
|
||||
|
||||
Supabase's automatic backups cover the whole instance, but
|
||||
`paliad.paliad_schema_migrations` collision history (memory episode
|
||||
"paliad migration bootstrap collision") suggests the shared-Postgres
|
||||
posture is fragile. Add a weekly `pg_dump --schema=paliad` cron on
|
||||
mlake that writes to an offsite bucket and a monthly restore-smoke-test
|
||||
(into an ephemeral Postgres container).
|
||||
|
||||
### T-10. Test coverage gaps
|
||||
|
||||
Only `akte_service_test.go`, `deadline_calculator_test.go`,
|
||||
`holidays_test.go`, `fees_test.go` exist. Missing: `FristService`,
|
||||
`TerminService`, `NotizService`, `CalDAVService` (unit tests for
|
||||
iCal encode / decode, encrypt / decrypt, visibility edge-cases).
|
||||
The CalDAV crypto in particular should have a table-driven test with
|
||||
known vectors.
|
||||
|
||||
### T-11. `internal/models/models.go` is one big file
|
||||
|
||||
Not inherently bad, but it's the dumping ground for 14 types. Split by
|
||||
domain (`user.go`, `akte.go`, `frist.go`, `termin.go`, …) next time it's
|
||||
touched.
|
||||
|
||||
### T-12. Dockerfile build cache hygiene
|
||||
|
||||
Every `COPY . .` invalidates on any source change, forcing a full `go
|
||||
mod download`. Move `go.mod`/`go.sum` copy + `go mod download` *before*
|
||||
`COPY . .` — already done. But also consider `FROM golang:1.24-alpine
|
||||
AS backend` → `golang:1.24` with `CGO_ENABLED=0` so libc-less `alpine`
|
||||
runtime doesn't fight any future pgx upgrade. Minor; today it's fine.
|
||||
|
||||
### T-13. `frontend/build.ts` has 24 hand-maintained render-and-write pairs
|
||||
|
||||
Any new page requires edits in 4 places (build.ts, handlers.go
|
||||
registration, i18n keys, CSS). Consider a page-manifest — an array
|
||||
`[{slug, render, clientEntry}]` — that the build script iterates over.
|
||||
Less ceremony; harder to forget one of the four places.
|
||||
|
||||
### T-14. `Gitea-backed file proxy` has no ETag / If-Modified-Since
|
||||
|
||||
`internal/handlers/files.go` caches bytes and the commit SHA in memory
|
||||
but never sets an `ETag` or `Last-Modified` response header, so every
|
||||
browser re-downloads a 500 KB .dotm on each click. Add:
|
||||
`w.Header().Set("ETag", entry.sha)` + handle `If-None-Match` with a
|
||||
304. Cheap win.
|
||||
|
||||
---
|
||||
|
||||
## Delivery suggestion
|
||||
|
||||
- **Week 1 (critical):** C-1, C-2, C-3, C-4, C-5. All small- to
|
||||
medium-effort and gates production readiness.
|
||||
- **Week 2 (important):** I-1, I-2, I-3, I-7, I-8. Polish the
|
||||
post-merger brand story.
|
||||
- **Week 3 (features, pick 2):** F-1 (global search) gives the biggest
|
||||
daily-use win; F-2 (Verfahrensleitfäden) is the biggest content win;
|
||||
F-3 (Downloads) closes a roadmap item cheaply.
|
||||
- **Always-on (tech debt):** pick one T-item per sprint until they're
|
||||
gone.
|
||||
|
||||
None of the critical items are theoretical — the JWT signature bypass
|
||||
and the dashboard Termine leak are real exploit-paths I could walk
|
||||
through today with a curl command and a Paliad cookie. Fix those two
|
||||
first.
|
||||
Reference in New Issue
Block a user